When I decide on a Playwright test boundary, I start with one question: what failure am I trying to catch here, and what failure am I willing to ignore?

That sounds simple, but it is the difference between a fast suite that gives false confidence and a slow suite that nobody trusts. In browser tests, mocking everything is tempting. It makes tests stable, but it can also hide real bugs in request payloads, auth flows, caching, rate limits, and backend contract changes. Hitting every real service is also tempting, because it feels “more end-to-end,” but it usually turns the suite into a flaky, expensive integration lab.

My practical rule is this: mock only where the external behavior is not the subject of the test, stub when I need a controlled response but still want to exercise my code path, and hit the real service only when the contract or integration itself is what I want to verify.

The decision framework I actually use

I group each dependency into one of three buckets.

1) Mock it when the dependency is not under test

If a service is noisy, expensive, slow, or unrelated to the behavior I care about, I mock it. Typical examples are analytics calls, feature flag providers, email delivery side effects, or payment providers in a UI flow where I am not validating the provider itself.

A mock is useful when I want to assert that my app made a request, but I do not care about the service’s real response mechanics. In Playwright, that often means intercepting a route and returning a canned response.

import { test, expect } from '@playwright/test';
test('shows the dashboard for an authenticated user', async ({ page }) => {
  await page.route('**/api/me', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ id: 'u_123', name: 'Ada' }),
    });
  });

await page.goto(‘/dashboard’); await expect(page.getByText(‘Ada’)).toBeVisible(); });

This is a good fit when the test is about UI rendering, routing, or local state transitions. It is a bad fit if the JSON shape itself is part of the contract you need to protect.

A mock that saves 30 seconds but hides a broken payload is usually not a win.

2) Stub it when I need control, but still want realism in my app path

I use “stub” to mean a controlled test double with a predictable response, often close to the real schema. In browser tests, the value is consistency. I can simulate empty states, error states, pagination, timeouts, or edge-case records without depending on unstable backend data.

A stub is what I reach for when the UI logic is more important than the remote service. For example, if the page has a retry button, I want to stub a 500 response first, then a 200 response on retry.

import { test, expect } from '@playwright/test';
test('retries after a transient server error', async ({ page }) => {
  let calls = 0;

await page.route(‘**/api/orders’, async route => { calls += 1; if (calls === 1) { await route.fulfill({ status: 500, body: ‘error’ }); return; }

await route.fulfill({
  status: 200,
  contentType: 'application/json',
  body: JSON.stringify([{ id: 'o_1', total: 42 }]),
});   });

await page.goto(‘/orders’); await page.getByRole(‘button’, { name: ‘Retry’ }).click(); await expect(page.getByText(‘$42’)).toBeVisible(); });

This is still a test double, but it is closer to the production shape of the interaction. The tradeoff is maintenance. If the backend schema changes, the stub can drift unless you keep it aligned with contracts or shared fixtures.

3) Hit the real service when the integration itself matters

I hit the real service when I care about the seam between browser, application, auth, and backend. That includes login flows, permission checks, checkout, file uploads, search integrations, webhook-driven refreshes, and anything where request serialization or backend validation has historically broken.

Real-service tests catch things mocks miss, like missing headers, expired cookies, CSRF behavior, serialization bugs, backend validation failures, and environment-specific config mistakes. They are slower and less deterministic, so I keep them selective.

If a test would still pass even when the production service changed in a way that breaks the app, then that test is probably too mocked.

My boundary checklist

Before I choose a mock stub or real call in Playwright tests, I ask five questions.

1) Is the service part of the user promise?

If the user cares about the outcome, I prefer a real call somewhere in the suite.

Examples:

  • Login should prove real auth wiring at least once.
  • Checkout should prove payment or payment-adjacent integration in a controlled environment.
  • Search should prove that the app can talk to the search backend and render actual results.

If the service is invisible to the user and only supports observability or internal bookkeeping, mock it by default.

2) Is the failure mode expensive to miss?

Some bugs are worth the extra cost. A broken payment callback, a bad redirect after login, or a malformed API request can block users and revenue. I am willing to pay for a slower test if it catches those failures.

Other failures are cheap to catch elsewhere. If the issue is just a telemetry event missing a property, I do not need a real browser test to protect it.

3) Can I isolate the dependency without making the test brittle?

