PRACTICAL GUIDE / playwright mock browser API
Mock Browser APIs Before App Startup with Playwright addInitScript
Mock browser APIs before app startup with Playwright addInitScript, stateful event doubles, read-only overrides, call recording, and focused UI assertions.
In this guide13 sections
- Define the Consumed Contract
- Install the Mock Before Navigation
- Preserve Event Semantics
- Override Read-Only Properties Carefully
- Record Calls with exposeFunction
- Choose Page or Context Scope
- Respect Browser and Test Process Boundaries
- Package Mocks as Scenario Fixtures
- Keep Real Integration Coverage
- Diagnose Mock Failures
- Review the Tradeoffs
- Operational Checklist
- Conclusion: Control the Capability Before Boot
What you will learn
- Define the Consumed Contract
- Install the Mock Before Navigation
- Preserve Event Semantics
- Override Read-Only Properties Carefully
Browser API mocking fails most often because it starts too late or models too little. If the application reads navigator.getBattery, navigator.cookieEnabled, geolocation support, or another capability during startup, replacing it after navigation cannot change the value already cached by application code. Install the double before the first document script and implement the state, events, and call behavior the feature actually consumes.
The mock should be narrower than the browser API specification and more faithful than a constant object. Define the application-facing contract first, preload one deterministic implementation, drive it through a small controller, and assert the user-visible response. Keep a separate layer of real-browser tests for behavior the double cannot prove.
Define the Consumed Contract
Inspect the application code and browser behavior before writing a mock. Record which method is called, whether it returns a promise, which fields are read, which events are registered, and whether the app caches the object. Mock only those surfaces, but match their names, value ranges, async shape, and event timing.
The official Playwright browser API mocking guide uses page.addInitScript because an application may call the API early during load. The page API runs the script for navigation and child-frame attachment after a document exists but before that document's scripts run.
Animated field map
Preloading a Browser API Mock
A declared browser contract is installed before application boot, then observed through calls, events, and user-visible behavior.
01 / browser contract
Browser API Contract
Identify methods, properties, promises, events, and value constraints the app uses.
02 / preload script
Preload Init Script
Install one isolated mock before any application script can read the capability.
03 / application boot
Application Boot
Let startup code discover and cache the mocked browser surface normally.
04 / mocked api call
Mocked API Call
Record calls and drive state changes with realistic browser events.
05 / ui assertion
UI Behavior Assertion
Verify the product response rather than only inspecting the mock object.
Install the Mock Before Navigation
Call addInitScript before page.goto, page.setContent, or the action that opens a popup whose startup must be controlled. Values passed as the script argument must be serializable. Do not close over Node variables; browser and test code execute in separate environments.
import { expect, test, type Page } from "@playwright/test";
type BatteryState = {
level: number;
charging: boolean;
chargingTime: number;
dischargingTime: number;
};
type BatteryControl = {
setLevel(value: number): void;
setCharging(value: boolean): void;
};
async function installBatteryMock(
page: Page,
initial: BatteryState,
): Promise<void> {
await page.addInitScript((state) => {
class BatteryMock extends EventTarget {
level = state.level;
charging = state.charging;
chargingTime = state.chargingTime;
dischargingTime = state.dischargingTime;
setLevel(value: number): void {
this.level = value;
this.dispatchEvent(new Event("levelchange"));
}
setCharging(value: boolean): void {
this.charging = value;
this.dispatchEvent(new Event("chargingchange"));
}
}
const battery = new BatteryMock();
Object.defineProperty(Object.getPrototypeOf(navigator), "getBattery", {
configurable: true,
value: async () => battery,
});
const mockWindow = window as typeof window & { __battery: BatteryControl };
mockWindow.__battery = {
setLevel: (value) => battery.setLevel(value),
setCharging: (value) => battery.setCharging(value),
};
}, initial);
}
test("power banner reacts to battery changes", async ({ page }) => {
await installBatteryMock(page, {
level: 0.2,
charging: false,
chargingTime: Infinity,
dischargingTime: 3_600,
});
await page.goto("/device-status");
await expect(page.getByTestId("battery-level")).toHaveText("20%");
await expect(page.getByRole("status")).toHaveText("Battery low");
await page.evaluate(() => {
const mockWindow = window as typeof window & { __battery: BatteryControl };
mockWindow.__battery.setCharging(true);
});
await expect(page.getByRole("status")).toHaveText("Charging");
});The test asserts the application response before and after an event. Reading window.__battery alone would only prove that the test installed its own object.
Preserve Event Semantics
Many capability consumers subscribe once and update UI only when a named event fires. Changing a mock field without dispatching levelchange or chargingchange leaves the app unchanged even though the object has new data. Extending EventTarget supplies addEventListener, removeEventListener, and dispatch behavior with little custom code.
Match the event contract the application uses. If production code assigns an onlevelchange property instead of calling addEventListener, the mock must support that path too. If callbacks receive an event whose target matters, use a real Event and dispatch it from the mocked object rather than calling listeners as plain functions.
Validate state transitions. Battery level should remain within its valid range; permission states should use supported values. Letting a test set impossible state can create UI paths no browser can produce and weaken confidence.
Override Read-Only Properties Carefully
Assignment may not affect a read-only navigator property. Inspect where the property is defined and whether its descriptor is configurable, then use Object.defineProperty at that level. Keep the mutation inside a fresh browser context so another test never inherits it.
test("offline guidance appears when cookies are unavailable", async ({ page }) => {
await page.addInitScript(() => {
const prototype = Object.getPrototypeOf(navigator);
Object.defineProperty(prototype, "cookieEnabled", {
configurable: true,
get: () => false,
});
});
await page.goto("/account");
await expect(page.getByRole("alert"))
.toContainText("Enable cookies to continue");
});If the native descriptor is non-configurable in a target browser, do not force a brittle override. Introduce an application capability adapter that can be injected in tests, or cover the fallback through a browser/environment where the condition is real.
Record Calls with exposeFunction
page.exposeFunction creates a function in the page that calls back into the Playwright process. Install the bridge before navigation and invoke it from the mock. This is useful when the contract includes call count, argument order, or event subscription names.
test("the dashboard requests battery state once", async ({ page }) => {
const calls: string[] = [];
await page.exposeFunction("recordBatteryCall", (name: string) => {
calls.push(name);
});
await page.addInitScript(() => {
const bridge = window as typeof window & {
recordBatteryCall(name: string): Promise<void>;
};
const battery = new EventTarget();
Object.assign(battery, { level: 0.8, charging: true });
Object.defineProperty(Object.getPrototypeOf(navigator), "getBattery", {
configurable: true,
value: async () => {
await bridge.recordBatteryCall("getBattery");
return battery;
},
});
});
await page.goto("/dashboard");
await expect(page.getByTestId("battery-level")).toHaveText("80%");
expect(calls).toEqual(["getBattery"]);
});Do not record every low-level event by default. Assert calls only when they are part of the application contract; otherwise prefer the visible outcome and avoid coupling the test to an internal invocation count.
Choose Page or Context Scope
Use page.addInitScript when one page owns the scenario. Use browserContext.addInitScript when every page and popup in the context must receive the same capability before startup. Context scope is important when an action opens a new tab that reads the API immediately.
Keep one composed init script when installation order matters. The evaluation order of multiple scripts added at page and context scope is not defined. Splitting a base mock and an override into two scripts can therefore produce intermittent state; pass the desired scenario into one installer instead.
Respect Browser and Test Process Boundaries
The init callback cannot access a test variable merely because it is lexically nearby in source. Pass serializable configuration as the second addInitScript argument. Use page.evaluate with an explicit argument to drive state after load. Use exposeFunction when page code must call Node-side logic.
Avoid passing class instances, functions, open connections, or secrets as init arguments. Serialize a minimal state record. The browser-side implementation should be self-contained because Playwright sends its function into a different execution environment.
Package Mocks as Scenario Fixtures
When several specs need the capability, expose a fixture or helper that installs one named scenario before navigation. Keep the fixture test-scoped and make the state explicit in the test declaration. An automatic global mock can accidentally remove all real-browser coverage.
import { test as base, type Page } from "@playwright/test";
type BrowserApiFixtures = {
installLowBattery(page: Page): Promise<void>;
};
export const test = base.extend<BrowserApiFixtures>({
installLowBattery: async ({}, use) => {
await use(async (page) => {
await installBatteryMock(page, {
level: 0.1,
charging: false,
chargingTime: Infinity,
dischargingTime: 900,
});
});
},
});The test must call the installer before opening the application. A fixture that navigates automatically would hide that crucial ordering and make later route setup harder to understand.
Keep Real Integration Coverage
A mock proves application logic against the modeled contract. It does not prove browser permission prompts, native implementation details, device integration, or whether a capability exists in each supported browser. Maintain focused tests or manual coverage for those risks.
Use project capabilities to skip a real integration check only with a documented reason. The mocked fallback test can run broadly, while a smaller browser-specific suite validates the native boundary where automation support and environment access permit it.
Diagnose Mock Failures
If the real API is called, confirm the init script was registered before the first navigation and that the application did not start in a fixture earlier. If assignment has no effect, inspect the property descriptor and prototype. If the UI shows initial state but ignores updates, implement the exact event names and target behavior.
If call records remain empty, install exposeFunction first and verify the browser-side bridge name. If one browser fails, compare property configurability and capability exposure rather than assuming identical prototypes. If behavior varies between runs, consolidate multiple init scripts whose order matters.
Review the Tradeoffs
An inline init script keeps a one-off scenario near the test but becomes hard to maintain when the contract is stateful. A shared typed installer improves reuse, although it can drift from the browser unless its surface stays small. A context-wide fixture handles popups consistently but risks mocking tests that intended to use the real API.
Choose the narrowest scope and fidelity that proves the product branch. Model promises and events when consumed; do not reproduce an entire browser specification for fields the application never reads.
Operational Checklist
- Document the exact browser surface the application consumes.
- Register the mock before the first navigation or popup creation.
- Pass scenario state as serializable init-script data.
- Preserve promise, property, method, and event semantics that matter.
- Use property descriptors for configurable read-only APIs.
- Drive updates through a small browser-side controller.
- Use
exposeFunctiononly for contract-relevant call evidence. - Consolidate scripts when installation order matters.
- Scope shared mocks to isolated tests or contexts.
- Retain real-browser coverage for native integration risks.
Conclusion: Control the Capability Before Boot
Define one consumed contract, install its double before application code runs, and prove both initial discovery and event-driven updates through the UI. Add call recording only where invocation behavior matters, keep read-only overrides isolated, and reserve a separate suite for native browser integration. A browser API mock is credible when it reproduces the application's boundary without pretending to be the whole browser.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Playwright documentation
Microsoft
Canonical API, locator, fixture, browser, and test-runner behavior.
- 02Playwright best practices
Microsoft
Official guidance for resilient tests, isolation, and user-facing locators.
- 03
- 04
FAQ / QUICK ANSWERS
Questions testers ask
Why must a Playwright browser API mock be installed before page.goto?
Applications often read browser capabilities during initial module execution and cache the result. `addInitScript` runs after the document is created but before page scripts, so the application observes the mock from its first access.
Can addInitScript mock read-only navigator properties?
A configurable read-only property can often be overridden with `Object.defineProperty` on the object or its prototype. Inspect the descriptor and keep the override scoped to an isolated test context.
How do I simulate browser API events in a mock?
Implement the relevant EventTarget behavior, retain mutable state in the page, and expose a small test-only controller that changes state and dispatches the same event names the application listens for.
What is exposeFunction used for with browser API mocks?
It creates a bridge from page code to the Playwright process. A mock can call that bridge to record method calls or arguments in the test without sharing Node variables directly with the browser environment.
Should every browser capability be mocked in end-to-end tests?
No. Mock only the capability boundary needed for deterministic application states. Keep real-browser coverage for permissions, native prompts, and integration behavior that the mock deliberately bypasses.
RELATED GUIDES
Continue the learning route
GUIDE 01
API Mocking with WireMock: Stubs, Tests, and Examples
API mocking with WireMock guide for QA teams covering stubs, request matching, delays, faults, recording, contracts, CI, and mistakes.
GUIDE 02
Playwright API Testing: Validate APIs Inside Your E2E Suite
Playwright API testing guide covering request contexts, setup flows, assertions, authentication, fixtures, UI strategy, and CI-ready checks.
GUIDE 03
Playwright Tutorial: End-to-End Testing from Scratch
Playwright tutorial for beginners: install, write your first test, TypeScript setup, codegen, fixtures, API testing, debugging, and CI tips in one guide.
GUIDE 04
Deterministic Network Tests with Playwright HAR Recording and Replay
Record, sanitize, and replay Playwright HAR fixtures for deterministic network tests, strict request matching, safer updates, and clear failures.