← All guides

Mock APIs in Puppeteer tests β€” setRequestInterception, a hosted mock, or both

Credit where due first: page.setRequestInterception(true) is a genuinely powerful primitive. It sits below the page at the network layer, sees every request the browser makes, and can abort, rewrite, or answer each one with request.respond(). It works offline and answers in microseconds. If the only consumer of your fake data is the page under test, and you're happy hand-writing response bodies, it can do the whole job.

But it has four sharp edges that a hosted mock URL smooths out β€” and the two tools compose beautifully, which is what most of this guide demonstrates:

Below: a hosted mock for the data, interception for what it's best at β€” surgical rewrites and per-test flag injection. Every snippet was run verbatim before publishing (Puppeteer 25.9, bundled Chrome 152, Node 22; the parallel two-page run in Β§6 included).

1. Get an API (10 seconds, no signup)

curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H 'content-type: application/json' -d '{"name":"pptr-demo","preset":"ecommerce"}'

That returns a project id and admin key, with products, orders, customers and reviews already seeded with realistic data β€” full CRUD, filtering, pagination, CORS on. Or create it in one click. The snippets below use the public demo project so you can paste-and-run them with nothing created at all.

2. The zero-interception version: point the app at the mock

If your app reads its API base from config/env, you don't need interception at all β€” this is the most robust setup, and it also covers SSR fetches that interception can never see:

const puppeteer = require('puppeteer');
const assert = require('node:assert');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('http://localhost:8137/?api=https://mockbird.mockbird.workers.dev/m/demo');
  await page.waitForFunction(() =>
    document.querySelector('#state').textContent.startsWith('loaded'));
  const names = await page.$$eval('#list li', els => els.map(e => e.textContent));
  assert.strictEqual(names.length, 5);
  await browser.close();
})();

(The app under test here is a plain page that fetches {base}/products?limit=5 and renders an <li> per product β€” swap in your own app and selector.)

3. The app hardcodes its API host? Rewrite it at the network layer

When you can't change the base URL, request.continue({ url }) swaps the URL below the page β€” the page still believes it called the production host, and the response comes from the mock (Mockbird sends Access-Control-Allow-Origin: *, so the cross-origin check passes):

const MOCK = 'https://mockbird.mockbird.workers.dev/m/demo';

await page.setRequestInterception(true);
page.on('request', req => {
  const url = req.url();
  if (url.startsWith('https://api.example.com')) {
    req.continue({ url: url.replace('https://api.example.com', MOCK) });
  } else {
    req.continue();   // handle EVERY request, or the page hangs
  }
});

This is interception doing the one thing only interception can do β€” and the response data, statefulness, filtering and pagination all come from the hosted mock instead of hand-written respond() bodies.

4. Loading and error states: inject a flag per test

Every Mockbird endpoint honors simulation query params. The interception handler is a clean per-test injection point β€” the app code stays untouched:

// spinner/skeleton spec: every API call takes a real 2 seconds
page.on('request', req => {
  const u = new URL(req.url());
  if (u.hostname === 'mockbird.mockbird.workers.dev') {
    u.searchParams.set('mock_delay', '2000');
    req.continue({ url: u.toString() });
  } else req.continue();
});

await page.goto(APP);
assert.strictEqual(await page.$eval('#state', el => el.textContent), 'loading...');
// …assert the skeleton, then wait for data

Swap the param for the error-matrix spec: u.searchParams.set('mock_status', '500') returns a real 500 with a JSON body (any 400–599 works), and your error UI renders exactly as it would in production. Full recipes: loading & error states guide.

5. Retry logic: an API that fails once, then recovers

mock_seq scripts an exact status sequence β€” deterministic, unlike random chaos. First call 500, every call after that 200:

const key = 'run-' + Date.now();   // fresh counter per test run
page.on('request', req => {
  const u = new URL(req.url());
  if (u.hostname === 'mockbird.mockbird.workers.dev') {
    u.searchParams.set('mock_seq', '500,200');
    u.searchParams.set('mock_seq_key', key);
    req.continue({ url: u.toString() });
  } else req.continue();
});

