
Stop Mocking at the Wrong Layer: Server-Side HTTP Interception in Playwright with mockttp
How to mock outgoing server-side HTTP requests in Playwright end-to-end tests using mockttp as a per-worker forward proxy, giving you deterministic control over third-party API calls without touching your application code.
Your E2E Tests Are Lying to You
Imagine you're running a full Playwright end-to-end test suite against your Next.js app. You've got page.route() intercepting browser-side requests beautifully. Your tests pass. You ship. Then production breaks because a third-party API your server calls — not your browser — started returning 503s, and your app handles that by showing users a blank page.
Your tests never caught it. They couldn't. Because page.route() only intercepts requests the browser makes. Every fetch() call inside your API routes, every server-side data fetch in getServerSideProps or a React Server Component — those fly under Playwright's radar completely.
This is a gap that's bitten me more than once. You think you have coverage, but you're only testing half the HTTP surface. The server-side half — the half that talks to payment processors, auth providers, and external data sources — remains completely unmocked and untested in your E2E layer.
A post published last week on the Playwright dev blog lays out a clean pattern for solving this: using mockttp as a per-worker forward proxy that intercepts your server's outgoing HTTP. Let's break down why this matters, how it works under the hood, and how to set it up properly.
The Problem: Two HTTP Layers, One Mock Strategy
Playwright's built-in page.route() API is excellent for what it does. It sits between the browser and the network, letting you intercept, modify, or stub any request the page makes. But modern web apps don't just make requests from the browser.
Server-side rendering, API routes, server actions, React Server Components — all of these make HTTP calls from your Node.js server process. These requests never touch the browser's network stack. They go directly from your server to the external service.
So when your Next.js API route calls Stripe's API, or your server component fetches data from a CMS, or your auth middleware validates a token against an OAuth provider — none of that is interceptable with page.route(). You're flying blind.
The traditional workarounds are ugly. You could mock at the module level with jest.mock(), but then you're not running real E2E tests anymore. You could spin up a fake server and hardcode its URL, but that's brittle and doesn't scale across parallel workers. You could use environment variables to point at different backends in test mode, but that means your test environment diverges from production.
What you actually want is a transparent forward proxy that sits between your server and the internet, intercepting and stubbing responses without your application code knowing anything about it.
How mockttp Solves This
mockttp is an HTTP toolkit from the folks behind HTTP Toolkit. It can act as a forward proxy — meaning your server sends requests through it, and mockttp can intercept, inspect, and respond to those requests based on rules you define.
The architecture looks like this:
- Each Playwright worker spins up its own mockttp proxy on a random port
- Your server process is configured to use that proxy via
HTTP_PROXY/HTTPS_PROXYenvironment variables - In your tests, you define rules: "when the server tries to call
api.stripe.com/v1/charges, respond with this JSON" - Your server makes requests normally — it doesn't know it's being proxied
- mockttp intercepts matching requests and returns your stubbed responses
The key insight is that this works at the network level, not the code level. Your application code doesn't change. No dependency injection, no test-only code paths, no conditional imports.
Technical Deep-Dive: Setting Up the Proxy
Let's walk through the implementation. First, you need mockttp installed:
npm install --save-dev mockttpThis gives you the mockttp library which can create proxy servers programmatically within your test process.
Next, you set up a Playwright fixture that creates a per-worker proxy. This is critical — per-worker, not per-test and not shared globally. Per-worker means parallel test execution works without proxy rules from one test bleeding into another.
Here's the fixture setup that creates the proxy server scoped to each Playwright worker:
// fixtures.ts
import { test as base } from '@playwright/test';
import * as mockttp from 'mockttp';
type MockFixtures = {
mockServer: mockttp.Mockttp;
};
export const test = base.extend<MockFixtures>({
mockServer: [async ({}, use) => {
const server = mockttp.getLocal({
cors: false,
});
await server.start();
await use(server);
await server.stop();
}, { scope: 'worker' }],
});The { scope: 'worker' } part is what makes this work with parallel execution. Each Playwright worker gets its own proxy instance on its own port. No collisions.
Now here's where it gets interesting. You need your server to actually route traffic through this proxy. The standard way is via environment variables. In your Playwright config, you'd set HTTP_PROXY and HTTPS_PROXY to point at the mockttp instance.
But there's a chicken-and-egg problem: the proxy port is dynamic (assigned at server start), and your web server needs to know it before it starts. The solution is to use Playwright's webServer config with environment variable forwarding:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
webServer: {
command: 'npm run dev',
port: 3000,
env: {
HTTP_PROXY: 'http://127.0.0.1:${MOCK_PORT}',
HTTPS_PROXY: 'http://127.0.0.1:${MOCK_PORT}',
NODE_TLS_REJECT_UNAUTHORIZED: '0',
},
},
});That NODE_TLS_REJECT_UNAUTHORIZED: '0' is necessary because mockttp uses its own CA certificate to intercept HTTPS traffic. In a test environment, this is fine. In production, obviously not. This is the same principle as how tools like mitmproxy or Burp Suite intercept HTTPS — they perform TLS termination with a custom CA.
Important: The
NODE_TLS_REJECT_UNAUTHORIZED=0setting should ONLY exist in your test environment. Never let this leak into production configuration.
Now, in your actual test, you define interception rules before triggering the server-side request:
import { test } from './fixtures';
import { expect } from '@playwright/test';
test('handles payment API failure gracefully', async ({ page, mockServer }) => {
// Intercept the server's outgoing call to Stripe
await mockServer.forGet('https://api.stripe.com/v1/charges')
.thenReply(503, JSON.stringify({
error: { message: 'Service temporarily unavailable' }
}));
// Now navigate — the server will try to call Stripe,
// hit our proxy, and get the 503
await page.goto('/dashboard/payments');
// Verify the app handles the failure correctly
await expect(page.getByText('Payment service temporarily unavailable'))
.toBeVisible();
});This is the payoff. You're testing the actual behavior of your app when a third-party service fails. Not a unit test with mocked modules. Not a browser-only intercept that misses server calls. A real end-to-end test that exercises the full request path.
The root cause of why page.route() can't do this is architectural: Playwright's network interception hooks into the browser's network stack via the Chrome DevTools Protocol (or equivalent for other browsers). Server-side Node.js processes don't speak CDP. They speak HTTP, and the way you intercept HTTP from a process you don't control is with a proxy.
Handling HTTPS and Certificate Trust
The HTTPS interception deserves more attention because it's where most people get stuck. When your server makes an HTTPS request through a forward proxy, the proxy needs to perform a man-in-the-middle on the TLS connection. mockttp generates a CA certificate for this purpose.
You have two options for handling this. The blunt approach is NODE_TLS_REJECT_UNAUTHORIZED=0, which disables all certificate validation. It works but it's a sledgehammer.
The cleaner approach is to trust mockttp's CA certificate explicitly:
// fixtures.ts — enhanced version
import { test as base } from '@playwright/test';
import * as mockttp from 'mockttp';
import { writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
export const test = base.extend<{}, { mockServer: mockttp.Mockttp; proxyCA: string }>({
mockServer: [async ({}, use) => {
const https = await mockttp.generateCACertificate();
const server = mockttp.getLocal({ https });
await server.start();
// Write CA cert to temp file for NODE_EXTRA_CA_CERTS
const caPath = join(tmpdir(), `mockttp-ca-${process.pid}.pem`);
writeFileSync(caPath, https.cert);
await use(server);
await server.stop();
}, { scope: 'worker' }],
});Then you'd set NODE_EXTRA_CA_CERTS to point at that temp file instead of disabling TLS validation entirely. This is more production-like and won't mask real certificate issues in your application code.
Detecting Where You Need This
How do you know if your app makes server-side HTTP calls that your current tests aren't covering? Here's a quick way to audit.
Grep your server-side code for outgoing HTTP calls:
# Find all fetch/axios/got calls in server-side code
grep -rn "fetch(\|axios\|got(\|http.request\|https.request" \
--include="*.ts" --include="*.js" \
src/app/api/ src/lib/ src/server/ pages/api/
# For Next.js App Router — server components make server-side fetches
grep -rn "fetch(" --include="*.tsx" --include="*.ts" \
src/app/ | grep -v "'use client'"Every hit in server-side code is a request that page.route() cannot intercept. If you have tests that exercise those code paths but only mock at the browser level, you have a coverage gap.
You can also verify the proxy is working by adding a passthrough rule with logging:
// Debug: log all requests passing through the proxy
await mockServer.forAnyRequest().thenPassThrough();
mockServer.on('request', (req) => {
console.log(`[proxy] ${req.method} ${req.url}`);
});Run your tests with this in place and you'll see exactly which server-side requests are flowing through the proxy. That's your interception surface.
Impact: Who Needs This
This pattern matters most for apps with significant server-side logic. If you're building a static site or a pure SPA that does everything client-side, page.route() is probably sufficient.
But if you're using any of these patterns, you need server-side HTTP mocking:
- Next.js App Router with Server Components that fetch data
- API routes that call external services (payment, email, auth)
- Server-side rendering that hydrates with data from third-party APIs
- BFF (Backend for Frontend) patterns where your server aggregates multiple APIs
- Webhook handlers that make outgoing calls in response to incoming events
The blast radius of not having this is subtle but dangerous. Your tests pass because the browser-side behavior looks correct with your page.route() mocks, but you're never testing what happens when the server-side calls fail, return unexpected data, or are slow. You're testing the happy path of a system you don't fully control.
Setting It Up: Complete Working Example
Here's a more complete setup that handles the port coordination problem properly. The trick is using Playwright's worker-scoped fixtures to start the proxy first, then pass the port to your server:
// e2e/fixtures.ts
import { test as base } from '@playwright/test';
import * as mockttp from 'mockttp';
type WorkerFixtures = {
mockProxy: mockttp.Mockttp;
};
type TestFixtures = {
mock: mockttp.Mockttp;
};
export const test = base.extend<TestFixtures, WorkerFixtures>({
mockProxy: [async ({}, use) => {
const server = mockttp.getLocal();
await server.start();
// Set env vars so the app server uses our proxy
process.env.HTTP_PROXY = `http://127.0.0.1:${server.port}`;
process.env.HTTPS_PROXY = `http://127.0.0.1:${server.port}`;
await use(server);
await server.stop();
}, { scope: 'worker' }],
// Per-test fixture that resets rules between tests
mock: async ({ mockProxy }, use) => {
await use(mockProxy);
mockProxy.reset();
},
});The mockProxy.reset() call after each test is crucial. Without it, rules from one test persist into the next, creating flaky tests that depend on execution order. This is the same principle as clearing mocks between tests in unit testing — isolation matters.
For the fix/mitigation side, if you're on Vercel and your server-side functions need to respect proxy settings, be aware that Vercel's Edge Runtime doesn't support HTTP_PROXY environment variables the same way Node.js does. You'll need to ensure your tests run against the Node.js runtime, not the Edge runtime:
// next.config.js — force Node.js runtime for testability
/** @type {import('next').NextConfig} */
module.exports = {
// In test environment, use Node.js runtime for all routes
...(process.env.TEST_ENV && {
experimental: {
runtime: 'nodejs',
},
}),
};For nginx reverse proxy setups where your app sits behind nginx, the proxy configuration is irrelevant — it's your app's outgoing requests you're intercepting, not incoming ones. The mockttp proxy sits between your app and the external internet, not between the client and your app.
Pro tip: If your app uses
undici(Node.js's built-in fetch uses undici under the hood), you may need to set theGLOBAL_AGENT_HTTP_PROXYenvironment variable as well, or use theglobal-agentpackage to ensure proxy settings are respected.
Verifying Your Setup Works
After setting everything up, verify the proxy is actually intercepting requests. Here's a quick smoke test:
test('proxy intercepts server-side requests', async ({ page, mock }) => {
// Set up a rule that should only trigger on server-side fetch
const endpointMock = await mock
.forGet('https://jsonplaceholder.typicode.com/posts/1')
.thenReply(200, JSON.stringify({ id: 1, title: 'Mocked!' }));
// Trigger a page load that causes a server-side fetch to this URL
await page.goto('/test-endpoint');
// Verify the mock was actually hit
const requests = await endpointMock.getSeenRequests();
expect(requests.length).toBe(1);
});If requests.length is 0, your proxy isn't being used. Check that the environment variables are set before your server process starts, and that your HTTP client respects proxy settings.
The Bigger Picture
This pattern — using a forward proxy for test-time HTTP interception — isn't new. It's how tools like VCR (Ruby), Polly.js, and WireMock have worked for years. What's new is having a clean integration path with Playwright's fixture system that handles parallel workers correctly.
The way I see it — the trend toward server-side rendering (RSC, server actions, streaming SSR) means more HTTP happens on the server and less in the browser. Our testing tools need to follow that shift. If your E2E test framework can only intercept browser-side requests, you're testing an increasingly small slice of your application's actual behavior.
This also connects to a broader principle: test at the boundary, not at the implementation. Mocking at the module level (jest.mock('stripe')) tests your code's interaction with a fake. Mocking at the network level tests your code's interaction with realistic HTTP responses — timeouts, malformed JSON, unexpected status codes, slow responses. That's where production bugs actually live.
The next time you're writing Playwright tests and reach for page.route(), ask yourself: is this request coming from the browser, or from my server? If it's the server, you need a different tool. Now you have one.
Related posts
- Automation
CloakBrowser: I tested it against 5 bot detectors — here's what happened
CloakBrowser claims to be a stealth Chromium that passes every bot detection test. I installed it, ran it against reCAPTCHA v3, Cloudflare Turnstile, and FingerprintJS to see if the hype is real.
May 19, 2026 · 8 min - Automation
Automating web3 workflows at scale — a sanitized case study
How I built custom tooling to manage hundreds of wallets, automate on-chain transactions, and run social bots across multiple protocols.
May 18, 2026 · 10 min