API mocking is a method used to simulate the behavior of real APIs during testing. It helps developers and testers work with predictable responses without relying on live backend services. In browser automation, mocking APIs ensures that test results are stable and not affected by network delays or backend changes.
Playwright, a popular end-to-end testing framework, includes features to intercept and modify network requests. This allows teams to test UI behavior against specific API responses, error conditions, or network scenarios.
This guide explains how to mock APIs with Playwright, covers advanced techniques, and addresses common challenges to help test their applications more reliably.
What is API Mocking?
API mocking is the process of creating simulated API endpoints that return predefined responses. Instead of calling a live backend, the application interacts with these mocked endpoints. This approach is useful when the backend is incomplete, unstable, or changing.
Mocked APIs can return static data, dynamic data, or error responses based on the needs of a test scenario. They allow teams to verify how the frontend handles various conditions, such as successful requests, validation failures, or server errors, without relying on real servers.
Why Mock APIs with Playwright?
Mocking APIs within Playwright gives full control over the requests and responses during testing. The goal is to create a consistent, predictable test environment that enables faster debugging and development, not just to replace the backend temporarily.
The advantages of mocking APIs in Playwright include:
- Full control over responses: Define exact request and response data for each endpoint to make tests predictable and repeatable. This removes uncertainty from backend changes or unstable data.
- Testing before backend readiness: Build and verify frontend components while backend development is still in progress, reducing idle time for teams.
- Improved execution speed: Bypass real network calls so tests run faster and require fewer resources.
- On-demand error scenarios: Simulate specific failures like 500 server errors, authentication issues, or slow responses to confirm the UI handles them correctly.
- Independence from backend stability: Keep tests running even if backend services are down, misconfigured, or undergoing maintenance.
Setting Up Playwright for Mock API Testing
Before mocking APIs, Playwright must be installed and configured in the project. This ensures the required browser drivers, dependencies, and test runner are ready. The setup process involves:
1. Installing Playwright
Use npm or yarn to install Playwright along with its browsers:
npm install @playwright/test npx playwright install
2. Creating a test file
Store tests in a tests directory or any preferred location. Import the Playwright test library at the top of the file:
const { test, expect } = require('@playwright/test');
3. Configuring the test runner
Playwright uses a configuration file (playwright.config.js or playwright.config.ts) to define test settings such as timeouts, base URLs, and browser types. This ensures all tests run under the same conditions.
4. Preparing the application URL
Ensure the test points to the correct environment, whether it is a local development server or a staging environment. For API mocking, the target environment should allow interception of requests without authentication conflicts.
Once this setup is complete, you can start intercepting and modifying API calls directly within Playwright scripts.
Read More: How to start with Playwright Debugging?
How to Mock API Requests with Playwright
Playwright controls network traffic through page.route. Tests can intercept a request, choose the response, and then verify the UI behavior. The workflow has four parts: intercept, fulfill, match precisely, and assert. Short snippets below explain each step, followed by a compact end‑to‑end example.
1. Intercept the request
Target the endpoint that the test needs to own. Use a specific pattern for stability.
await page.route('**/api/users', route => { // Will fulfill in step 2 });
2. Define the mock response
Use route.fulfill to return status, headers, and body. Keep static data for predictability. Generate data from query params or request body for realistic behavior.
await page.route('**/api/users', async route => { const url = new URL(route.request().url()); const pageParam = url.searchParams.get('page'); if (!pageParam) { return route.fulfill({ status: 200, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([ { id: 1, name: 'John Doe' }, { id: 2, name: 'Jane Smith' } ]) }); } const base = (Number(pageParam) - 1) * 2; return route.fulfill({ status: 200, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([ { id: base + 1, name: `User ${base + 1}` }, { id: base + 2, name: `User ${base + 2}` } ]) }); });
3. Match request patterns
Keep a broad pass‑through for unrelated calls. Apply a precise pattern to the endpoint under test. This avoids accidental interception.
await page.route('**/api/**', route => route.continue()); // allow the rest await page.route('**/api/users', route => { /* fulfill here */ });
4. Run the test with mocked data
Trigger the UI action that calls the API. Assert on the rendered output, loading behavior, or error messages.
await page.click('#load-default'); await expect(page.locator('#list li')).toHaveCount(2); await expect(page.locator('#list')).toContainText('1: John Doe');
Example: Interception, fulfillment, and verification in one test
The example below shows a broad route for general API traffic, a targeted route with static and dynamic responses, a simple UI that calls the API, and assertions that confirm the UI displays the mocked payloads.
// tests/mock-api.spec.js const { test, expect } = require('@playwright/test'); test('mock API with static and dynamic responses', async ({ page }) => { // Broad pass-through for other API calls await page.route('**/api/**', route => route.continue()); // Targeted interception for the users endpoint await page.route('**/api/users', async route => { const url = new URL(route.request().url()); const pageParam = url.searchParams.get('page'); // Static response when no page query is present if (!pageParam) { return route.fulfill({ status: 200, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([ { id: 1, name: 'John Doe' }, { id: 2, name: 'Jane Smith' } ]) }); } // Dynamic response driven by the page query const base = (Number(pageParam) - 1) * 2; const users = [ { id: base + 1, name: `User ${base + 1}` }, { id: base + 2, name: `User ${base + 2}` } ]; return route.fulfill({ status: 200, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(users) }); }); // Minimal UI that calls the mocked API await page.setContent(` <div> <button id="load-default">Load default</button> <button id="load-page-2">Load page 2</button> <ul id="list"></ul> <script> async function load(url) { const res = await fetch(url); const data = await res.json(); const list = document.getElementById('list'); list.innerHTML = data.map(u => '<li>' + u.id + ': ' + u.name + '</li>').join(''); } document.getElementById('load-default').onclick = () => load('/api/users'); document.getElementById('load-page-2').onclick = () => load('/api/users?page=2'); </script> </div> `); // Assertions: static response await page.click('#load-default'); await expect(page.locator('#list li')).toHaveCount(2); await expect(page.locator('#list')).toContainText('1: John Doe'); await expect(page.locator('#list')).toContainText('2: Jane Smith'); // Assertions: dynamic response for page=2 await page.click('#load-page-2'); await expect(page.locator('#list li')).toHaveCount(2); await expect(page.locator('#list')).toContainText('3: User 3'); await expect(page.locator('#list')).toContainText('4: User 4'); });
Key takeaways:
- Control the exact response with the route.fulfill: status, headers, body.
- Use precise patterns for the endpoint under test: **/api/users.
- Keep a broad pass‑through for everything else: **/api/**.
- Validate the UI against the expected DOM output for both static and dynamic cases.
Advanced API Mocking Techniques in Playwright
Once basic interception is in place, certain situations call for more control, visibility, or flexibility. The techniques below build on the fundamentals to handle real-world testing challenges.
1. Apply routes at the right scope
Not all mocks should apply globally. Some scenarios require every page in the test to share the same mocks, such as a logged-in state across tabs. Others require page-specific mocks to avoid affecting unrelated tests in the same run. Choosing the correct scope ensures your mocks are active only where they are needed.
// Context-level: applies to every page in this context const context = await browser.newContext(); await context.route('**/api/**', route => route.continue()); // Page-level: override a single endpoint in one test const page = await context.newPage(); await page.route('**/api/users', route => route.fulfill({ /* ... */ }));
2. Control order and specificity:
Playwright uses the most recently added route that matches a request. Broad “catch-all” routes should be registered first, followed by more specific routes. This ensures targeted mocks are not overridden.
await page.route('**/api/**', r => r.continue()); // broad await page.route('**/api/users', r => r.fulfill({ /*...*/ })); // specific await page.unroute('**/api/users'); // cleanup when done Modify requests instead of replacing responses: Forward the request while changing headers or payload. Useful for auth headers, A/B flags, or proxying to a different backend. await page.route('**/api/orders', async route => { const headers = { ...route.request().headers(), 'x-test-flag': 'A' }; await route.continue({ headers }); });
3. Abort to simulate transport failures
Sometimes you still want the request to reach the real backend, but with slight modifications. You can change headers, query parameters, or the request body before passing it on.
await page.route('**/api/payments', route => route.abort('failed')); // or 'timedout'
4. Introduce latency deterministically
Some scenarios require testing how the UI behaves when a request cannot reach the backend at all. Instead of returning an error response, you can abort the request to mimic network outages or connectivity loss.
await page.route('**/api/search', async route => { await new Promise(r => setTimeout(r, 800)); // 800 ms delay await route.fulfill({ status: 200, body: JSON.stringify({ results: [] }) }); });
5. Mock file‑based with HAR
To verify loading indicators, timeouts, or race condition handling, you can delay the mock response before fulfilling it. This simulates slower network conditions or heavy backend processing.
// Replays responses from a HAR file await context.routeFromHAR('fixtures/app.har', { url: '**/api/**', notFound: 'fallback' }); // Add targeted overrides on top if needed await context.route('**/api/feature-flags', r => r.fulfill({ body: '{"beta":true}' }));
6. Handle GraphQL cleanly
When an API has many endpoints or complex payloads, recording real network traffic and replaying it during tests can be faster than building each mock manually. Playwright supports loading a HAR file and matching requests against it.
await page.route('**/graphql', async route => { const { operationName, variables } = await route.request().postDataJSON(); if (operationName === 'GetUser') { return route.fulfill({ body: JSON.stringify({ data: { user: { id: 'u1', name: 'Ava' } } }) }); } if (operationName === 'Search' && variables.query === 'shoes') { return route.fulfill({ body: JSON.stringify({ data: { results: [{ id: 'p1', title: 'Runner' }] } }) }); } return route.continue(); // default });
7. Keep mocks observable
Since GraphQL routes all requests through a single endpoint, the request body must be inspected to decide which mock to return. This allows mocking specific queries or mutations while letting others pass through.
await page.route('**/api/users', r => r.fulfill({ status: 200, headers: { 'Content-Type': 'application/json', 'x-mocked': 'true' }, body: JSON.stringify([{ id: 1, name: 'Mocked User' }]) }));
8. Make mocks observable
When troubleshooting test runs, it is useful to know which responses came from mocks instead of the live backend. Adding a custom header or marker in the mock makes it easy to spot these responses in browser DevTools or network logs.
await page.route('**/api/users', r => r.fulfill({ status: 200, headers: { 'Content-Type': 'application/json', 'x-mocked': 'true' }, body: JSON.stringify([{ id: 1, name: 'Mocked User' }]) }));
Mocking Dynamic Data with Playwright for Realistic Testing
Static responses work for basic rendering checks, but they fall short when an application relies on changing backend data. These techniques show different ways to make mocks act like a real API, allowing the UI to read, write, and interact with evolving data.
1. Maintain in-memory state
To simulate a backend that changes over time, keep a data store in memory during the test. Handle GET requests by returning the current state, and update it in response to POST, PUT, or DELETE calls.
const db = { users: [{ id: 1, name: 'Ava' }] }; let nextId = 2; await page.route('**/api/users', async route => { const req = route.request(); if (req.method() === 'GET') { return route.fulfill({ body: JSON.stringify(db.users) }); } if (req.method() === 'POST') { const { name } = await req.postDataJSON(); const user = { id: nextId++, name }; db.users.push(user); return route.fulfill({ status: 201, body: JSON.stringify(user) }); } return route.continue(); });
Also Read: HTTP Methods: GET vs POST vs PUSH
2. Implement filtering, sorting, and pagination
If the real API applies query parameters to filter, sort, or paginate results, your mocks should do the same. This ensures that UI controls like search boxes and sort dropdowns are tested against realistic behavior.
const sortBy = 'name:asc'; const [field, dir] = sortBy.split(':'); const sorted = [...db.users].sort((a, b) => dir === 'asc' ? a[field].localeCompare(b[field]) : b[field].localeCompare(a[field]) );
3. Enforce authentication or authorization
Some endpoints require specific tokens or permissions. You can replicate this by checking request headers and returning a 403 or 401 if the request is not authorized.
await page.route('**/api/admin/**', async route => { const token = route.request().headers()['authorization']; if (token !== 'Bearer test-admin') { return route.fulfill({ status: 403, body: '{"error":"forbidden"}' }); } return route.continue(); });
4. Generate server-side fields
APIs often add metadata such as IDs, timestamps, or version numbers to responses. Including these fields in your mocks allows the UI to behave exactly as it would with production data.
const now = new Date().toISOString(); const item = { id: crypto.randomUUID(), createdAt: now, updatedAt: now };
5. Model errors consistently
The shape of error responses matters for UI validation. If the real API sends validation errors in a certain format, your mocks should use the same structure so the UI handles them correctly.
return route.fulfill({ status: 422, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ errors: [{ field: 'name', code: 'too_short', min: 3 }] }) });
6. Simulate network variability
To test how the UI behaves on slow or inconsistent networks, you can delay responses or introduce jitter in response times.
const delay = ms => new Promise(r => setTimeout(r, ms)); await page.route('**/api/slow-reports', async route => { await delay(1200 + Math.floor(Math.random() * 300)); // jitter await route.fulfill({ status: 200, body: '{"ok":true}' }); });
7. Combine recorded and dynamic mocks
Some endpoints may be fine replayed from a static HAR file, while others need dynamic behavior. You can use both approaches in the same test.
await context.routeFromHAR('fixtures/baseline.har', { url: '**/api/**', notFound: 'fallback' }); await context.route('**/api/clock', r => r.fulfill({ body: JSON.stringify({ now: '2030-01-01T00:00:00Z' }) }));
How Requestly Enhances API Mocking with Playwright
Requestly by BrowserStack is an HTTP interception and API mocking tool available as a browser extension or desktop app. It offers a visual interface for creating, modifying, and sharing mock rules without touching Playwright code.
This makes Requestly useful for quick setup during development, for team-wide mock sharing, or for scenarios where mocks need to be applied outside of automated test scripts.
Key features that help with Playwright API mocking:
- Modify API Responses: Change response bodies for specific endpoints to test different UI states.
- Create Mock Endpoints: Host mock APIs that Playwright tests can call, ensuring stable and reusable mock environments.
- Modify HTTP Status Code: Return custom status codes like 404 or 500 to test error handling.
- Supports GraphQL API Overrides: Target GraphQL requests by operation name or variables to control which queries are mocked.
- Delay Request: Introduce latency to simulate slow network conditions and verify loading states.
- Block Network Requests: Prevent certain calls from completing to simulate outages or remove noise like analytics requests.
Common Challenges with API Mocking in Playwright
Mocking gives control, yet it introduces new failure modes. The points below explain what typically goes wrong and how to avoid it.
- Over‑broad routes: Wildcard patterns capture extra traffic and change behavior unintentionally. Prefer exact patterns per endpoint and add pass‑through routes for the rest.
- Route registration timing: Routes added after navigation miss early requests. Register routes before the page.goto or use context‑level routes when setup is global.
- Most recent match confusion: Playwright uses the last matching route. Add broad pass‑through first, then specific mocks. Remove routes when a test no longer needs them.
- State leakage between tests: In‑memory stores and active routes bleed across cases. Create a fresh browser context per test and call unroute during teardown.
- Mock drift from the API contract: Shapes, HTTP status codes, and headers deviate from the real spec. Base mocks on the schema or examples and keep them versioned with the app.
- Auth and CORS gaps: Mocked calls may miss auth headers or CORS headers. Set Authorization on requests you continue, and include Access‑Control‑Allow‑Origin on fulfilled mocks when the app requires it.
- HTTP Archive (HAR) replay pitfalls: Recorded fixtures go stale or fail on URL mismatches. Re‑record after API changes and use notFound: ‘fallback’ with targeted overrides.
Best Practices to Mock APIs with Playwright
API mocking in Playwright can be highly effective when it is structured, predictable, and aligned with the real backend’s behavior. The following practices help maintain reliable tests and reduce maintenance overhead.
- Use precise route matching: Match requests with exact URL patterns whenever possible. Avoid overly broad wildcards like **/api/** unless followed by targeted routes for critical endpoints.
- Register routes before navigation: Set up mocks before calling page.goto to ensure early requests are intercepted. For global mocks, register routes at the browser context level.
- Separate mock logic from test logic: Store mock definitions in helper functions or dedicated files. This keeps tests readable and makes mocks easier to update.
- Version mocks alongside API changes: Keep mock data in sync with backend schemas. Update mock files when API contracts change to avoid false positives.
- Use dynamic mocks where needed: Return different responses based on query parameters, request bodies, or test variables to simulate real-world variations.
- Simulate realistic delays and failures: Introduce latency, timeouts, or aborted requests to test error handling and loading states.
- Combine mocking with live calls selectively: Allow stable, non-critical endpoints to use the live backend and mock only those essential to the test.
Conclusion
Mocking APIs with Playwright gives full control over test conditions, enabling predictable, repeatable, and faster UI testing. By intercepting requests and crafting precise responses, teams can test features early, simulate complex scenarios, and validate error handling without depending on backend availability.
Requestly enables API mocking without modifying test code. It supports overriding responses, changing status codes, adding latency, and blocking requests. These capabilities allow teams to replicate production scenarios, investigate defects, and verify fixes efficiently during development or manual testing.