.agents/skills/playwright-best-practices/architecture/when-to-mock.md
When to use: Deciding whether to mock API calls, intercept network requests, or hit real services in Playwright tests.
Mock at the boundary, test your stack end-to-end. Mock third-party services you don't own (payment gateways, email providers, OAuth). Never mock your own frontend-to-backend communication. Tests should prove YOUR code works, not that third-party APIs are available.
| Scenario | Mock? | Strategy |
|---|---|---|
| Your own REST/GraphQL API | Never | Hit real API against staging or local dev |
| Your database (through your API) | Never | Seed via API or fixtures |
| Authentication (your auth system) | Mostly no | Use storageState to skip login in most tests |
| Stripe / payment gateway | Always | route.fulfill() with expected responses |
| SendGrid / email service | Always | Mock the API call, verify request payload |
| OAuth providers (Google, GitHub) | Always | Mock token exchange, test your callback handler |
| Analytics (Segment, Mixpanel) | Always | route.abort() or route.fulfill() |
| Maps / geocoding APIs | Always | Mock with static responses |
| Feature flags (LaunchDarkly) | Usually | Mock to force specific flag states |
| CDN / static assets | Never | Let them load normally |
| Flaky external dependency | CI: mock, local: real | Conditional mocking based on environment |
| Slow external dependency | Dev: mock, nightly: real | Separate test projects in config |
Is this service part of YOUR codebase?
├── 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 paid per call? → ALWAYS mock.
├── Is it rate-limited? → ALWAYS mock.
├── Is it slow or unreliable? → ALWAYS mock.
└── Is it a complex multi-step flow? → Mock with HAR recording.
Block third-party scripts that slow tests and add no coverage:
test.beforeEach(async ({page}) => {
await page.route('**/{analytics,tracking,segment,hotjar}.{com,io}/**', (route) => {
route.abort()
})
})
test('dashboard renders without tracking scripts', async ({page}) => {
await page.goto('/dashboard')
await expect(page.getByRole('heading', {name: 'Dashboard'})).toBeVisible()
})
Completely replace a third-party API response:
test('order flow with mocked payment service', async ({page}) => {
await page.route('**/api/charge', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
transactionId: 'txn_mock_abc',
status: 'completed',
}),
})
})
await page.goto('/order/confirm')
await page.getByRole('button', {name: 'Complete Purchase'}).click()
await expect(page.getByText('Order confirmed')).toBeVisible()
})
test('display error on payment decline', async ({page}) => {
await page.route('**/api/charge', (route) => {
route.fulfill({
status: 402,
contentType: 'application/json',
body: JSON.stringify({
error: {code: 'insufficient_funds', message: 'Card declined.'},
}),
})
})
await page.goto('/order/confirm')
await page.getByRole('button', {name: 'Complete Purchase'}).click()
await expect(page.getByRole('alert')).toContainText('Card declined')
})
Let the real API call happen but tweak the response:
test('display low inventory warning', async ({page}) => {
await page.route('**/api/inventory/*', async (route) => {
const response = await route.fetch()
const data = await response.json()
data.quantity = 1
data.lowStock = true
await route.fulfill({
response,
body: JSON.stringify(data),
})
})
await page.goto('/products/widget-pro')
await expect(page.getByText('Only 1 remaining')).toBeVisible()
})
test('inject test notification into real response', async ({page}) => {
await page.route('**/api/alerts', async (route) => {
const response = await route.fetch()
const data = await response.json()
data.items.push({
id: 'test-alert',
text: 'Report generated',
category: 'info',
})
await route.fulfill({
response,
body: JSON.stringify(data),
})
})
await page.goto('/home')
await expect(page.getByText('Report generated')).toBeVisible()
})
For complex API sequences (OAuth flows, multi-step wizards):
Recording:
test('capture API traffic for admin panel', async ({page}) => {
await page.routeFromHAR('tests/fixtures/admin-panel.har', {
url: '**/api/**',
update: true,
})
await page.goto('/admin')
await page.getByRole('tab', {name: 'Reports'}).click()
await page.getByRole('tab', {name: 'Settings'}).click()
})
Replaying:
test('admin panel loads with recorded data', async ({page}) => {
await page.routeFromHAR('tests/fixtures/admin-panel.har', {
url: '**/api/**',
update: false,
})
await page.goto('/admin')
await expect(page.getByRole('heading', {name: 'Reports'})).toBeVisible()
})
HAR maintenance:
.har files to version control// playwright.config.ts
export default defineConfig({
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
use: {
baseURL: 'http://localhost:3000',
},
})
// playwright.config.ts
export default defineConfig({
use: {
baseURL: process.env.CI ? 'https://staging.example.com' : 'http://localhost:3000',
},
})
// playwright.config.ts
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,
},
globalTeardown: './tests/global-teardown.ts',
})
// 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')
}
}
Create fixtures that let individual tests opt into mocking specific services:
// tests/fixtures/service-mocks.ts
import {test as base} from '@playwright/test'
type MockConfig = {
mockPayments: boolean
mockNotifications: boolean
mockAnalytics: boolean
}
export const test = base.extend<MockConfig>({
mockPayments: [true, {option: true}],
mockNotifications: [true, {option: true}],
mockAnalytics: [true, {option: true}],
page: async ({page, mockPayments, mockNotifications, mockAnalytics}, use) => {
if (mockPayments) {
await page.route('**/api/billing/**', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({status: 'paid', id: 'inv_mock_789'}),
})
})
}
if (mockNotifications) {
await page.route('**/api/notify', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({delivered: true}),
})
})
}
if (mockAnalytics) {
await page.route('**/{segment,mixpanel,amplitude}.**/**', (route) => {
route.abort()
})
}
await use(page)
},
})
export {expect} from '@playwright/test'
// tests/billing.spec.ts
import {test, expect} from './fixtures/service-mocks'
test('subscription renewal sends notification', async ({page}) => {
await page.goto('/account/billing')
await page.getByRole('button', {name: 'Renew Now'}).click()
await expect(page.getByText('Subscription renewed')).toBeVisible()
})
test.describe('integration suite', () => {
test.use({mockPayments: false})
test('real billing flow against test gateway', async ({page}) => {
await page.goto('/account/billing')
await page.getByRole('button', {name: 'Renew Now'}).click()
await expect(page.getByText('Subscription renewed')).toBeVisible()
})
})
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'ci-fast',
testMatch: '**/*.spec.ts',
use: {baseURL: 'http://localhost:3000'},
},
{
name: 'nightly-full',
testMatch: '**/*.integration.spec.ts',
use: {baseURL: 'https://staging.example.com'},
timeout: 120_000,
},
],
})
Guard against mock drift from real APIs:
test.describe('contract validation', () => {
test('billing mock matches real API shape', async ({request}) => {
const realResponse = await request.post('/api/billing/charge', {
data: {amount: 5000, currency: 'usd'},
})
const realBody = await realResponse.json()
const mockBody = {
status: 'paid',
id: 'inv_mock_789',
}
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])
}
})
})
| Don't Do This | Problem | Do This Instead |
|---|---|---|
| Mock your own API | Tests pass, app breaks. Zero integration coverage. | Hit your real API. Mock only third-party services. |
| Mock everything for speed | You test a fiction. Frontend and backend may be incompatible. | Mock only external boundaries. |
| Never mock anything | Tests are slow, flaky, fail when third parties have outages. | Mock third-party services. |
| Use outdated mocks | Mock returns different shape than real API. | Run contract validation tests. Re-record HAR files regularly. |
Mock with page.evaluate() to stub fetch | Fragile, doesn't survive navigation. | Use page.route() which intercepts at network layer. |
| Copy-paste mocks across files | One API change requires updating many files. | Centralize mocks in fixtures. |
| Block all network and whitelist | Extremely brittle. Every new endpoint requires update. | Allow all by default. Selectively mock third-party services. |