Back to Blog

Mastering Mocking in Playwright: A Deep Dive into `page.route()` for Flawless Tests

Unlock reliable, fast, and isolated tests with this deep dive into Playwright mocking. Learn to master `page.route()` for API mocking, network interception, and robust test data management.

ScanlyApp Team

QA Testing and Automation Experts

Published

10 min read

Reading time

Mastering Mocking in Playwright: A Deep Dive into page.route() for Flawless Tests

Your end-to-end (E2E) tests are failing. Again. But it's not your application's fault. The third-party payment API is down. The staging database is a mess. The search service is returning inconsistent results. Your tests are flaky, slow, and nobody trusts them.

As a founder or builder, this is a nightmare. You invested in test automation to increase confidence and velocity, but instead, it's creating noise and slowing you down.

The solution is to regain control over your test environment through mocking. Specifically, by mastering network interception.

This guide is a deep dive into one of Playwright's most powerful features: page.route(). You will learn how to intercept network requests to perform API mocking, manage test data, and simulate edge cases, transforming your tests from flaky and slow to fast, deterministic, and utterly reliable. This is the Playwright tutorial that will level up your entire testing strategy.

The Problem: Why Real Dependencies Kill Your Tests

Relying on live, external services in your E2E tests is a recipe for disaster.

Problem Why it Happens The Impact
Flakiness External APIs go down, networks glitch, services are slow. Tests fail for reasons unrelated to your code changes. Team starts ignoring failures.
Slowness Real network requests add seconds (or minutes) to every test run. CI/CD pipeline slows to a crawl. Developer feedback loop is destroyed.
Cost Some third-party APIs charge per call. Running thousands of tests can lead to unexpected bills.
Data Instability Staging databases are constantly changing. Tests that rely on specific data (e.g., "user with 3 orders") break unexpectedly.
Untestable Edge Cases It's hard to force a real API to return a 503 Service Unavailable error. You can't test how your application handles server errors, timeouts, or weird data.

Playwright mocking with page.route() solves all of these problems by putting you in complete control of the network.

Introducing page.route(): Your Network Superpower

page.route(url, handler) is a Playwright function that lets you intercept any network request made by the browser during a test. Once intercepted, you can decide what to do with it:

  • route.continue(): Let the request proceed to the network as normal.
  • route.abort(): Block the request entirely.
  • route.fulfill(): Respond to the request yourself with a mock response, without ever hitting the network.

This is the core of network interception and API mocking.

Playwright page.route() network interception diagram showing how browser HTTP requests can be continued to the real server, aborted entirely, or fulfilled with a custom mock response (Diagram showing a browser request being intercepted by page.route. Arrows point to three options: Continue, Abort, Fulfill.)

Use Case 1: Basic API Mocking

Let's say your dashboard fetches a list of projects from /api/projects. This can be slow and the data can change. Let's mock it.

The Scenario: Test the dashboard's loading state and final rendered state without hitting the real API.

import { test, expect } from '@playwright/test';

test.describe('Dashboard with Mocked API', () => {
  test('should display a loading state and then the mocked projects', async ({ page }) => {
    // The mock data we want to return
    const mockProjects = [
      { id: 'proj_1', name: 'Project Alpha', status: 'Active' },
      { id: 'proj_2', name: 'Project Beta', status: 'Inactive' },
    ];

    // Intercept the GET request to /api/projects
    await page.route('**/api/projects', async (route) => {
      console.log('Intercepted:', route.request().url());

      // Respond with our mock data
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify(mockProjects),
      });
    });

    // Navigate to the dashboard
    await page.goto('/dashboard');

    // Assert the loading state is visible initially (if you have one)
    await expect(page.locator('[data-testid="loading-spinner"]')).toBeVisible();

    // Assert that the final data is rendered correctly
    await expect(page.locator('h2:has-text("Project Alpha")')).toBeVisible();
    await expect(page.locator('h2:has-text("Project Beta")')).toBeVisible();
    await expect(page.locator('[data-testid="loading-spinner"]')).not.toBeVisible();

    // The test is now fast and will never fail due to backend issues.
  });
});

