🔥 0
0
Lesson 7 of 10 25 min +200 XP

Network Interception & Mocking

How do you test what happens when a payment fails? When the inventory API returns an error? When the network is slow? You can't wait for these conditions to occur naturally. Instead, you mock them.

Why Mock Network Requests?

ScenarioReal NetworkMocked
Payment failureNeed a card that always failsMock decline response
Out of stockWait for inventory depletionMock empty stock
Slow networkThrottle actual connectionAdd artificial delay
API error (500)Wait for server issuesMock error response
Third-party downWait for outageMock timeout

Mocking makes the untestable testable.

Basic Route Interception

page.route() Fundamentals

import { test, expect } from '@playwright/test';

test('intercept products API', async ({ page }) => {
  // Intercept requests matching the pattern
  await page.route('**/api/products', async (route) => {
    // You can:
    // 1. Fulfill with mock data
    // 2. Continue to real server
    // 3. Abort the request
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: '1', name: 'Mocked Product', price: 99.99 },
        { id: '2', name: 'Another Mock', price: 49.99 },
      ]),
    });
  });

  await page.goto('/products');

  // Page displays mocked data
  await expect(page.getByText('Mocked Product')).toBeVisible();
  await expect(page.getByText('$99.99')).toBeVisible();
});

Route Matching Patterns

// Exact URL
await page.route('https://api.shop.com/products', handler);

// Glob patterns
await page.route('**/api/products', handler);         // Any domain
await page.route('**/api/products/*', handler);       // With path segment
await page.route('**/api/products?**', handler);      // With query string

// Regex patterns
await page.route(/\/api\/products\/\d+/, handler);    // Product by ID
await page.route(/\/api\/(products|categories)/, handler);  // Multiple endpoints

// Function predicate
await page.route(
  (url) => url.pathname.startsWith('/api/') && url.searchParams.has('mock'),
  handler
);

Three Response Patterns

1. Fulfill - Return Mock Data

await page.route('**/api/cart', async (route) => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({
      items: [
        { productId: 'SKU-001', name: 'Test Product', quantity: 2, price: 29.99 },
      ],
      subtotal: 59.98,
      tax: 5.40,
      total: 65.38,
    }),
  });
});

2. Continue - Modify and Forward

await page.route('**/api/products/*', async (route) => {
  // Get actual response from server
  const response = await route.fetch();
  const json = await response.json();

  // Modify the response
  json.price = json.price * 0.9;  // 10% discount
  json.onSale = true;

  await route.fulfill({
    response,  // Use original response headers, status
    body: JSON.stringify(json),
  });
});

3. Abort - Simulate Failure

await page.route('**/api/recommendations', async (route) => {
  // Simulate network failure
  await route.abort('failed');
});

// Other abort reasons:
// 'aborted', 'accessdenied', 'addressunreachable', 'blockedbyclient',
// 'blockedbyresponse', 'connectionaborted', 'connectionclosed',
// 'connectionfailed', 'connectionrefused', 'connectionreset',
// 'internetdisconnected', 'namenotresolved', 'timedout', 'failed'

E-Commerce Mocking Scenarios

Mocking Payment Gateway

test.describe('Payment Scenarios', () => {
  test('successful payment', async ({ page }) => {
    await page.route('**/api/payments/process', async (route) => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({
          success: true,
          transactionId: 'TXN-12345',
          message: 'Payment approved',
        }),
      });
    });

    await page.goto('/checkout');
    // ... fill checkout form
    await page.getByRole('button', { name: 'Pay Now' }).click();

    await expect(page.getByText('Payment approved')).toBeVisible();
    await expect(page.getByText('TXN-12345')).toBeVisible();
  });

  test('declined card', async ({ page }) => {
    await page.route('**/api/payments/process', async (route) => {
      await route.fulfill({
        status: 400,
        contentType: 'application/json',
        body: JSON.stringify({
          success: false,
          error: 'card_declined',
          message: 'Your card was declined. Please try another payment method.',
        }),
      });
    });

    await page.goto('/checkout');
    // ... fill checkout form
    await page.getByRole('button', { name: 'Pay Now' }).click();

    await expect(page.getByRole('alert')).toContainText('card was declined');
    await expect(page.getByRole('button', { name: 'Pay Now' })).toBeEnabled();
  });

  test('insufficient funds', async ({ page }) => {
    await page.route('**/api/payments/process', async (route) => {
      await route.fulfill({
        status: 400,
        contentType: 'application/json',
        body: JSON.stringify({
          success: false,
          error: 'insufficient_funds',
          message: 'Insufficient funds. Available balance: $50.00',
        }),
      });
    });

    await page.goto('/checkout');
    await page.getByRole('button', { name: 'Pay Now' }).click();

    await expect(page.getByText('Insufficient funds')).toBeVisible();
  });

  test('payment timeout', async ({ page }) => {
    await page.route('**/api/payments/process', async (route) => {
      // Simulate slow response
      await new Promise(resolve => setTimeout(resolve, 35000));
      await route.fulfill({
        status: 504,
        body: 'Gateway Timeout',
      });
    });

    await page.goto('/checkout');
    await page.getByRole('button', { name: 'Pay Now' }).click();

    // App should show timeout message before actual timeout
    await expect(page.getByText(/taking longer than expected|try again/i)).toBeVisible({
      timeout: 40000,
    });
  });

  test('3D Secure redirect', async ({ page }) => {
    await page.route('**/api/payments/process', async (route) => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({
          success: false,
          requiresAction: true,
          actionType: '3ds_redirect',
          redirectUrl: 'https://bank.example/3ds-verify',
        }),
      });
    });

    await page.goto('/checkout');
    await page.getByRole('button', { name: 'Pay Now' }).click();

    // Verify redirect handling
    await expect(page.getByText('Verifying with your bank')).toBeVisible();
  });
});

