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.
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.
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.
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.
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
routecatches 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 runand 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
- Remove a mock and confirm the test fails with “not mocked” rather than reaching the network.
- Switch to
server-errorand confirm the error UI and retry are tested. - Run the suite with the network disconnected and confirm it still passes.
- 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.
Related
- Driving service worker state from a test — test hooks.
- Stabilising flaky extension tests — deterministic tests.
- Syncing extension data with a backend API — the code under test.
- End-to-end testing and automation — the parent topic.