Skip to main content
/tayyab/portfolio — zsh
tayyab
TA
// dispatch.read --classified=false --access-level: public

Network Mocking in Playwright: Patterns That Don't Suck (With Real Examples)

April 5, 2026 EST. READ: 11 MIN #Quality Assurance

Network mocking is where Playwright suites tend to swing too hard. I've seen test suites that mock everything — at which point you're not testing your app, you're testing your mocks. I've seen test suites that mock nothing — at which point you can't reliably test error paths because the real backend doesn't fail on demand.

The right answer is surgical: mock specific requests when you need a deterministic response (errors, slow networks, edge data), let the rest go through to the real backend (or a stable staging environment). Here are the four patterns I use on real client projects, with the gotchas that took me longest to learn.

Table of Contents

When to Mock vs When to Use Real APIs

My rules:

Mock when:

  • Testing error paths (4xx/5xx responses) the real API won't reliably produce.
  • Testing third-party services with rate limits, cost, or test-mode quirks (Stripe, SendGrid).
  • Testing loading states that require a slow response.
  • Testing edge data (empty list, exactly 1 item, very large list) that's painful to seed in real backends.

Don't mock when:

  • The happy path. Run against staging or a real test backend.
  • Anything where the API contract might change without your tests catching it.
  • Anything that touches business logic you actually want to validate.

Default: don't mock. Reach for mocking when you have a specific reason.

Pattern 1: Single-Request Mock for an Error Case

You want to test what happens when the payment API returns 500. Real Stripe won't oblige. Mock it just for this test:

test('payment failure shows retry message', async ({ page }) => {
  await page.route('**/api/checkout/charge', (route) => {
    route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'payment_provider_unavailable' }),
    });
  });

  await page.goto('/checkout');
  await fillCheckoutForm(page);
  await page.getByRole('button', { name: 'Pay' }).click();

  await expect(page.getByRole('alert')).toContainText(/please try again/i);
  await expect(page.getByTestId('cart-summary')).toBeVisible(); // cart preserved
});

Mock applies only to this test. Other tests hit the real API. Rest of the test exercises the actual error-handling code in your app.

Pattern 2: Conditional Mock Based on Request Body

Some tests need different responses for different inputs. The route handler can inspect the request:

test('partial payment failure on second item', async ({ page }) => {
  await page.route('**/api/orders', async (route) => {
    const body = JSON.parse(route.request().postData() || '{}');
    if (body.items?.length > 1) {
      await route.fulfill({
        status: 422,
        contentType: 'application/json',
        body: JSON.stringify({
          error: 'second_item_unavailable',
          partial: { fulfilled: [body.items[0]] },
        }),
      });
    } else {
      await route.continue(); // single-item orders go to real backend
    }
  });

  await page.goto('/cart');
  await addItemsToCart(page, 2);
  await page.getByRole('button', { name: 'Checkout' }).click();

  await expect(page.getByRole('alert')).toContainText(/partial.*unavailable/i);
});

The route.continue() branch is critical. Without it, every order request gets mocked, including ones unrelated to your test.

Pattern 3: Slow-Network Mock for Loading-State Tests

You want to verify the loading spinner appears. Real APIs are too fast — by the time you assert, the spinner is gone.

test('shows loading spinner during slow API', async ({ page }) => {
  await page.route('**/api/dashboard', async (route) => {
    await new Promise((res) => setTimeout(res, 2000));
    await route.continue();
  });

  await page.goto('/dashboard');

  // Spinner is visible while the mocked-slow request is pending
  await expect(page.getByTestId('dashboard-spinner')).toBeVisible();

  // Eventually the real response comes back and the spinner disappears
  await expect(page.getByTestId('dashboard-spinner')).toBeHidden({ timeout: 5000 });
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

Note route.continue(), not fulfill — you want the real response, just delayed. This tests the loading UI without faking the data.

Pattern 4: Fixture-Driven Mock for Deterministic Data

Visual regression tests need exact data. You don't want a real backend deciding what user names appear. Mock the response with a fixture file:

// tests/fixtures/users.json
{
  "data": [
    { "id": 1, "name": "Alice Test", "email": "alice@test.com" },
    { "id": 2, "name": "Bob Test", "email": "bob@test.com" }
  ]
}

// In test:
import users from './fixtures/users.json';

test('user list visual regression', async ({ page }) => {
  await page.route('**/api/users', (route) => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify(users),
    });
  });

  await page.goto('/users');
  await expect(page).toHaveScreenshot('user-list.png');
});

The fixture file lives in the repo. Same data, every run, every CI environment.

Wrapping Mocks in Reusable Fixtures

Repeating route setup in every test is noisy. Wrap common mocks in Playwright fixtures:

// fixtures/network.ts
import { test as base, Page } from '@playwright/test';