Mocking Inventory Status

test.describe('Inventory Scenarios', () => {
  test('product in stock', async ({ page }) => {
    await page.route('**/api/products/SKU-001', async (route) => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({
          id: 'SKU-001',
          name: 'Wireless Headphones',
          price: 129.99,
          stock: 50,
          inStock: true,
        }),
      });
    });

    await page.goto('/products/SKU-001');

    await expect(page.getByText('In Stock')).toBeVisible();
    await expect(page.getByRole('button', { name: 'Add to Cart' })).toBeEnabled();
  });

  test('product out of stock', async ({ page }) => {
    await page.route('**/api/products/SKU-001', async (route) => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({
          id: 'SKU-001',
          name: 'Wireless Headphones',
          price: 129.99,
          stock: 0,
          inStock: false,
        }),
      });
    });

    await page.goto('/products/SKU-001');

    await expect(page.getByText('Out of Stock')).toBeVisible();
    await expect(page.getByRole('button', { name: 'Add to Cart' })).toBeDisabled();
    await expect(page.getByRole('button', { name: 'Notify Me' })).toBeVisible();
  });

  test('low stock warning', async ({ page }) => {
    await page.route('**/api/products/SKU-001', async (route) => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({
          id: 'SKU-001',
          name: 'Wireless Headphones',
          price: 129.99,
          stock: 3,
          inStock: true,
          lowStock: true,
        }),
      });
    });

    await page.goto('/products/SKU-001');

    await expect(page.getByText('Only 3 left!')).toBeVisible();
  });
});

Mocking Shipping Calculations

test('shipping options based on location', async ({ page }) => {
  await page.route('**/api/shipping/calculate', async (route) => {
    const request = route.request();
    const postData = JSON.parse(request.postData() || '{}');

    // Different responses based on request
    let options;
    if (postData.zipCode?.startsWith('96')) {
      // Hawaii - limited options
      options = [
        { id: 'express', name: 'Express (5-7 days)', price: 45.99 },
      ];
    } else {
      options = [
        { id: 'standard', name: 'Standard (5-7 days)', price: 5.99 },
        { id: 'express', name: 'Express (2-3 days)', price: 15.99 },
        { id: 'overnight', name: 'Overnight', price: 29.99 },
      ];
    }

    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ options }),
    });
  });

  await page.goto('/checkout');
  await page.getByLabel('ZIP Code').fill('94102');
  await page.getByLabel('ZIP Code').blur();

  await expect(page.getByText('Standard (5-7 days)')).toBeVisible();
  await expect(page.getByText('Express (2-3 days)')).toBeVisible();
});

Simulating Network Conditions

Slow Network

test('handles slow network gracefully', async ({ page }) => {
  await page.route('**/api/**', async (route) => {
    // Add 3 second delay to all API calls
    await new Promise(resolve => setTimeout(resolve, 3000));
    await route.continue();
  });

  await page.goto('/products');

  // Loading indicator should appear
  await expect(page.getByTestId('loading-spinner')).toBeVisible();

  // Eventually content loads
  await expect(page.getByTestId('product-card').first()).toBeVisible({
    timeout: 10000,
  });
});

Offline Mode

test('shows offline message when disconnected', async ({ page, context }) => {
  await page.goto('/products');
  await expect(page.getByTestId('product-card').first()).toBeVisible();

  // Go offline
  await context.setOffline(true);

  // Try to add to cart
  await page.getByRole('button', { name: 'Add to Cart' }).first().click();

  // Should show offline message
  await expect(page.getByText(/offline|no connection/i)).toBeVisible();

  // Go back online
  await context.setOffline(false);

  // Retry should work
  await page.getByRole('button', { name: 'Retry' }).click();
  await expect(page.getByTestId('cart-count')).toHaveText('1');
});

