SurfSense/.cursor/skills/playwright-testing/when-to-mock.md
2026-05-04 13:54:13 +05:30

26 KiB
Executable file

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, core/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

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

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

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

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

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

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

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

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

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

// 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

// 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

// 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

// 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

// 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',
});
// 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

// 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',
});
// 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

// 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<MockOptions>({
  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';
// 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

// 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 };
// 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

// 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

// 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

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

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.