Mocking Server Side HTTP in Playwright with mockttp —…
    Neura MarketNeura Market/CoPilot
    ChatGPTChatGPTClaudeClaudeGeminiGeminiCursorCursorGrokGrokPerplexityPerplexityCoPilotCoPilot
    DeepSeekDeepSeekStable DiffusionStable DiffusionMidjourneyMidjourney
    View All Directories
    OverviewRulesPromptsMCPsAgentsGamesBlogVideosGuidesCoursesCommunityPluginsTrending
    CoPilotBlogMocking Server Side HTTP in Playwright with mockttp
    Back to Blog
    Mocking Server Side HTTP in Playwright with mockttp
    playwright

    Mocking Server Side HTTP in Playwright with mockttp

    Simon Knott May 22, 2026
    0 views

    Mock outgoing HTTP from your server in Playwright tests by running a per-worker mockttp forward proxy.


    title: Mocking Server Side HTTP in Playwright with mockttp published: true description: Mock outgoing HTTP from your server in Playwright tests by running a per-worker mockttp forward proxy. tags: playwright, testing, typescript, mockttp

    cover_image: https://direct_url_to_image.jpg

    Use a ratio of 100:42 for best results.

    published_at: 2026-05-22 13:09 +0000


    I've been building a little recipe app for my household. Along the way I needed to mock out third-party APIs in my Playwright tests. For browser requests that's page.route(), and it works wonderfully. For server-side HTTP calls, it doesn't.

    I tried mock-only branches inside the server code. That got messy fast, especially when the same API had to behave differently in different tests. So I went looking for something cleaner.

    I landed on a forward proxy that intercepts outgoing traffic from the server, configured from the test code. Here's how it works.

    The Idea

    Instead of patching modules or adding if (isUnderTest) branches, we mock at the network boundary. The app makes the same HTTPS calls it always makes. A forward proxy sits in front and decides whether to answer with a canned response or pass the request through.

    What makes this work is the HTTP_PROXY / HTTPS_PROXY env var convention. Almost every HTTP client honours it.

    I use mockttp as the proxy. It generates a TLS certificate on the fly, runs an HTTP/HTTPS forward proxy in-process, and exposes a fluent rule builder for mocks and interception. We start one per Playwright worker, and every test configures it as it likes.

    The snippets below show a Node dev server, but none of this is Node-specific. Any language whose HTTP client respects HTTP_PROXY / HTTPS_PROXY works the same way: Python, Go, Ruby, Rust, .NET. Java needs -Dhttps.proxyHost / -Dhttps.proxyPort instead of env vars. You'll also need to teach each runtime to trust mockttp's certificate: SSL_CERT_FILE for Python and Ruby, SSL_CERT_DIR for Go, and so on.

    Setting Up the Fixture

    We set up mockttp as a Playwright custom fixture, so every test gets a clean, isolated set of mocks. Here's the whole integration in one file:

    // tests/fixtures.ts
    import * as fs from "node:fs/promises";
    import { tmpdir } from "node:os";
    import { join } from "node:path";
    import { test as base } from "@playwright/test";
    import * as mockttp from "mockttp";
    
    export const test = base.extend<
      { mocks: mockttp.Mockttp },
      { mockttp: { server: mockttp.Mockttp; caCertPath: string } }
    >({
      mockttp: [async ({}, use) => {
        const ca = await mockttp.generateCACertificate();
        const dir = await fs.mkdtemp(join(tmpdir(), "mockttp-ca-"));
        const caCertPath = join(dir, "ca.pem");
        await fs.writeFile(caCertPath, ca.cert);
    
        const server = mockttp.getLocal({ https: { cert: ca.cert, key: ca.key } });
        await server.start();
        await server.forUnmatchedRequest().thenPassThrough();
        await use({ server, caCertPath });
        await server.stop();
      }, { scope: "worker" }],
    
      mocks: async ({ mockttp }, use) => {
        await use(mockttp.server);
        await mockttp.server.reset();
        await mockttp.server.forUnmatchedRequest().thenPassThrough();
      },
    });
    
    export { expect } from "@playwright/test";
    

    A quick tour of the moving parts:

    1. The mockttp fixture is worker-scoped and returns both the server and the certificate path. One mockttp server and one TLS certificate per Playwright worker.
    2. The mocks fixture is test-scoped and exposes the server directly, so tests can write mocks.forPost(...). Each test gets a clean rule set thanks to mockttp.server.reset() on teardown.
    3. forUnmatchedRequest().thenPassThrough() is our default, so requests we didn't mock still go through. reset() clears it too, so we re-add it after.

    Spinning Up the Dev Server

    For mocking to work, the dev server has to know about the mockttp instance. It needs HTTPS_PROXY pointing at our proxy and NODE_EXTRA_CA_CERTS pointing at our certificate, both of which are per-worker. So we spawn it ourselves from a worker fixture, pass in the right env, and override baseURL so page.goto("/") still works.

    // tests/fixtures.ts
    import * as childProcess from "node:child_process";
    // ...other imports as above
    
    export const test = base.extend<
      { mocks: mockttp.Mockttp },
      {
        mockttp: { server: mockttp.Mockttp; caCertPath: string };
        devServer: { baseURL: string };
      }
    >({
      // ...mockttp and mocks fixtures from above...
    
      devServer: [async ({ mockttp }, use) => {
        const child = childProcess.spawn(
          "npm", ["start"],
          {
            stdio: ["ignore", "pipe", "pipe"],
            env: {
              ...process.env,
              HTTP_PROXY: mockttp.server.url,
              HTTPS_PROXY: mockttp.server.url,
              NODE_USE_ENV_PROXY: "1",
              NODE_EXTRA_CA_CERTS: mockttp.caCertPath,
              PORT: "0",
            },
          },
        );
        const baseURL = await new Promise<string>((resolve, reject) => {
          let buf = "";
          child.stdout!.on("data", (chunk) => {
            buf += chunk.toString();
            const m = buf.match(/Listening on (https?:\S+)/);
            if (m) resolve(m[1]);
          });
          child.once("exit", (code) =>
            reject(new Error(`dev server exited with code ${code}`)),
          );
        });
        await use({ baseURL });
        child.kill("SIGTERM");
      }, { scope: "worker" }],
    
      baseURL: async ({ devServer }, use) => {
        await use(devServer.baseURL);
      },
    });
    

    A tour of the env vars we pass in:

    1. HTTP_PROXY and HTTPS_PROXY point at the worker's mockttp via server.url.
    2. NODE_USE_ENV_PROXY=1 makes Node's built-in fetch honor HTTPS_PROXY (Node 20+).
    3. NODE_EXTRA_CA_CERTS only accepts a file path, which is why the worker fixture writes the certificate to one.
    4. PORT: "0" lets the OS hand the dev server a free port, so workers don't collide. We parse the actual port out of stdout once the server is up.

    Why Not Playwright's webServer?

    Playwright ships a webServer config option that boots your app before tests run:

    // playwright.config.ts
    export default defineConfig({
      webServer: {
        command: "npm start",
        port: 5173,
      },
    });
    

    It's convenient, but it starts one process for the whole test run, before any worker fixture has a chance to set up. The test worker that starts the proxy doesn't exist yet at that point, so webServer can't pick up HTTPS_PROXY or NODE_EXTRA_CA_CERTS. That's why we spawn the server from a fixture instead.

    Writing a Test

    Now the fun part. Mocks live right next to the assertions that depend on them:

    // tests/recipes.spec.ts
    import { test, expect } from "./fixtures";
    
    test("summarises a recipe", async ({ page, mocks }) => {
      const endpoint = await mocks
        .forPost("https://api.openai.com/v1/chat/completions")
        .thenJson(200, { choices: [{ message: { content: "Pasta." } }] });
    
      await page.goto("/recipes/123");
      await page.getByRole("button", { name: "Summarise" }).click();
      await expect(page.getByText("Pasta.")).toBeVisible();
    
      expect(await endpoint.getSeenRequests()).toHaveLength(1);
    });
    

    No shared mocks/ directory, no "which canned response is this test using?" question. The test owns its mocks.

    Beyond HTTP: A Test Channel for Anything

    Here's a neat trick once you have this set up. The proxy isn't just for real third-party APIs. It can act as a general-purpose RPC channel between your server code and the test runner. Anything that's awkward to mock (clocks, random IDs, feature flags, filesystem state) can be replaced with a small HTTP call to a fictitious http://playwright/... URL when the app is under test:

    // app/server/clock.ts
    export async function now(): Promise<Date> {
      // 👉 use whatever env var your project sets in test mode
      if (process.env.NODE_ENV === "test") {
        const res = await fetch("http://playwright/clock");
        return new Date(await res.text());
      }
      return new Date();
    }
    

    Then in the test, you respond to it:

    // tests/billing.spec.ts
    import { test } from "./fixtures";
    
    test("renews subscription on the day it expires", async ({ page, mocks }) => {
      await mocks
        .forGet("http://playwright/clock")
        .thenReply(200, "2030-01-15T00:00:00Z");
    
      // …drive the UI; the server now thinks it's January 15th.
    });
    

    Gotchas

    A few more notes:

    1. Forgotten mocks hit the real network. Consider swapping the passthrough default for a loud thenReply(599, …).
    2. In-process apps need undici's ProxyAgent instead of the env vars. The env-variable trick only works for child processes.
    3. mockttp has an admin-server / remote control mode. If you can't spawn the app from inside your worker process, you can connect to a mockttp instance running in a separate process.
    4. mockttp.reset() clears everything, including the passthrough. Remember to re-add it on teardown.

    That's it. Happy mocking!

    Tags

    playwrighttestingtypescriptmockttp

    Comments

    More Blog

    View all
    Five Gemma-4 models, one accelerator: what porting E2B 31B to AWS Inferentia2 taught megemma

    Five Gemma-4 models, one accelerator: what porting E2B 31B to AWS Inferentia2 taught me

    I ported the whole Gemma-4 family — E2B, E4B, 12B, 31B, and the 26B-A4B MoE — to run on...

    X
    xbill
    Hey DEV, I'm Tobore. Let's actually connect.community

    Hey DEV, I'm Tobore. Let's actually connect.

    Hey DEV, I'm Tobore. Let's actually connect. I've been on here for a while now, mostly writing and...

    L
    Laurina Ayarah
    I burned through thousands of AI tokens. Then a friend did it for freeai

    I burned through thousands of AI tokens. Then a friend did it for free

    (yep, kinda clickbait, just for the funsies 😊) At the beginning of the year, I relaunched my...

    P
    Paulo Henrique
    Claude might be saturating your machineai

    Claude might be saturating your machine

    My laptop was sitting idle with the fan at full tilt. Nothing was running that I knew of. The culprit...

    S
    Sidhant Panda
    Automated GitHub Code Reviews Using Google Geminigithubactions

    Automated GitHub Code Reviews Using Google Gemini

    I Built a Thing! TL;DR — Google Gemini-based Pull Request reviews and Issue Triaging for...

    D
    Darren "Dazbo" Lester
    What is an "agentic harness," actually?ai

    What is an "agentic harness," actually?

    I've been hearing the word "harness" thrown around a lot lately. I assumed it just meant "the IDE" or...

    T
    Tilde A. Thurium

    Stay up to date

    Get the latest CoPilot prompts, rules, and resources delivered to your inbox weekly.

    Neura Market LogoNeura Market

    Discover the best AI prompts, plugins, and resources for CoPilot and more.

    Content Types

    • Rules
    • Prompts
    • MCPs
    • Agents
    • Guides

    Platforms

    • ChatGPT Directory
    • Claude Directory
    • Gemini Directory
    • Cursor Directory
    • Grok Directory
    • Perplexity Directory
    • DeepSeek Directory
    • CoPilot Directory
    • Stable Diffusion Directory
    • Midjourney Directory
    • All Directories

    Resources

    • Blog
    • Documentation
    • Help Center
    • Marketplace

    Legal

    • Privacy Policy
    • Terms of Service

    © 2026 Neura Market. All rights reserved.

    |

    Not affiliated with any AI platform vendors.

    Neura Market

    Custom AI Systems & Services

    Our team of experienced AI builders will help build custom AI systems, workflows, and solutions for your business.

    Request custom work

    Ready-made automations for this

    Workflows from the Neura Market marketplace related to this CoPilot resource

    • Extract Text from Images & PDFs via Telegram with Mistral OCR to Markdownn8n · $24.99 · Related topic
    • Microsoft Outlook AI Email Assistant with Contact Support from Monday and Airtablen8n · $14.99 · Related topic
    • Cold Email Icebreakers from Local Business Search with GP-4 and Dumpling AIn8n · $9.99 · Related topic
    • eBay Enhances Data Access for AI Agents with MCP Server Integrationn8n · $9.99 · Related topic
    Browse all workflows