Our verification run's retry loop observed exactly [500, 200] and got the data on attempt two. The counter is server-side per key β€” a stale key from a previous run would replay from wherever it left off, hence the timestamp. For random flakiness use ?mock_chaos=0.4 (chaos-failed writes are not applied, so pointing retry logic at it is safe).

6. Parallel pages, different data states β€” no restore races

Empty list, edge cases, bug repro: save each state as a named snapshot once, then any request can pin itself to a snapshot with one header. Live data is untouched; two pages read different states of the same project at the same time. Setup (once):

ADMIN='x-admin-key: YOUR_ADMIN_KEY'
B=https://mockbird.mockbird.workers.dev/api/projects/YOUR_ID
curl -X POST $B/snapshots -H "$ADMIN" -H 'content-type: application/json' -d '{"name":"baseline"}'
curl -X POST $B/resources/products -H "$ADMIN" -H 'content-type: application/json' -d '{"seed":0}'
curl -X POST $B/snapshots -H "$ADMIN" -H 'content-type: application/json' -d '{"name":"empty"}'
curl -X POST $B/snapshots/baseline/restore -H "$ADMIN"

Then in the test, the pinned page adds the header via interception:

async function preparePage(browser, snapshot) {
  const page = await browser.newPage();
  if (snapshot) {
    await page.setRequestInterception(true);
    page.on('request', req => {
      if (req.url().startsWith(MOCK)) {
        req.continue({ headers: { ...req.headers(), 'X-Mockbird-Snapshot': snapshot } });
      } else req.continue();
    });
  }
  return page;
}

const liveP  = await preparePage(browser, null);      // live data
const emptyP = await preparePage(browser, 'empty');   // pinned to "empty"
await Promise.all([runApp(liveP), runApp(emptyP)]);   // same project, same moment
// liveP renders 5 products; emptyP renders the empty state

That exact parallel run is part of our pre-publish verification. Writes while pinned return 405 with a restore hint, so a test can't corrupt a scenario by accident.

7. Two gotchas we hit while verifying this guide

Honest comparison

setRequestInterception + respond()Mockbird (hosted)Both (this guide)
Works offline / airgapped CIβœ”βœ˜ needs network✘
Response latency~0 msreal network RTTreal RTT
Arbitrary JS per responseβœ”partial β€” templated custom routesβœ” via respond() where needed
Seeded, realistic, stateful CRUD datahand-written, statelessβœ”βœ”
Browser cache stays enabled✘ (disabled by interception)βœ” (no interception needed, Β§2)✘ on intercepted pages
SSR / Node-side fetchesβœ˜βœ” any process, any languageβœ” via base URL
Same data for curl / teammates / QA linksβœ˜βœ” it's a URLβœ”
Parallel data scenariosDIY state machineβœ” snapshot pinning, one headerβœ”

If your test surface is browser-only, the backend exists, and you need a handful of stubs β€” plain interception (or MSW, whose service-worker mode also works under Puppeteer) is a fine answer, and this table says so.

Try it now

npm i puppeteer
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H 'content-type: application/json' -d '{"name":"pptr-demo","preset":"ecommerce"}'

…or skip the terminal: create the same seeded project in one click. Free, no signup required β€” the project is anonymous and claimable later. Full docs.

Using Playwright instead (or as well)? The Playwright guide covers the same patterns with page.route, including the route.fallback composition trick. Stuck on Selenium? The Selenium guide does these patterns with the new BiDi network API (selenium-wire is archived). Component stories rather than e2e? Storybook. Running in CI? Ephemeral project per GitHub Actions run.

Environment used for verification: Puppeteer 25.9.0, bundled Chrome for Testing 152, Node 22, run against the live demo project and a scratch project on 2 Sep 2026 β€” including the Β§6 parallel two-page run (twice) and both gotchas reproduced. If a snippet here doesn't work, that's a bug: tell us.