Why this is a game-changer for founders: Your frontend team can now build and test new features before the backend API is even ready. They just need to agree on the API contract (the shape of the JSON) and mock it. This enables true parallel development.

Use Case 2: Simulating Error Conditions

How does your app behave when an API fails? It's crucial to test this, but hard to reproduce. page.route() makes it trivial.

The Scenario: Ensure a user-friendly error message is shown when the projects API returns a 500 Internal Server Error.

import { test, expect } from '@playwright/test';

test('should display an error message when the projects API fails', async ({ page }) => {
  // Intercept the API call and force a server error
  await page.route('**/api/projects', async (route) => {
    await route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ message: 'Internal Server Error' }),
    });
  });

  await page.goto('/dashboard');

  // Assert that the error message is displayed to the user
  const errorComponent = page.locator('[data-testid="error-message"]');
  await expect(errorComponent).toBeVisible();
  await expect(errorComponent).toContainText("Sorry, we couldn't load your projects. Please try again later.");

  // Assert that no project data is shown
  await expect(page.locator('h2:has-text("Project Alpha")')).not.toBeVisible();
});

You can use this same technique to simulate:

  • 401 Unauthorized or 403 Forbidden to test your app's authentication logic.
  • 404 Not Found for specific resources.
  • Network timeouts (see advanced use cases).

Use Case 3: Modifying Responses for Test Data Management

Sometimes you don't want to mock the entire response, just tweak it. This is a powerful strategy for test data management.

The Scenario: You need to test how the UI handles a project with an extremely long name, but you don't want to pollute your staging database with weird data.

import { test, expect } from '@playwright/test';

test('should handle projects with very long names correctly', async ({ page }) => {
  // Intercept the request, let it go to the server, but modify the response
  await page.route('**/api/projects', async (route) => {
    // 1. Fetch the real response from the network
    const response = await route.fetch();
    const json = await response.json();

    // 2. Modify the response body
    if (json.length > 0) {
      json[0].name =
        'This is an incredibly long project name designed to test the wrapping and truncation logic of the user interface to prevent any visual bugs or layout shifts.';
    }

    // 3. Fulfill the request with the modified data
    await route.fulfill({ response, json });
  });

  await page.goto('/dashboard');

  // Now you can assert that your UI handles the long name gracefully
  const longNameElement = page.locator(`h2:has-text("${json[0].name}")`);

  // Example assertion: check if the text is truncated with an ellipsis
  await expect(longNameElement).toHaveCSS('text-overflow', 'ellipsis');
});

This approach gives you the best of both worlds: you're testing against a realistic backend response, but with the specific data variations you need for your test case.

Use Case 4: Blocking Unnecessary Requests

Modern web apps make many third-party requests: analytics, chat widgets, ad trackers. These slow down your tests and create noise. You can use page.route() to block them.

The Scenario: Speed up tests by blocking all tracking and analytics scripts.

import { test, expect } from '@playwright/test';

test.beforeEach(async ({ page }) => {
  // Block requests to analytics and tracking domains
  await page.route('**/*.{png,jpg,jpeg,svg}', (route) => route.abort()); // Block images
  await page.route('**/*google-analytics.com', (route) => route.abort());
  await page.route('**/*hotjar.com', (route) => route.abort());
  await page.route('**/*segment.com', (route) => route.abort());
});

test('fast test without third-party scripts', async ({ page }) => {
  // This test will now run significantly faster
  await page.goto('/');
  await expect(page.locator('h1')).toBeVisible();
});

This is a simple but highly effective optimization that can shave seconds off every single test.

Advanced page.route() Techniques

Simulating Slow Networks and Timeouts

You can introduce delays to test loading skeletons and spinners.

