Mocking Server Side HTTP in Playwright with mockttp
Network Boundary Mocking: Intercepting Server-Side Calls in Playwright E2E Tests
Current Situation Analysis
End-to-end (E2E) testing often hits a wall when the application under test (AUT) makes outbound HTTP requests to third-party services. Playwright's native page.route() API is excellent for intercepting browser-initiated requests, but it operates entirely within the browser context. It cannot intercept network calls originating from the server-side runtime.
Developers typically resort to one of two flawed strategies:
- Code Branching: Injecting conditional logic like
if (process.env.NODE_ENV === 'test')directly into business logic to return stubbed data. This pollutes production code, increases cyclomatic complexity, and creates a divergence between tested and production behavior. - Shared Mock Servers: Running a separate mock service that all tests share. This introduces state leakage between tests, requires complex synchronization, and makes parallel execution difficult.
The industry standard for unit testing relies on dependency injection and mock libraries, but E2E tests require the full stack to run. The missing link is a mechanism to intercept network traffic at the OS or runtime boundary without modifying application code.
WOW Moment: Key Findings
By shifting the mocking strategy from the application layer to the network boundary, you gain isolation, realism, and zero code intrusion. The following comparison highlights why a forward-proxy approach outperforms traditional methods in E2E scenarios.
| Strategy | Code Purity | Test Isolation | Network Realism | Parallel Safety | Maintenance Overhead |
|---|---|---|---|---|---|
| Code Branching | β Low | β Low | β Low | β High | π΄ High |
| Shared Mock Server | β High | β Low | β High | β Low | π‘ Medium |
| Network Proxy | β High | β High | β High | β High | π’ Low |
Why this matters: The Network Proxy approach treats the mock as infrastructure rather than code. Each Playwright worker spins up an isolated proxy instance. Tests define mocks inline, ensuring that a failure in one test cannot affect another. The application code remains identical to production, as it simply makes standard HTTP calls that the proxy transparently intercepts.
Core Solution
The solution leverages a forward proxy pattern. A local proxy server sits between the application and the external network. The application is configured to route traffic through this proxy via environment variables. The proxy evaluates each request against a set of rules defined by the test: return a canned response, modify the payload, or pass the request through to the real internet.
We use mockttp for this implementation. It provides an in-process proxy, dynamic TLS certificate generation, and a fluent API for rule definition.
Architecture Decisions
- Worker-Scoped Proxy: Playwright runs tests in parallel workers. Each worker must have its own proxy instance to prevent mock collisions. The proxy lifecycle is tied to the worker.
- Test-Scoped Rules: Mock definitions are reset after every test. This guarantees a clean slate and prevents state leakage.
- Process Spawning: The application must be spawned as a child process from within the test fixture. This allows us to inject per-worker environment variables (
HTTPS_PROXY,NODE_EXTRA_CA_CERTS) before the application starts. - Dynamic Port Allocation: Hardcoded ports cause collisions in parallel runs. The application is started with
PORT=0, allowing the OS to assign a free port. The fixture parses the port from the application's stdout.
Implementation
1. Fixture Configuration
Create a custom fixture file that manages the proxy lifecycle and application spawning.
// tests/fixtures/network-interceptor.ts
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import * as childProcess from "node:child_process";
import { test as base } from "@playwright/test";
import * as mockttp from "mockttp";
// Define fixture types
type NetworkInterceptor = mockttp.Mockttp;
type AppRunner = { baseURL: string };
export const test = base.extend<
{ interceptor: NetworkInterceptor },
{ proxyServer: { instance: mockttp.Mockttp; certPath: string }; appRunner: AppRunner }
>({
// Worker-scoped: One proxy per worker
proxyServer: [async ({}, use) => {
const ca = await mockttp.generateCACertificate();
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "e2e-proxy-"));
const certPath = path.join(tempDir, "ca.pem");
await fs.writeFile(certPath, ca.cert);
const instance = mockttp.getLocal({
https: { cert: ca.cert, key: ca.key },
});
await instance.start();
// Default behavior: pass unmatched requests through
await instance.forUnmatchedRequest().thenPassThrough();
await use({ instance, certPath });
await instance.stop();
await fs.rm(tempDir, { recursive: true, force: true });
}, { scope: "worker" }],
// Test-scoped: Clean mocks for every test
interceptor: async ({ proxyServer }, use) => {
await use(proxyServer.instance);
// Reset rules and restore passthrough
await proxyServer.instance.reset();
await proxyServer.instance.forUnmatchedRequest().thenPassThrough();
},
// Spawns the app with proxy env vars
appRunner: async ({ proxyServer }, use) => {
const child = childProcess.spawn("npm", ["run", "dev"], {
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
HTTP_PROXY: proxyServer.instance.url,
HTTPS_PROXY: proxyServer.instance.url,
NODE_USE_ENV_PROXY: "1", // Required for Node 20+ fetch
NODE_EXTRA_CA_CERTS: proxyServer.certPath,
PORT: "0", // Request dynamic port
},
});
const baseURL = await new Promise<string>((resolve, reject) => {
let outputBuffer = "";
child.stdout!.on("data", (chunk) => {
outputBuffer += chunk.toString();
// Adjust regex based on your server's startup log format
const match = outputBuffer.match(/Server listening on (https?:\/\/\S+)/);
if (match) {
resolve(match[1]);
}
});
child.once("exit", (code) => {
if (code !== 0) {
reject(new Error(`App exited with code ${code}`));
}
});
});
await use({ baseURL });
child.kill("SIGTERM");
},
// Override baseURL so page.goto() works automatically
baseURL: async ({ appRunner }, use) => {
await use(appRunner.baseURL);
},
});
export { expect } from "@playwright/test";
2. Writing Tests
Mocks are defined inline within the test. The test owns the mock definition, ensuring clarity and isolation.
// tests/e2e/checkout.spec.ts
import { test, expect } from "./fixtures/network-interceptor";
test("processes payment via mocked gateway", async ({ page, interceptor }) => {
// Define mock before navigating
const paymentEndpoint = await interceptor
.forPost("https://api.payment-gateway.com/v1/charge")
.thenJson(200, {
status: "succeeded",
transaction_id: "txn_mock_123",
});
await page.goto("/checkout");
await page.getByRole("button", { name: "Pay Now" }).click();
await expect(page.getByText("Payment Successful")).toBeVisible();
// Verify the request was made
const requests = await paymentEndpoint.getSeenRequests();
expect(requests).toHaveLength(1);
expect(requests[0].body).toContain("amount=5000");
});
3. Advanced: RPC Channel for Non-HTTP State
The proxy can mock arbitrary URLs to inject state into the application without modifying code. This is useful for time-sensitive logic or feature flags.
Application code:
// src/services/time-service.ts
export async function getNow(): Promise<Date> {
// In test mode, fetch time from the proxy
if (process.env.NODE_ENV === "test") {
const res = await fetch("http://test-harness/clock");
return new Date(await res.text());
}
return new Date();
}
Test code:
test("handles subscription renewal", async ({ page, interceptor }) => {
await interceptor
.forGet("http://test-harness/clock")
.thenReply(200, "2025-12-31T23:59:59Z");
await page.goto("/subscription");
// App believes it is New Year's Eve
await expect(page.getByText("Renewal Due")).toBeVisible();
});
Pitfall Guide
| Pitfall | Explanation | Fix |
|---|---|---|
| Silent Network Leaks | The default thenPassThrough() allows unmocked requests to hit the real internet. This can cause flaky tests or accidental charges. |
In strict mode, replace passthrough with thenReply(503, "Mock Missing") to fail fast on unexpected requests. |
webServer Config Trap |
Playwright's webServer config starts the app before fixtures run. You cannot inject per-worker proxy environment variables. |
Always spawn the app manually within a worker-scoped fixture to control the environment. |
| Certificate Trust Failure | The application rejects the proxy's TLS certificate, causing UNABLE_TO_VERIFY_LEAF_SIGNATURE errors. |
Ensure NODE_EXTRA_CA_CERTS points to the generated CA file. For other runtimes, configure the trust store accordingly. |
| Port Collisions | Hardcoding PORT=3000 causes failures when multiple workers run in parallel. |
Use PORT=0 and parse the assigned port from stdout. Update baseURL dynamically. |
| Reset Oversights | Calling interceptor.reset() clears all rules, including the passthrough rule. Subsequent tests may fail to reach real services. |
Re-add forUnmatchedRequest().thenPassThrough() immediately after reset() in the teardown hook. |
| In-Process Proxying | If the app runs in the same process as the test runner, environment variables may not affect all HTTP clients. | Use undici's ProxyAgent programmatically, or ensure the app runs as a separate child process. |
| Node 20+ Fetch Ignorance | Node's built-in fetch ignores HTTPS_PROXY by default in newer versions. |
Set NODE_USE_ENV_PROXY=1 in the environment variables. |
Production Bundle
Action Checklist
- Install
mockttpas a dev dependency:npm install -D mockttp. - Create
tests/fixtures/network-interceptor.tswith the proxy and app runner fixtures. - Verify your application logs the listening URL on startup for port parsing.
- Update
playwright.config.tsto remove thewebServeroption if present. - Write a test that mocks a third-party API and asserts the UI response.
- Run tests in parallel (
workers: 4) to verify isolation. - Audit mocks for sensitive data; ensure no real credentials are logged.
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Frontend-only interactions | page.route() |
Simpler API, no proxy overhead. | None |
| Server-side API calls | Network Proxy | Intercepts all outbound traffic transparently. | Low (Dev dependency) |
| Unit testing logic | Jest/Vitest mocks | Faster execution, granular control. | None |
| Integration with real services | Passthrough mode | Validates end-to-end flow with real network. | API usage costs |
| Time/Clock dependencies | RPC Channel via Proxy | Injects state without code changes. | Low |
Configuration Template
Copy this template into your project to bootstrap network boundary mocking.
// tests/fixtures/proxy.ts
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import * as childProcess from "node:child_process";
import { test as base } from "@playwright/test";
import * as mockttp from "mockttp";
export const test = base.extend<
{ proxy: mockttp.Mockttp },
{ proxyManager: { server: mockttp.Mockttp; cert: string }; runner: { url: string } }
>({
proxyManager: [async ({}, use) => {
const ca = await mockttp.generateCACertificate();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "proxy-"));
const cert = path.join(dir, "ca.pem");
await fs.writeFile(cert, ca.cert);
const server = mockttp.getLocal({ https: { cert: ca.cert, key: ca.key } });
await server.start();
await server.forUnmatchedRequest().thenPassThrough();
await use({ server, cert });
await server.stop();
await fs.rm(dir, { recursive: true, force: true });
}, { scope: "worker" }],
proxy: async ({ proxyManager }, use) => {
await use(proxyManager.server);
await proxyManager.server.reset();
await proxyManager.server.forUnmatchedRequest().thenPassThrough();
},
runner: async ({ proxyManager }, use) => {
const proc = childProcess.spawn("npm", ["start"], {
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
HTTP_PROXY: proxyManager.server.url,
HTTPS_PROXY: proxyManager.server.url,
NODE_USE_ENV_PROXY: "1",
NODE_EXTRA_CA_CERTS: proxyManager.cert,
PORT: "0",
},
});
const url = await new Promise<string>((resolve, reject) => {
let buf = "";
proc.stdout!.on("data", (d) => {
buf += d.toString();
const m = buf.match(/Listening on (https?:\S+)/);
if (m) resolve(m[1]);
});
proc.once("exit", (c) => reject(new Error(`Exit ${c}`)));
});
await use({ url });
proc.kill("SIGTERM");
},
baseURL: async ({ runner }, use) => {
await use(runner.url);
},
});
export { expect } from "@playwright/test";
Quick Start Guide
- Initialize: Run
npm install -D mockttp. - Add Fixture: Save the configuration template as
tests/fixtures/proxy.ts. - Import: In your test file, import
testandexpectfrom./fixtures/proxy. - Define Mock: Use
await proxy.forPost("...").thenJson(200, { ... })at the start of your test. - Execute: Run
npx playwright test. The app starts with the proxy active, mocks are applied, and results are verified.
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
