# When to Mock vs Use Real Services > **When to use**: When deciding whether to mock API calls, intercept network requests, or hit real services in your Playwright tests. > **Prerequisites**: [core/locators.md](locators.md), [core/assertions-and-waiting.md](assertions-and-waiting.md) ## Quick Answer **Mock at the boundary, test your stack end-to-end.** Mock third-party services you do not own (Stripe, SendGrid, OAuth providers, analytics). Never mock your own frontend-to-backend communication. Your tests should prove that YOUR code works, not that third-party APIs are up. ## Decision Flowchart ``` Is this service part of YOUR codebase (your API, your backend)? ├── YES → Do NOT mock. Test the real integration. │ ├── Is it slow? → Optimize the service, not the test. │ └── Is it flaky? → Fix the service. Flaky infra is a bug. └── NO → It's a third-party service. ├── Is it free, fast, and reliable? (rare) │ └── Consider real in CI. Mock if rate-limited. ├── Is it paid per call? (Stripe, Twilio, SendGrid) │ └── ALWAYS mock. ├── Is it rate-limited? (OAuth, social APIs) │ └── ALWAYS mock. ├── Is it slow or unreliable? │ └── ALWAYS mock. └── Is it a complex multi-step flow? (OAuth redirect dance) └── Mock with HAR recording. Update periodically. ``` ## Decision Matrix | Scenario | Mock? | Why | Strategy | |---|---|---|---| | Your own REST/GraphQL API | Never | This IS the integration you are testing | Hit real API against staging or local dev | | Your database (through your API) | Never | Data round-trips are the whole point of E2E | Seed via API or fixtures, never mock DB | | Authentication (your auth system) | Mostly no | Auth bugs are critical; test the real flow | Use `storageState` to skip login in most tests, but keep a few real login tests | | Stripe / payment gateway | Always | Costs money, rate-limited, flaky in CI | `route.fulfill()` with expected responses | | SendGrid / email service | Always | Side effects (real emails), no UI to assert | Mock the API call, verify request payload | | OAuth providers (Google, GitHub) | Always | Redirect-heavy, rate-limited, CAPTCHAs | Mock token exchange, test your callback handler | | Analytics (Segment, Mixpanel) | Always | Fire-and-forget, no UI impact, slows tests | `route.abort()` or `route.fulfill()` | | Maps / geocoding APIs | Always | Rate-limited, paid, slow | Mock with static responses | | Feature flags (LaunchDarkly, etc.) | Usually | Control test conditions deterministically | Mock to force specific flag states | | CDN / static assets | Never | Already fast, part of your infra | Let them load normally | | Flaky external dependency | CI: mock, local: real | Keeps CI green, catches real issues locally | Conditional mocking based on environment | | Slow external dependency | Dev: mock, nightly: real | Fast feedback in dev, full integration in nightly | Separate test projects in config | ## Mocking Strategies ### Full Mock (route.fulfill) **Use when**: You want to completely replace a third-party API response. The most common mocking strategy. **TypeScript** ```typescript import { test, expect } from '@playwright/test'; test('checkout flow with mocked payment API', async ({ page }) => { // Mock Stripe payment intent creation await page.route('**/api/create-payment-intent', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ clientSecret: 'pi_mock_secret_123', amount: 9900, currency: 'usd', }), }); }); // Mock Stripe confirmation await page.route('**/api/confirm-payment', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'succeeded', receiptUrl: 'https://receipt.example.com/123', }), }); }); await page.goto('/checkout'); await page.getByRole('button', { name: 'Pay $99.00' }).click(); await expect(page.getByText('Payment successful')).toBeVisible(); }); test('handle payment failure gracefully', async ({ page }) => { // Mock a declined card response await page.route('**/api/confirm-payment', (route) => { route.fulfill({ status: 402, contentType: 'application/json', body: JSON.stringify({ error: { code: 'card_declined', message: 'Your card was declined.' }, }), }); }); await page.goto('/checkout'); await page.getByRole('button', { name: 'Pay $99.00' }).click(); await expect(page.getByRole('alert')).toContainText('card was declined'); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); test('checkout flow with mocked payment API', async ({ page }) => { await page.route('**/api/create-payment-intent', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ clientSecret: 'pi_mock_secret_123', amount: 9900, currency: 'usd', }), }); }); await page.route('**/api/confirm-payment', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'succeeded', receiptUrl: 'https://receipt.example.com/123', }), }); }); await page.goto('/checkout'); await page.getByRole('button', { name: 'Pay $99.00' }).click(); await expect(page.getByText('Payment successful')).toBeVisible(); }); test('handle payment failure gracefully', async ({ page }) => { await page.route('**/api/confirm-payment', (route) => { route.fulfill({ status: 402, contentType: 'application/json', body: JSON.stringify({ error: { code: 'card_declined', message: 'Your card was declined.' }, }), }); }); await page.goto('/checkout'); await page.getByRole('button', { name: 'Pay $99.00' }).click(); await expect(page.getByRole('alert')).toContainText('card was declined'); }); ``` ### Partial Mock (Modify Responses) **Use when**: You want the real API call to happen but need to tweak the response -- injecting error states, adding edge-case data, or overriding a single field. **TypeScript** ```typescript import { test, expect } from '@playwright/test'; test('show warning when inventory is low', async ({ page }) => { // Let the real API call go through, but override the stock count await page.route('**/api/products/*', async (route) => { const response = await route.fetch(); // forward to real server const body = await response.json(); // Modify just the field we care about body.stockCount = 2; body.lowStockWarning = true; await route.fulfill({ response, // preserve headers, status body: JSON.stringify(body), }); }); await page.goto('/products/running-shoes'); await expect(page.getByText('Only 2 left in stock')).toBeVisible(); await expect(page.getByRole('button', { name: 'Add to cart' })).toBeEnabled(); }); test('inject additional items into a real API response', async ({ page }) => { await page.route('**/api/notifications', async (route) => { const response = await route.fetch(); const body = await response.json(); // Append a test notification to whatever real data comes back body.notifications.push({ id: 'test-notif', message: 'Your export is ready', type: 'success', read: false, }); await route.fulfill({ response, body: JSON.stringify(body), }); }); await page.goto('/dashboard'); await expect(page.getByText('Your export is ready')).toBeVisible(); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); test('show warning when inventory is low', async ({ page }) => { await page.route('**/api/products/*', async (route) => { const response = await route.fetch(); const body = await response.json(); body.stockCount = 2; body.lowStockWarning = true; await route.fulfill({ response, body: JSON.stringify(body), }); }); await page.goto('/products/running-shoes'); await expect(page.getByText('Only 2 left in stock')).toBeVisible(); await expect(page.getByRole('button', { name: 'Add to cart' })).toBeEnabled(); }); test('inject additional items into a real API response', async ({ page }) => { await page.route('**/api/notifications', async (route) => { const response = await route.fetch(); const body = await response.json(); body.notifications.push({ id: 'test-notif', message: 'Your export is ready', type: 'success', read: false, }); await route.fulfill({ response, body: JSON.stringify(body), }); }); await page.goto('/dashboard'); await expect(page.getByText('Your export is ready')).toBeVisible(); }); ``` ### Record and Replay (HAR Files) **Use when**: Complex API sequences with many endpoints (OAuth flows, multi-step wizards, dashboard data loading). Record once from a real session, replay deterministically. Update the recording periodically so mocks do not drift from reality. **Recording a HAR file:** **TypeScript** ```typescript import { test } from '@playwright/test'; // Record HAR — run this once, then commit the .har file test('record API traffic for dashboard', async ({ page }) => { await page.routeFromHAR('tests/fixtures/dashboard.har', { url: '**/api/**', update: true, // record mode: forwards requests and saves responses }); await page.goto('/dashboard'); // Interact with the page to capture all relevant API calls await page.getByRole('tab', { name: 'Analytics' }).click(); await page.getByRole('tab', { name: 'Users' }).click(); await page.getByRole('button', { name: 'Load more' }).click(); // HAR file is saved automatically when the page closes }); ``` **Replaying a HAR file:** **TypeScript** ```typescript import { test, expect } from '@playwright/test'; test('dashboard loads with recorded data', async ({ page }) => { // Replay mode: serves responses from the HAR file await page.routeFromHAR('tests/fixtures/dashboard.har', { url: '**/api/**', update: false, // replay mode (default) }); await page.goto('/dashboard'); await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible(); await expect(page.getByTestId('revenue-chart')).toBeVisible(); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); // Record test('record API traffic for dashboard', async ({ page }) => { await page.routeFromHAR('tests/fixtures/dashboard.har', { url: '**/api/**', update: true, }); await page.goto('/dashboard'); await page.getByRole('tab', { name: 'Analytics' }).click(); await page.getByRole('tab', { name: 'Users' }).click(); await page.getByRole('button', { name: 'Load more' }).click(); }); // Replay test('dashboard loads with recorded data', async ({ page }) => { await page.routeFromHAR('tests/fixtures/dashboard.har', { url: '**/api/**', update: false, }); await page.goto('/dashboard'); await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible(); await expect(page.getByTestId('revenue-chart')).toBeVisible(); }); ``` **HAR maintenance workflow:** 1. Record HAR files against a known-good staging environment. 2. Commit `.har` files to version control (they are JSON, diffable). 3. Re-record monthly or when APIs change. Add a CI reminder or calendar event. 4. Use `update: true` in a dedicated test file to refresh recordings. 5. Scope HAR to specific URL patterns (`url: '**/api/v2/**'`) so unrelated requests still hit real servers. ### Blocking Unwanted Requests **Use when**: Third-party scripts (analytics, ads, chat widgets) slow down tests and add no value. Block them outright. **TypeScript** ```typescript import { test, expect } from '@playwright/test'; test.beforeEach(async ({ page }) => { // Block analytics and tracking — they slow tests and add no coverage await page.route('**/{google-analytics,segment,hotjar,intercom}.{com,io}/**', (route) => { route.abort(); }); // Block all image requests in tests that don't need them // await page.route('**/*.{png,jpg,jpeg,gif,svg,webp}', (route) => route.abort()); }); test('page loads fast without third-party scripts', async ({ page }) => { await page.goto('/dashboard'); await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); test.beforeEach(async ({ page }) => { await page.route('**/{google-analytics,segment,hotjar,intercom}.{com,io}/**', (route) => { route.abort(); }); }); test('page loads fast without third-party scripts', async ({ page }) => { await page.goto('/dashboard'); await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); }); ``` ## Real Service Strategies ### Against Staging Environment **Use when**: You have a shared staging environment that mirrors production. Best for integration confidence. **TypeScript** ```typescript // playwright.config.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ use: { baseURL: process.env.CI ? 'https://staging.yourapp.com' : 'http://localhost:3000', }, projects: [ { name: 'integration', testMatch: '**/*.integration.spec.ts', use: { baseURL: 'https://staging.yourapp.com' }, }, { name: 'e2e', testMatch: '**/*.e2e.spec.ts', use: { baseURL: 'http://localhost:3000' }, }, ], }); ``` **JavaScript** ```javascript // playwright.config.js const { defineConfig } = require('@playwright/test'); module.exports = defineConfig({ use: { baseURL: process.env.CI ? 'https://staging.yourapp.com' : 'http://localhost:3000', }, projects: [ { name: 'integration', testMatch: '**/*.integration.spec.js', use: { baseURL: 'https://staging.yourapp.com' }, }, { name: 'e2e', testMatch: '**/*.e2e.spec.js', use: { baseURL: 'http://localhost:3000' }, }, ], }); ``` ### Against Local Dev Server **Use when**: Fastest feedback loop. Run your backend locally and test against it. **TypeScript** ```typescript // playwright.config.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ webServer: { command: 'npm run dev', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, // start fresh in CI, reuse locally timeout: 30_000, }, use: { baseURL: 'http://localhost:3000', }, }); ``` **JavaScript** ```javascript // playwright.config.js const { defineConfig } = require('@playwright/test'); module.exports = defineConfig({ webServer: { command: 'npm run dev', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, timeout: 30_000, }, use: { baseURL: 'http://localhost:3000', }, }); ``` ### Against Test Containers **Use when**: You need a fully isolated environment with databases, caches, and services. Best for reproducible CI runs. **TypeScript** ```typescript // playwright.config.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ webServer: { command: 'docker compose -f docker-compose.test.yml up --wait', url: 'http://localhost:3000/health', reuseExistingServer: !process.env.CI, timeout: 120_000, // containers take longer to start }, use: { baseURL: 'http://localhost:3000', }, // Global teardown to stop containers globalTeardown: './tests/global-teardown.ts', }); ``` ```typescript // tests/global-teardown.ts import { execSync } from 'child_process'; export default function globalTeardown() { if (process.env.CI) { execSync('docker compose -f docker-compose.test.yml down -v'); } } ``` **JavaScript** ```javascript // playwright.config.js const { defineConfig } = require('@playwright/test'); module.exports = defineConfig({ webServer: { command: 'docker compose -f docker-compose.test.yml up --wait', url: 'http://localhost:3000/health', reuseExistingServer: !process.env.CI, timeout: 120_000, }, use: { baseURL: 'http://localhost:3000', }, globalTeardown: './tests/global-teardown.js', }); ``` ```javascript // tests/global-teardown.js const { execSync } = require('child_process'); module.exports = function globalTeardown() { if (process.env.CI) { execSync('docker compose -f docker-compose.test.yml down -v'); } }; ``` ## Hybrid Approach The strongest test suites combine real and mocked services. The principle: **mock what you do not own, run what you do.** ### Fixture-Based Mock Control Create a fixture that lets individual tests opt into mocking specific services while keeping everything else real. **TypeScript** ```typescript // tests/fixtures/mock-fixtures.ts import { test as base } from '@playwright/test'; type MockOptions = { mockPayments: boolean; mockEmail: boolean; mockAnalytics: boolean; }; export const test = base.extend({ mockPayments: [true, { option: true }], // default: mock payments mockEmail: [true, { option: true }], // default: mock email mockAnalytics: [true, { option: true }], // default: mock analytics page: async ({ page, mockPayments, mockEmail, mockAnalytics }, use) => { if (mockPayments) { await page.route('**/api/payments/**', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'succeeded', id: 'pay_mock_123' }), }); }); } if (mockEmail) { await page.route('**/api/send-email', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ messageId: 'msg_mock_456' }), }); }); } if (mockAnalytics) { await page.route('**/{segment,google-analytics,mixpanel}.**/**', (route) => { route.abort(); }); } await use(page); }, }); export { expect } from '@playwright/test'; ``` ```typescript // tests/checkout.spec.ts import { test, expect } from './fixtures/mock-fixtures'; // Uses defaults: payments mocked, email mocked, analytics blocked test('checkout sends confirmation email', async ({ page }) => { await page.goto('/checkout'); await page.getByRole('button', { name: 'Pay $99.00' }).click(); await expect(page.getByText('Confirmation email sent')).toBeVisible(); }); // Override: test with real payment API (nightly integration test) test.describe('nightly integration', () => { test.use({ mockPayments: false }); test('real payment flow against Stripe test mode', async ({ page }) => { await page.goto('/checkout'); // This hits the real Stripe test API await page.getByRole('button', { name: 'Pay $99.00' }).click(); await expect(page.getByText('Payment successful')).toBeVisible(); }); }); ``` **JavaScript** ```javascript // tests/fixtures/mock-fixtures.js const { test: base } = require('@playwright/test'); const test = base.extend({ mockPayments: [true, { option: true }], mockEmail: [true, { option: true }], mockAnalytics: [true, { option: true }], page: async ({ page, mockPayments, mockEmail, mockAnalytics }, use) => { if (mockPayments) { await page.route('**/api/payments/**', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'succeeded', id: 'pay_mock_123' }), }); }); } if (mockEmail) { await page.route('**/api/send-email', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ messageId: 'msg_mock_456' }), }); }); } if (mockAnalytics) { await page.route('**/{segment,google-analytics,mixpanel}.**/**', (route) => { route.abort(); }); } await use(page); }, }); module.exports = { test, expect: require('@playwright/test').expect }; ``` ```javascript // tests/checkout.spec.js const { test, expect } = require('./fixtures/mock-fixtures'); test('checkout sends confirmation email', async ({ page }) => { await page.goto('/checkout'); await page.getByRole('button', { name: 'Pay $99.00' }).click(); await expect(page.getByText('Confirmation email sent')).toBeVisible(); }); test.describe('nightly integration', () => { test.use({ mockPayments: false }); test('real payment flow against Stripe test mode', async ({ page }) => { await page.goto('/checkout'); await page.getByRole('button', { name: 'Pay $99.00' }).click(); await expect(page.getByText('Payment successful')).toBeVisible(); }); }); ``` ### Environment-Based Mocking Split test projects by environment to run mocked tests in every CI push and full-integration tests nightly. **TypeScript** ```typescript // playwright.config.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ projects: [ { name: 'fast-ci', testMatch: '**/*.spec.ts', use: { baseURL: 'http://localhost:3000', // All external services mocked via fixtures }, }, { name: 'nightly-integration', testMatch: '**/*.integration.spec.ts', use: { baseURL: 'https://staging.yourapp.com', // Real services, longer timeouts }, timeout: 120_000, }, ], }); ``` **JavaScript** ```javascript // playwright.config.js const { defineConfig } = require('@playwright/test'); module.exports = defineConfig({ projects: [ { name: 'fast-ci', testMatch: '**/*.spec.js', use: { baseURL: 'http://localhost:3000', }, }, { name: 'nightly-integration', testMatch: '**/*.integration.spec.js', use: { baseURL: 'https://staging.yourapp.com', }, timeout: 120_000, }, ], }); ``` ### Verifying Mock Accuracy Mock responses drift from real APIs over time. Guard against this. **TypeScript** ```typescript import { test, expect } from '@playwright/test'; // Run this weekly or when APIs change — validates that mocks match reality test.describe('mock contract validation', () => { test('payment mock matches real Stripe test API shape', async ({ request }) => { // Hit the real API const realResponse = await request.post('/api/create-payment-intent', { data: { amount: 9900, currency: 'usd' }, }); const realBody = await realResponse.json(); // Verify mock has the same shape const mockBody = { clientSecret: 'pi_mock_secret_123', amount: 9900, currency: 'usd', }; // Same keys exist in both expect(Object.keys(mockBody).sort()).toEqual(Object.keys(realBody).sort()); // Same types for each key for (const key of Object.keys(mockBody)) { expect(typeof mockBody[key]).toBe(typeof realBody[key]); } }); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); test.describe('mock contract validation', () => { test('payment mock matches real Stripe test API shape', async ({ request }) => { const realResponse = await request.post('/api/create-payment-intent', { data: { amount: 9900, currency: 'usd' }, }); const realBody = await realResponse.json(); const mockBody = { clientSecret: 'pi_mock_secret_123', amount: 9900, currency: 'usd', }; expect(Object.keys(mockBody).sort()).toEqual(Object.keys(realBody).sort()); for (const key of Object.keys(mockBody)) { expect(typeof mockBody[key]).toBe(typeof realBody[key]); } }); }); ``` ## Anti-Patterns | Don't Do This | Problem | Do This Instead | |---|---|---| | Mock your own API (`page.route('**/api/users', ...)` when you own the `/api/users` endpoint) | You are testing a fiction. Your frontend and backend may be completely incompatible. | Hit your real API. Mock only third-party services behind your API. | | Mock everything for speed | Tests pass, app breaks. You have zero integration coverage. | Mock only external boundaries. Optimize your own services for test speed. | | Never mock anything | Tests are slow, flaky, and fail when Stripe has an outage. You test third-party uptime, not your code. | Mock third-party services. Your CI should not depend on someone else's infrastructure. | | Use outdated mocks that do not match the real API | Mock returns `{ status: "ok" }` but real API returns `{ status: "success", data: {...} }`. Tests pass, production breaks. | Run contract validation tests periodically. Re-record HAR files monthly. | | Mock at the wrong layer (intercepting your own frontend HTTP client) | Bypasses request/response serialization, headers, error handling. | Mock at the network level with `page.route()`. This tests your full HTTP client code. | | Copy-paste mock responses across dozens of test files | One API change requires updating 40 files. Mocks diverge. | Centralize mocks in fixtures or helper files. Single source of truth. | | Mock with `page.evaluate()` to stub `fetch`/`XMLHttpRequest` | Fragile, does not survive navigation, misses service workers. | Use `page.route()` which intercepts at the network layer. | | Block all network requests and whitelist | Extremely brittle. Every new API endpoint requires a whitelist update. Tests break on any backend change. | Allow all traffic by default. Selectively mock only the third-party services you need to. | ## Related - [core/network-mocking.md](network-mocking.md) -- detailed network interception patterns and API - [core/api-testing.md](api-testing.md) -- testing your API directly with `request` context - [core/authentication.md](authentication.md) -- when to mock auth vs test real login flows - [core/fixtures-and-hooks.md](fixtures-and-hooks.md) -- building reusable mock fixtures - [core/configuration.md](configuration.md) -- `webServer`, `baseURL`, and project configuration - [ci/ci-github-actions.md](../ci/ci-github-actions.md) -- CI setup for different test tiers