await page.route('**/api/projects', async (route) => {
  // Add a 3-second delay before responding
  await new Promise((resolve) => setTimeout(resolve, 3000));

  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([{ id: 'proj_1', name: 'Delayed Project' }]),
  });
});

To simulate a network timeout, you can simply abort the request.

await page.route('**/api/projects', (route) => {
  // Aborting simulates a failed network request
  route.abort('connectionfailed');
});

Using route.fallback() for Cleaner Code

When you have multiple route handlers, route.fallback() provides a cleaner way to let un-mocked requests continue.

import { test, expect } from '@playwright/test';

test.beforeEach(async ({ page }) => {
  // Mock only the specific endpoint we care about
  await page.route('**/api/user/profile', async (route) => {
    await route.fulfill({ json: { name: 'Mock User', theme: 'dark' } });
  });

  // Let all other requests pass through to the network
  await page.route('**/*', (route) => route.fallback());
});

test('loads user profile from mock but other data from network', async ({ page }) => {
  await page.goto('/dashboard');

  // This will be the mock name
  await expect(page.locator('[data-testid="user-name"]')).toHaveText('Mock User');

  // This data will come from the real /api/projects endpoint
  await expect(page.locator('h2:has-text("Real Project From DB")')).toBeVisible();
});

Organizing Your Mocks

For larger applications, you'll want to organize your mocks to keep them reusable and maintainable.

mocks/projectMocks.js:

export const mockProjects = [
  { id: 'proj_1', name: 'Project Alpha' },
  { id: 'proj_2', name: 'Project Beta' },
];

export async function applyProjectMocks(page) {
  await page.route('**/api/projects', async (route) => {
    await route.fulfill({ json: mockProjects });
  });
}

tests/dashboard.spec.js:

import { test, expect } from '@playwright/test';
import { applyProjectMocks } from '../mocks/projectMocks';

test.beforeEach(async ({ page }) => {
  await applyProjectMocks(page);
});

test('dashboard displays mocked projects', async ({ page }) => {
  // ... test logic ...
});

The Business Case for Mocking

For founders, product managers, and anyone responsible for shipping a quality product on time, mastering Playwright mocking is not just a technical exercise. It's a strategic advantage.

Benefit Impact on Your Business
Increased Velocity Frontend and backend teams can work in parallel. Tests run faster, so CI/CD is quicker.
Higher Reliability Tests become deterministic. A failure means a real bug in your code, not a problem with the environment.
Reduced Costs Fewer flaky test reruns save CI/CD minutes. No charges from third-party APIs during testing.
Improved Quality Easily test error states and edge cases that are impossible to create with live services.
Developer Happiness Developers can run tests reliably on their local machines without a complex setup.

By isolating your application from its dependencies during testing, you are not just writing better tests; you are building a more efficient and resilient development process. This is a core principle of a mature QA strategy.

Take Control of Your Test Environment

You now have the knowledge to transform your E2E testing suite. By mastering page.route(), you can eliminate flakiness, increase test speed, and test scenarios that were previously impossible. You can move from being at the mercy of your test environment to being in complete control of it.

What If You Could Have Reliable Tests Without the Code?

Writing and maintaining mock handlers is powerful, but it still requires developer time. What if you could get the benefits of a controlled test environment without the setup?

ScanlyApp is designed for teams who need reliable tests, fast.Smart Element Detection: Our AI-powered platform is resilient to minor UI changes, reducing test flakiness. ✅ No-Code Test Creation: Empower your entire team—from QA to product managers—to build and maintain E2E tests. ✅ CI/CD Integration: Run your full test suite on every deployment, catching bugs before they hit production. ✅ Visual and Functional Testing: ScanlyApp checks that your app not only works correctly but also looks perfect.

Stop letting flaky tests slow you down.

Start your free ScanlyApp trial and experience truly reliable E2E testing.

Related Posts