A good Playwright mocking strategy keeps the test readable. If I have to intercept six network calls, synchronize state across three routes, and maintain a pile of fixture JSON, I usually over-mocked the flow.

That is often a sign I should move part of the coverage to an API test, a component test, or a smaller contract test.

4) Will the double drift from production behavior?

The more logic I put into a stub, the more likely it becomes a second implementation. That is where test suites become dishonest.

Common drift risks:

  • Hand-maintained fixture JSON no longer matches backend schema.
  • Mocked errors do not resemble real errors.
  • “Success” responses skip auth, rate limiting, or pagination details.

If the test double needs lots of logic, I usually simplify the test or move it closer to the real service.

5) What is the cheapest reliable layer for this assertion?

I try not to force every assertion through the browser.

If I only need to verify data transformation, use an API test or unit test. If I need to verify rendering and interaction, use Playwright. If I need to verify service contract behavior, use the real service or a contract-level test.

That keeps the total cost of ownership lower than trying to make browser tests carry everything.

A practical matrix for Playwright E2E boundaries

Here is the decision pattern I use most often.

Dependency Default choice Why
Analytics, logging, feature flags Mock Not the test subject, high noise, low value in browser E2E
Third-party email/SMS delivery Stub or mock Usually enough to verify that the request was initiated
Backend APIs for page data Stub in most UI tests, real in a few contract-like E2E tests Balances stability and realism
Auth/session flow Real at least in one critical path Common source of integration bugs
Payments, search, uploads Real in targeted tests Contract and serialization matter
Edge-case states Stub Easy to force deterministic scenarios

This is not a rigid rulebook. It is a maintenance filter. The more expensive the service is to run and the less the user cares about its internal behavior, the more I lean toward a double.

Where over-mocking hurts most

The biggest failure mode I see is a suite that validates the UI against fictional data and fictional service behavior, then declares the product healthy.

A few examples:

  • The app expects createdAt but the mock returns created_at.
  • The real API returns 403 for a permission issue, but the stub always returns 200.
  • The frontend sends a nested payload that looks correct in the fixture, but production rejects it.
  • The UI never exercises a loading spinner or disabled state because the response is always instant.

These are real bugs, and mocks can hide them for a long time.

The fix is not “never mock.” The fix is to be intentional about where the contract lives. If the browser test is supposed to validate the request boundary, then some tests need to hit the real service or at least a very faithful contract stub.

Where hitting real services hurts most

The opposite failure mode is also common. Teams put every Playwright test against shared environments and then spend the rest of the week triaging flakes.

Typical causes:

  • Shared test data gets mutated by another run.
  • External services rate-limit or throttle CI.
  • Background jobs create timing races.
  • Environment config drifts.
  • Network or browser-cloud issues become indistinguishable from product bugs.

Real-service tests are useful, but they need boundaries. I usually keep them small in number and stable in scope. One or two critical-path tests that run against real infrastructure are often enough to catch broken integrations.

A simple pattern I like in Playwright

I prefer to centralize test data setup, then use route interception only where it improves signal.

For example, if most of the page is driven by backend data, I may seed through an API or test fixture, then stub only the unstable dependency.

import { test, expect } from '@playwright/test';
test('renders a profile page with real app data and mocked notifications', async ({ page }) => {
  await page.route('**/api/notifications', route =>
    route.fulfill({ status: 200, body: JSON.stringify([]) })
  );

await page.goto(‘/profile’); await expect(page.getByRole(‘heading’, { name: ‘Profile’ })).toBeVisible(); });

This keeps the browser test focused on page behavior instead of making it responsible for every upstream dependency.

My default recommendation

If a team is unsure where to start, I recommend this order:

  1. Mock non-critical side effects.
  2. Stub page data and edge cases.
  3. Hit the real service for a small number of critical paths.
  4. Add contract or API tests where the browser layer adds little value.

That gives you a better balance of speed, confidence, and maintainability than either extreme. It also makes flaky test triage easier, because you can usually tell whether a failure came from the UI, the app boundary, or the dependency itself.

The rule I come back to

I do not ask, “Can I mock this?” I ask, “What am I losing if I mock this?”

If the answer is only noise, I mock or stub it. If the answer is the actual bug I care about, I let the test touch the real service.

That is the boundary judgment that keeps Playwright E2E tests useful instead of theatrical.