Mocking Network Responses in Extension Tests

Control the network in end-to-end extension tests: Playwright route interception for pages and extension pages, why service worker fetches need a different approach, a local mock API server, MSW in unit tests, and simulating errors and slow networks.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

The extension syncs with a backend, fetches prices from an API, and authenticates with OAuth. End-to-end tests that hit the real services are slow, flaky, need credentials in CI, and cannot easily produce the cases that matter most — a 500 error, a timeout, a malformed response, a rate limit. Mocking the network solves all of that, but extensions complicate it: requests come from web pages, extension pages and the service worker, and test tools intercept those differently. Playwright’s route interception covers pages, but historically not extension service workers. This guide shows how to control every request an extension makes in tests. It belongs to end-to-end testing and automation.

Where requests come from

An extension’s network traffic originates in three places. Web pages the content script runs on load their own resources, which tests mock to control page content. Extension pages — popup, options, side panel — can call fetch, as pages in the browser context. The service worker usually owns API calls, sync and authentication. Playwright’s context.route() intercepts requests from pages in the context, including extension pages opened in tabs. Requests from service workers are interceptable in recent Playwright versions for Chromium (service worker network events are routed through the context), but support has varied by version, so the most robust approach for worker traffic is to point the extension at a local mock server through build-time or runtime configuration.

Mocking by request originPage requests are mocked with context.route; extension page requests are mocked the same way; service worker API requests go to a configurable base URL that points at a local mock server in tests, which can also simulate errors and latency.Web pagefixture assetsExtension pagespopup, optionsService workerAPI, sync, authmocked bycontext.route()Playwrightcontext.route()PlaywrightMock API serverAPI_BASE=localhost
Route interception for pages; a configurable API base URL for the worker.

Step-by-step: controlling the network

1. Make the API base URL configurable

1// config.js
2export const API_BASE = import.meta.env.VITE_API_BASE ?? "https://api.readable.example";
1VITE_API_BASE=http://localhost:4100 npm run build:test

Execution context: the extension source and a test build. A build-time base URL lets the test build talk to a local server while production builds keep the real one. Add http://localhost/* to host_permissions only in the test manifest. Never let a runtime setting change the API base in production without validation — it would let anyone with access to storage redirect API traffic.