Intermittent Failures

test('retries failed requests', async ({ page }) => {
  let requestCount = 0;

  await page.route('**/api/cart/add', async (route) => {
    requestCount++;

    if (requestCount < 3) {
      // First two attempts fail
      await route.fulfill({
        status: 500,
        body: 'Internal Server Error',
      });
    } else {
      // Third attempt succeeds
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({ success: true, cartCount: 1 }),
      });
    }
  });

  await page.goto('/products/SKU-001');
  await page.getByRole('button', { name: 'Add to Cart' }).click();

  // App should retry and eventually succeed
  await expect(page.getByTestId('cart-count')).toHaveText('1');
  expect(requestCount).toBe(3);  // Verify retries happened
});

HAR File Recording and Playback

Recording Network Traffic

// Record HAR during test development
test('record checkout flow', async ({ page }) => {
  // Start recording
  await page.routeFromHAR('./hars/checkout.har', {
    update: true,  // Record mode
    updateContent: 'embed',  // Include response bodies
  });

  // Perform actions - all network traffic is recorded
  await page.goto('/products');
  await page.getByRole('button', { name: 'Add to Cart' }).first().click();
  await page.goto('/checkout');
  // ... complete checkout
});

Playback Recorded Traffic

test('checkout with recorded responses', async ({ page }) => {
  // Playback mode - no real network calls
  await page.routeFromHAR('./hars/checkout.har', {
    update: false,  // Playback mode
    notFound: 'abort',  // Fail if request not in HAR
  });

  await page.goto('/products');
  await page.getByRole('button', { name: 'Add to Cart' }).first().click();

  // All responses come from HAR file
  await expect(page.getByTestId('cart-count')).toHaveText('1');
});

HAR Best Practices

// playwright.config.ts
export default defineConfig({
  use: {
    // Record HAR in CI for debugging
    har: process.env.CI ? {
      path: './test-results/network.har',
      mode: 'minimal',  // Don't include response bodies
    } : undefined,
  },
});

Mocking Fixtures

Create reusable mock fixtures:

// fixtures/mocks.fixture.ts
import { test as base } from '@playwright/test';

type MockFixtures = {
  mockProducts: (products: Product[]) => Promise<void>;
  mockPayment: (result: 'success' | 'declined' | 'error') => Promise<void>;
  mockInventory: (productId: string, stock: number) => Promise<void>;
};

export const test = base.extend<MockFixtures>({
  mockProducts: async ({ page }, use) => {
    const mock = async (products: Product[]) => {
      await page.route('**/api/products', async (route) => {
        await route.fulfill({
          status: 200,
          contentType: 'application/json',
          body: JSON.stringify(products),
        });
      });
    };
    await use(mock);
  },

  mockPayment: async ({ page }, use) => {
    const mock = async (result: 'success' | 'declined' | 'error') => {
      await page.route('**/api/payments/process', async (route) => {
        const responses = {
          success: { status: 200, body: { success: true, transactionId: 'TXN-123' } },
          declined: { status: 400, body: { success: false, error: 'card_declined' } },
          error: { status: 500, body: { error: 'Internal error' } },
        };
        await route.fulfill({
          status: responses[result].status,
          contentType: 'application/json',
          body: JSON.stringify(responses[result].body),
        });
      });
    };
    await use(mock);
  },

  mockInventory: async ({ page }, use) => {
    const mock = async (productId: string, stock: number) => {
      await page.route(`**/api/products/${productId}`, async (route) => {
        await route.fulfill({
          status: 200,
          contentType: 'application/json',
          body: JSON.stringify({
            id: productId,
            stock,
            inStock: stock > 0,
          }),
        });
      });
    };
    await use(mock);
  },
});

export { expect } from '@playwright/test';
// tests/payment.spec.ts
import { test, expect } from '../fixtures/mocks.fixture';

test('handles declined payment', async ({ page, mockPayment }) => {
  await mockPayment('declined');

  await page.goto('/checkout');
  await page.getByRole('button', { name: 'Pay Now' }).click();

  await expect(page.getByRole('alert')).toBeVisible();
});

Key Takeaways

  • page.route() intercepts any network request
  • Three patterns: Fulfill (mock), Continue (modify), Abort (fail)
  • Test edge cases: Payment failures, inventory issues, network errors
  • HAR files record and replay real network traffic
  • Mock fixtures make common mocks reusable
  • Simulate conditions: Slow network, offline mode, intermittent failures

Next Lesson

You can mock any network scenario. But what about visual bugs? In the next lesson, we'll learn visual testing and mobile emulation to catch UI regressions and test responsive designs.

API Testing & Hybrid Workflows