11 KiB
Mocking Strategy: Real vs Mock Services
Table of Contents
- Core Principle
- Decision Matrix
- Decision Flowchart
- Mocking Techniques
- Real Service Strategies
- Hybrid Approach: Fixture-Based Mock Control
- Validating Mock Accuracy
- Anti-Patterns
When to use: Deciding whether to mock API calls, intercept network requests, or hit real services in Playwright tests.
Core Principle
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.
Decision Matrix
| 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 |
Decision Flowchart
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.
Mocking Techniques
Blocking Unwanted Requests
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();
});
Full Mock (route.fulfill)
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');
});
Partial Mock (Modify Responses)
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();
});
Record and Replay (HAR Files)
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:
- Record against a known-good staging environment
- Commit
.harfiles to version control - Re-record when APIs change
- Scope HAR to specific URL patterns
Real Service Strategies
Local Dev Server
// 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',
},
});
Staging Environment
// playwright.config.ts
export default defineConfig({
use: {
baseURL: process.env.CI
? 'https://staging.example.com'
: 'http://localhost:3000',
},
});
Test Containers
// 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');
}
}
Hybrid Approach: Fixture-Based Mock Control
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();
});
});
Environment-Based Test Projects
// 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,
},
],
});
Validating Mock Accuracy
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]);
}
});
});
Anti-Patterns
| 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. |