2. Run a mock API server for the worker

 1// tests/mock-api.mjs
 2import http from "node:http";
 3const scenarios = { default: { "/v1/items": { status: 200, body: { items: [] } } } };
 4let active = "default";
 5
 6export function startMockApi(port = 4100) {
 7  return http.createServer(async (req, res) => {
 8    if (req.url === "/__scenario") { active = (await body(req)).name; return res.end("ok"); }
 9    const r = scenarios[active]?.[req.url.split("?")[0]] ?? { status: 404, body: { error: "not mocked" } };
10    if (r.delayMs) await new Promise((x) => setTimeout(x, r.delayMs));
11    res.writeHead(r.status, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
12    res.end(JSON.stringify(r.body));
13  }).listen(port);
14}
15export const setScenario = (name) => fetch(`http://localhost:4100/__scenario`, { method: "POST", body: JSON.stringify({ name }) });

Execution context: Node, started by the test runner. Named scenarios — default, server-error, slow, rate-limited, malformed — let each test switch the server’s behaviour with one call. Unmocked paths return a loud 404 so missing mocks are obvious rather than silently reaching the internet.

Scenarios worth mockingNetwork scenarios to simulate in extension tests — success, empty, server error, timeout, rate limit, malformed JSON, auth expired and offline — with the behaviour each verifies.ScenarioMockVerifiesSuccess200 + fixtureHappy pathServer error500Error UI, retry/backoffTimeout / slowdelay 30 sLoading state, abortRate limited429 + Retry-AfterRespects Retry-AfterMalformed200 + bad JSONNo crash; reports errorAuth expired401Token refresh flowOfflinecontext.setOfflineQueue + resume
The failure cases are the reason to mock at all.

3. Intercept page and extension-page requests with route

1test("popup shows prices from the API", async ({ context, extensionId }) => {
2  await context.route("https://api.readable.example/v1/prices*", (route) =>
3    route.fulfill({ json: { prices: [{ sku: "A1", amount: 1999, currency: "EUR" }] } }));
4  const popup = await context.newPage();
5  await popup.goto(`chrome-extension://${extensionId}/popup.html`);
6  await expect(popup.getByText("19,99 €")).toBeVisible();
7});

Execution context: a Playwright test. context.route applies to every page in the context, including extension pages loaded in tabs, so requests the popup makes directly are intercepted without changing the build. Use route.fulfill for responses, route.abort("failed") for network errors, and route.continue() to let a request through after inspecting it.

4. Test service worker failure handling

1test("sync retries with backoff after a server error", async ({ context, serviceWorker }) => {
2  await setScenario("server-error");
3  await serviceWorker.evaluate(() => self.syncNow());           // test hook exposed in the test build
4  await expect.poll(() => serviceWorker.evaluate(() => chrome.storage.local.get("syncState").then((r) => r.syncState?.status)))
5    .toBe("retry-scheduled");
6  const alarm = await serviceWorker.evaluate(() => chrome.alarms.get("sync-retry"));
7  expect(alarm).toBeTruthy();
8  await setScenario("default");
9});

Execution context: a Playwright test with the worker handle from context.serviceWorkers(). The mock server returns 500; the test asserts the extension’s observable reaction — state in storage and a scheduled retry alarm — rather than internal variables. See retrying failed background jobs with backoff.

An offline-then-online testThe test sets the context offline; the user saves an item in the popup; the worker's sync fails and queues it; the test sets the context online and triggers the online handler; the worker syncs the queue to the mock server, which records the request.TestExtensionMock APIsetOffline(true); save itemfetch fails → queuesetOffline(false); trigger syncPOST /v1/itemsassert received 1 item
Simulate the network state, then assert the queue drains.

5. Simulate offline and slow networks

1await context.setOffline(true);
2// … exercise offline behaviour …
3await context.setOffline(false);
4
5const cdp = await context.newCDPSession(popup);
6await cdp.send("Network.emulateNetworkConditions", { offline: false, latency: 800, downloadThroughput: 50_000, uploadThroughput: 20_000 });

Execution context: Playwright with Chromium. setOffline toggles the context’s network; Chrome DevTools Protocol network emulation adds latency and bandwidth limits for loading-state tests. Offline emulation applies to pages; for the worker, the mock server’s slow and connection-refused scenarios (stop the server) are more reliable. See handling offline and retrying requests.

6. Mock at the unit level with MSW

1// vitest setup
2import { setupServer } from "msw/node";
3import { http, HttpResponse } from "msw";
4export const server = setupServer(http.get("https://api.readable.example/v1/items", () => HttpResponse.json({ items: [] })));
5beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
6afterEach(() => server.resetHandlers());
7afterAll(() => server.close());

Execution context: unit and integration tests in Node. Mock Service Worker intercepts fetch in Node, so service-worker modules tested outside the browser can use the same handlers as the mock server. onUnhandledRequest: "error" keeps tests from reaching the network. See using Vitest with WebExtension mocks.

7. Record fixtures from the real API occasionally

Capture real responses (with secrets and personal data removed) into fixture files and regenerate them periodically. Mocks that drift from the real API give false confidence; a scheduled contract test against a staging API catches drift. See contract testing a storage schema.

Common mistakes

  • Real APIs in CI. Slow, flaky and needs credentials.
  • Only mocking success. Error handling goes untested.
  • Assuming route catches worker fetches. Verify, or use a mock server.
  • Unmocked requests passing through silently. Fail on unhandled requests.
  • Test-only host permissions in production. Generate manifests per build.

Cross-browser variation

  • Chrome / Edge: Playwright routing for pages and extension pages; CDP network emulation; service worker routing depends on Playwright version.
  • Firefox: use the mock server approach with web-ext run and WebDriver; route interception for extension contexts is limited.
  • Safari: no automation path for routing; rely on the mock server with a test build in manual checks.

Verification

  1. Remove a mock and confirm the test fails with “not mocked” rather than reaching the network.
  2. Switch to server-error and confirm the error UI and retry are tested.
  3. Run the suite with the network disconnected and confirm it still passes.
  4. Confirm the production manifest has no localhost host permission.

FAQ

Can I intercept requests in the service worker with Playwright?

Recent Playwright versions route Chromium service worker requests through context.route; test it in your setup and fall back to a mock server if not.

Should content-script requests be mocked?

Content scripts’ fetch calls run with the page’s origin; context.route intercepts them like page requests.

How do I mock OAuth?

Point launchWebAuthFlow at a local mock authorisation server in the test build, or inject tokens directly into storage for tests that don’t exercise the login itself.

Do mocks hide CORS problems?

They can. The mock server should send the same CORS headers as production, and a periodic test against staging catches differences the mocks hide.

Other Testing, Debugging & Performance Optimization Resources