type Fixtures = {
  mockPaymentSuccess: () => Promise<void>;
  mockPaymentFailure: (errorCode?: string) => Promise<void>;
  mockSlowAPI: (urlPattern: string, delayMs: number) => Promise<void>;
};

export const test = base.extend<Fixtures>({
  mockPaymentSuccess: async ({ page }, use) => {
    await use(async () => {
      await page.route('**/api/checkout/charge', (route) => {
        route.fulfill({
          status: 200,
          contentType: 'application/json',
          body: JSON.stringify({ id: 'ord_test_123', status: 'paid' }),
        });
      });
    });
  },
  mockPaymentFailure: async ({ page }, use) => {
    await use(async (errorCode = 'card_declined') => {
      await page.route('**/api/checkout/charge', (route) => {
        route.fulfill({
          status: 402,
          contentType: 'application/json',
          body: JSON.stringify({ error: errorCode }),
        });
      });
    });
  },
  mockSlowAPI: async ({ page }, use) => {
    await use(async (urlPattern: string, delayMs: number) => {
      await page.route(urlPattern, async (route) => {
        await new Promise((res) => setTimeout(res, delayMs));
        await route.continue();
      });
    });
  },
});

Use them in tests:

import { test } from './fixtures/network';
import { expect } from '@playwright/test';

test('declined card shows retry', async ({ page, mockPaymentFailure }) => {
  await mockPaymentFailure('card_declined');
  await page.goto('/checkout');
  // ... rest of test
});

Tests stay readable; mock implementation lives once.

Three Gotchas That Bit Me

Gotcha 1: Routes are scoped to the page, not the context

If your test opens a popup or new tab, your route handler doesn't apply to it. To mock across all pages in a context:

await page.context().route('**/api/**', handler);

Gotcha 2: Order of route handlers matters

Last-registered runs first. If you call page.route('**/api/**', handler1) then page.route('**/api/checkout', handler2), the second one runs first for checkout requests. Inside it, call route.fallback() to delegate to the next handler.

Gotcha 3: Mocks don't apply to requests already in flight

Set up routes before navigating. page.goto('/') followed by page.route() means the initial page load missed the mock. Reverse the order.

FAQs

Should I mock the whole backend for fast E2E tests?

No. You'll lose contract coverage. Mock only what you need to test specifically (errors, edge data, slow paths).

What about MSW (Mock Service Worker)?

MSW is great for component tests and unit tests. For Playwright, use page.route — it's faster and integrated. MSW adds a service worker layer that's overkill for E2E.

Can I mock GraphQL queries?

Yes. Match on URL plus inspect the request body for the operation name:

await page.route('**/graphql', (route) => {
  const body = JSON.parse(route.request().postData() || '{}');
  if (body.operationName === 'GetUsers') {
    route.fulfill({ status: 200, body: JSON.stringify({ data: { users: [] } }) });
  } else {
    route.continue();
  }
});

How do I mock WebSocket frames?

Different API: page.routeWebSocket. Newer feature, less commonly needed. Most apps test WS via integration with a real server.

Should I assert on outgoing requests?

Yes for contracts. page.waitForRequest with a predicate on the body lets you verify your app sends the right shape:

const req = await page.waitForRequest('**/api/orders');
const body = JSON.parse(req.postData() || '{}');
expect(body.currency).toBe('USD');

What about ETag and conditional GETs?

Mocks bypass HTTP caching. If your test depends on cache-validation behavior, you need to test against a real server.

How do I clean up mocks between tests?

Each test gets a fresh page (and context). Mocks are scoped automatically. No teardown needed.

Does mocking work with the request fixture (API tests)?

For API-only tests you don't need mocks — you call the real endpoint. Mocks are for browser tests where the page makes the request.

How do I record real responses to use as fixtures?

Run the test against a real backend with page.on('response', logResponse). Capture the JSON. Save to a fixture file. Subsequent runs mock from that file.

What about HAR files?

Playwright can record and replay HAR (HTTP Archive) files. Useful for snapshot-style testing of complex network sequences. Overkill for most cases.

Wrap-Up

Network mocking is a precision tool. Surgical mocks of specific requests are excellent. Wholesale mocking of your entire backend is a smell. Use the four patterns — single error, conditional, slow-network, fixture-driven — and wrap them in reusable fixtures so the tests stay readable.

If your team's tests over-mock or under-mock and you want a sanity check, that's part of framework engagements. Or book a free call.

Related reading:

Tayyab Akmal
// author

Tayyab Akmal

AI & QA Automation Engineer

6 years of catching critical bugs in fintech, e-commerce, and SaaS — then building the Playwright and Selenium automation that prevents them from shipping again.

// feedback_channel

FOUND THIS USEFUL?

Share your thoughts or let's discuss automation testing strategies.

→ Start Conversation
Available for hire