Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Comprehensive Playwright testing guide covering E2E, component, API, visual, accessibility, and security tests.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
architecture/when-to-mock.md
1# Mocking Strategy: Real vs Mock Services23## Table of Contents451. [Core Principle](#core-principle)62. [Decision Matrix](#decision-matrix)73. [Decision Flowchart](#decision-flowchart)84. [Mocking Techniques](#mocking-techniques)95. [Real Service Strategies](#real-service-strategies)106. [Hybrid Approach: Fixture-Based Mock Control](#hybrid-approach-fixture-based-mock-control)117. [Validating Mock Accuracy](#validating-mock-accuracy)128. [Anti-Patterns](#anti-patterns)1314> **When to use**: Deciding whether to mock API calls, intercept network requests, or hit real services in Playwright tests.1516## Core Principle1718**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.1920## Decision Matrix2122| Scenario | Mock? | Strategy |23| --- | --- | --- |24| Your own REST/GraphQL API | Never | Hit real API against staging or local dev |25| Your database (through your API) | Never | Seed via API or fixtures |26| Authentication (your auth system) | Mostly no | Use `storageState` to skip login in most tests |27| Stripe / payment gateway | Always | `route.fulfill()` with expected responses |28| SendGrid / email service | Always | Mock the API call, verify request payload |29| OAuth providers (Google, GitHub) | Always | Mock token exchange, test your callback handler |30| Analytics (Segment, Mixpanel) | Always | `route.abort()` or `route.fulfill()` |31| Maps / geocoding APIs | Always | Mock with static responses |32| Feature flags (LaunchDarkly) | Usually | Mock to force specific flag states |33| CDN / static assets | Never | Let them load normally |34| Flaky external dependency | CI: mock, local: real | Conditional mocking based on environment |35| Slow external dependency | Dev: mock, nightly: real | Separate test projects in config |3637## Decision Flowchart3839```text40Is this service part of YOUR codebase?41├── YES → Do NOT mock. Test the real integration.42│ ├── Is it slow? → Optimize the service, not the test.43│ └── Is it flaky? → Fix the service. Flaky infra is a bug.44└── NO → It's a third-party service.45├── Is it paid per call? → ALWAYS mock.46├── Is it rate-limited? → ALWAYS mock.47├── Is it slow or unreliable? → ALWAYS mock.48└── Is it a complex multi-step flow? → Mock with HAR recording.49```5051## Mocking Techniques5253### Blocking Unwanted Requests5455Block third-party scripts that slow tests and add no coverage:5657```typescript58test.beforeEach(async ({ page }) => {59await page.route('**/{analytics,tracking,segment,hotjar}.{com,io}/**', (route) => {60route.abort();61});62});6364test('dashboard renders without tracking scripts', async ({ page }) => {65await page.goto('/dashboard');66await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();67});68```6970### Full Mock (route.fulfill)7172Completely replace a third-party API response:7374```typescript75test('order flow with mocked payment service', async ({ page }) => {76await page.route('**/api/charge', (route) => {77route.fulfill({78status: 200,79contentType: 'application/json',80body: JSON.stringify({81transactionId: 'txn_mock_abc',82status: 'completed',83}),84});85});8687await page.goto('/order/confirm');88await page.getByRole('button', { name: 'Complete Purchase' }).click();89await expect(page.getByText('Order confirmed')).toBeVisible();90});9192test('display error on payment decline', async ({ page }) => {93await page.route('**/api/charge', (route) => {94route.fulfill({95status: 402,96contentType: 'application/json',97body: JSON.stringify({98error: { code: 'insufficient_funds', message: 'Card declined.' },99}),100});101});102103await page.goto('/order/confirm');104await page.getByRole('button', { name: 'Complete Purchase' }).click();105await expect(page.getByRole('alert')).toContainText('Card declined');106});107```108109### Partial Mock (Modify Responses)110111Let the real API call happen but tweak the response:112113```typescript114test('display low inventory warning', async ({ page }) => {115await page.route('**/api/inventory/*', async (route) => {116const response = await route.fetch();117const data = await response.json();118119data.quantity = 1;120data.lowStock = true;121122await route.fulfill({123response,124body: JSON.stringify(data),125});126});127128await page.goto('/products/widget-pro');129await expect(page.getByText('Only 1 remaining')).toBeVisible();130});131132test('inject test notification into real response', async ({ page }) => {133await page.route('**/api/alerts', async (route) => {134const response = await route.fetch();135const data = await response.json();136137data.items.push({138id: 'test-alert',139text: 'Report generated',140category: 'info',141});142143await route.fulfill({144response,145body: JSON.stringify(data),146});147});148149await page.goto('/home');150await expect(page.getByText('Report generated')).toBeVisible();151});152```153154### Record and Replay (HAR Files)155156For complex API sequences (OAuth flows, multi-step wizards):157158**Recording:**159160```typescript161test('capture API traffic for admin panel', async ({ page }) => {162await page.routeFromHAR('tests/fixtures/admin-panel.har', {163url: '**/api/**',164update: true,165});166167await page.goto('/admin');168await page.getByRole('tab', { name: 'Reports' }).click();169await page.getByRole('tab', { name: 'Settings' }).click();170});171```172173**Replaying:**174175```typescript176test('admin panel loads with recorded data', async ({ page }) => {177await page.routeFromHAR('tests/fixtures/admin-panel.har', {178url: '**/api/**',179update: false,180});181182await page.goto('/admin');183await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();184});185```186187**HAR maintenance:**188189- Record against a known-good staging environment190- Commit `.har` files to version control191- Re-record when APIs change192- Scope HAR to specific URL patterns193194## Real Service Strategies195196### Local Dev Server197198```typescript199// playwright.config.ts200export default defineConfig({201webServer: {202command: 'npm run dev',203url: 'http://localhost:3000',204reuseExistingServer: !process.env.CI,205timeout: 30_000,206},207use: {208baseURL: 'http://localhost:3000',209},210});211```212213### Staging Environment214215```typescript216// playwright.config.ts217export default defineConfig({218use: {219baseURL: process.env.CI220? 'https://staging.example.com'221: 'http://localhost:3000',222},223});224```225226### Test Containers227228```typescript229// playwright.config.ts230export default defineConfig({231webServer: {232command: 'docker compose -f docker-compose.test.yml up --wait',233url: 'http://localhost:3000/health',234reuseExistingServer: !process.env.CI,235timeout: 120_000,236},237globalTeardown: './tests/global-teardown.ts',238});239```240241```typescript242// tests/global-teardown.ts243import { execSync } from 'child_process';244245export default function globalTeardown() {246if (process.env.CI) {247execSync('docker compose -f docker-compose.test.yml down -v');248}249}250```251252## Hybrid Approach: Fixture-Based Mock Control253254Create fixtures that let individual tests opt into mocking specific services:255256```typescript257// tests/fixtures/service-mocks.ts258import { test as base } from '@playwright/test';259260type MockConfig = {261mockPayments: boolean;262mockNotifications: boolean;263mockAnalytics: boolean;264};265266export const test = base.extend<MockConfig>({267mockPayments: [true, { option: true }],268mockNotifications: [true, { option: true }],269mockAnalytics: [true, { option: true }],270271page: async ({ page, mockPayments, mockNotifications, mockAnalytics }, use) => {272if (mockPayments) {273await page.route('**/api/billing/**', (route) => {274route.fulfill({275status: 200,276contentType: 'application/json',277body: JSON.stringify({ status: 'paid', id: 'inv_mock_789' }),278});279});280}281282if (mockNotifications) {283await page.route('**/api/notify', (route) => {284route.fulfill({285status: 200,286contentType: 'application/json',287body: JSON.stringify({ delivered: true }),288});289});290}291292if (mockAnalytics) {293await page.route('**/{segment,mixpanel,amplitude}.**/**', (route) => {294route.abort();295});296}297298await use(page);299},300});301302export { expect } from '@playwright/test';303```304305```typescript306// tests/billing.spec.ts307import { test, expect } from './fixtures/service-mocks';308309test('subscription renewal sends notification', async ({ page }) => {310await page.goto('/account/billing');311await page.getByRole('button', { name: 'Renew Now' }).click();312await expect(page.getByText('Subscription renewed')).toBeVisible();313});314315test.describe('integration suite', () => {316test.use({ mockPayments: false });317318test('real billing flow against test gateway', async ({ page }) => {319await page.goto('/account/billing');320await page.getByRole('button', { name: 'Renew Now' }).click();321await expect(page.getByText('Subscription renewed')).toBeVisible();322});323});324```325326### Environment-Based Test Projects327328```typescript329// playwright.config.ts330export default defineConfig({331projects: [332{333name: 'ci-fast',334testMatch: '**/*.spec.ts',335use: { baseURL: 'http://localhost:3000' },336},337{338name: 'nightly-full',339testMatch: '**/*.integration.spec.ts',340use: { baseURL: 'https://staging.example.com' },341timeout: 120_000,342},343],344});345```346347## Validating Mock Accuracy348349Guard against mock drift from real APIs:350351```typescript352test.describe('contract validation', () => {353test('billing mock matches real API shape', async ({ request }) => {354const realResponse = await request.post('/api/billing/charge', {355data: { amount: 5000, currency: 'usd' },356});357const realBody = await realResponse.json();358359const mockBody = {360status: 'paid',361id: 'inv_mock_789',362};363364expect(Object.keys(mockBody).sort()).toEqual(Object.keys(realBody).sort());365366for (const key of Object.keys(mockBody)) {367expect(typeof mockBody[key]).toBe(typeof realBody[key]);368}369});370});371```372373## Anti-Patterns374375| Don't Do This | Problem | Do This Instead |376| --- | --- | --- |377| Mock your own API | Tests pass, app breaks. Zero integration coverage. | Hit your real API. Mock only third-party services. |378| Mock everything for speed | You test a fiction. Frontend and backend may be incompatible. | Mock only external boundaries. |379| Never mock anything | Tests are slow, flaky, fail when third parties have outages. | Mock third-party services. |380| Use outdated mocks | Mock returns different shape than real API. | Run contract validation tests. Re-record HAR files regularly. |381| Mock with `page.evaluate()` to stub fetch | Fragile, doesn't survive navigation. | Use `page.route()` which intercepts at network layer. |382| Copy-paste mocks across files | One API change requires updating many files. | Centralize mocks in fixtures. |383| Block all network and whitelist | Extremely brittle. Every new endpoint requires update. | Allow all by default. Selectively mock third-party services. |384