15 Days of Playwright - Day 4

Mocking network requests is a crucial aspect of testing applications and intercepting responses. In this lesson, I will demonstrate how to mock network requests in Playwright tests.

15 Days of Playwright - Day 4
Jhonatas Matos

Jhonatas Matos

API mocking is one of Playwright's most powerful features for creating reliable, fast, and deterministic tests. By controlling network responses, you can test edge cases, simulate errors, and eliminate external dependencies that make tests flaky.

Today, I will explore how to intercept and mock API requests using Playwright's network capabilities.

Why Mock APIs?

API mocking allows you to create controlled testing environments by:

  • Isolating tests from external dependencies
  • Simulating edge cases that are hard to reproduce
  • Speeding up tests by avoiding real network calls
  • Testing error handling without breaking real systems
  • Stabilizing CI environments by eliminating flakiness

How Playwright's Network Interception Works

When you use page.route(), Playwright acts as a man-in-the-middle:

  1. Your test registers a route handler
  2. Playwright intercepts matching requests
  3. Your handler decides to mock, modify, or continue the request
  4. The browser receives the controlled response

Understanding page.route()

The page.route() method is your gateway to network control. It takes two parameters:

  1. URL Pattern: A glob pattern to match requests (**/api/* matches any API endpoint)
  2. Handler Function: An async function that receives a Route object
  • Modify API responses
  • Simulate different HTTP status codes
  • Test error scenarios
  • Accelerate tests by avoiding real network calls

The Route Object Methods:

URL Pattern Examples:

  • **/api/users - Match specific endpoint exactly
  • **/api/*/detail - Match nested endpoints
  • **/*.json - Match all JSON files
  • **/api/** - Match anything under /api/ (use cautiously!)

Basic Syntax

await page.route('**/api/endpoint', async route => {
  // Handle the request here
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({ data: 'mocked' })
  });
});

In this example, we intercept requests to /api/endpoint and respond with a mocked JSON object.

Common Mocking Patterns

Basic Success Response

When to use: Testing happy paths and normal user flows

// Mock a successful login API call
await page.route('**/api/login', async route => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({
      success: true,
      user: { id: 1, name: 'John Doe', email: 'john@example.com' },
      token: 'fake-jwt-token'
    })
  });
});

In this example, we intercept requests to /api/login and respond with a mocked JSON object.

Error Scenarios

// Mock a server error
await page.route('**/api/transfer', async route => {
  await route.fulfill({
    status: 500,
    contentType: 'application/json',
    body: JSON.stringify({
      error: 'Internal server error',
      message: 'Please try again later'
    })
  });
});

// Mock authentication failure
await page.route('**/api/profile', async route => {
  await route.fulfill({
    status: 401,
    contentType: 'application/json',
    body: JSON.stringify({
      error: 'Unauthorized',
      message: 'Invalid credentials'
    })
  });
});

In these examples, we intercept requests to /api/transfer and /api/profile and respond with mocked error responses.

Dynamic Responses Based on Request

// Respond differently based on request data
await page.route('**/api/products/*', async route => {
  const request = route.request();
  const productId = request.url().split('/').pop();
  
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({
      id: productId,
      name: `Product ${productId}`,
      price: 99.99,
      inStock: true
    })
  });
});

In this example, we intercept requests to /api/products/ and respond with a dynamic product object based on the requested product ID.

Adding Artificial Delay

// Simulate slow network
await page.route('**/api/transactions', async route => {
  await new Promise(resolve => setTimeout(resolve, 2000)); // 2-second delay
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([])
  });
});

Real-World Scenarios

Scenario 1: Testing Loading States

test('should show loading indicator during API call', async ({ page }) => {
  // Mock delayed response
  await page.route('**/api/user-data', async route => {
    await new Promise(resolve => setTimeout(resolve, 1000));
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ name: 'John Doe' })
    });
  });

  await page.goto('/profile');
  
  // Verify loading indicator appears
  await expect(page.locator('.loading-spinner')).toBeVisible();
  
  // Verify data loads and spinner disappears
  await expect(page.locator('.user-name')).toHaveText('John Doe');
  await expect(page.locator('.loading-spinner')).toBeHidden();
});

Scenario 2: Testing Error Handling

test('should display error message on API failure', async ({ page }) => {
  // Mock API failure
  await page.route('**/api/checkout', async route => {
    await route.fulfill({
      status: 402,
      contentType: 'application/json',
      body: JSON.stringify({
        error: 'Payment required',
        message: 'Insufficient funds'
      })
    });
  });

  await page.goto('/checkout');
  await page.click('#place-order');
  
  // Verify error UI is displayed correctly
  await expect(page.locator('.error-message'))
    .toHaveText('Insufficient funds');
  await expect(page.locator('.retry-button')).toBeVisible();
});

Scenario 3: A/B Testing Simulation

test('should handle different feature flag responses', async ({ page }) => {
  // Mock different feature flag responses
  await page.route('**/api/feature-flags', async route => {
    const userType = Math.random() > 0.5 ? 'premium' : 'standard';
    
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        newDashboard: userType === 'premium',
        darkMode: true,
        exportEnabled: userType === 'premium'
      })
    });
  });

  await page.goto('/dashboard');
  
  // Test both UI variations
  const isPremium = await page.locator('.premium-feature').isVisible();
  
  if (isPremium) {
    await expect(page.locator('.export-button')).toBeVisible();
  } else {
    await expect(page.locator('.export-button')).toBeHidden();
  }
});

Advanced Techniques

Request Inspection

// Analyze requests before deciding how to handle them
await page.route('**/api/**', async route => {
  const request = route.request();
  
  console.log('Request URL:', request.url());
  console.log('Method:', request.method());
  console.log('Headers:', request.headers());
  console.log('Post Data:', await request.postData());
  
  // Continue with actual request
  await route.continue();
});

Conditional Mocking

// Only mock specific requests
await page.route('**/api/**', async route => {
  const request = route.request();
  
  if (request.url().includes('/api/transactions')) {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([{ id: 1, amount: 100 }])
    });
  } else {
    // Pass through other API calls
    await route.continue();
  }
});

Response Modification

// Modify actual API responses
await page.route('**/api/products', async route => {
  const response = await route.fetch();
  const json = await response.json();
  
  // Add additional data to response
  json.push({ id: 999, name: 'Injected Product', price: 0.01 });
  
  await route.fulfill({
    response,
    contentType: 'application/json',
    body: JSON.stringify(json)
  });
});

Request Inspection - The "Spy" Pattern

Sometimes you want to observe without interfering - perfect for:

  • Analytics testing
  • Request validation
  • Performance monitoring
// Analyze requests before deciding how to handle them
await page.route('**/api/**', async route => {
  const request = route.request();
  
  console.log('📡 Request intercepted:', request.method(), request.url());
  
  if (request.url().includes('/health')) {
    // Don't mock health checks
    await route.continue();
  } else {
    // Mock other API calls
    await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
  }
});

Practical BugBank Example

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

test.describe('BugBank API Mocking', () => {
  test('should complete transfer with mocked API', async ({ page }) => {
    // Mock balance check
    await page.route('**/api/balance', async route => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({ balance: 2000 })
      });
    });

    // Mock successful transfer
    await page.route('**/api/transfer', async route => {
      const request = route.request();
      const postData = JSON.parse(await request.postData());
      
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({
          success: true,
          transactionId: 'txn_12345',
          newBalance: 2000 - postData.amount,
          message: 'Transfer completed successfully'
        })
      });
    });

    // Test execution
    await page.goto('https://bugbank.netlify.app');
    
    // Verify initial balance
    await expect(page.locator('.balance-amount')).toHaveText('2000');
    
    // Perform transfer
    await page.fill('#recipient', 'user2@example.com');
    await page.fill('#amount', '500');
    await page.click('#transfer-button');
    
    // Verify success
    await expect(page.locator('.transaction-status'))
      .toHaveText('Transfer completed successfully');
    await expect(page.locator('.balance-amount')).toHaveText('1500');
  });

  test('should handle transfer failure', async ({ page }) => {
    // Mock transfer failure
    await page.route('**/api/transfer', async route => {
      await route.fulfill({
        status: 400,
        contentType: 'application/json',
        body: JSON.stringify({
          success: false,
          error: 'Insufficient funds',
          message: 'Your account balance is too low'
        })
      });
    });

    await page.goto('https://bugbank.netlify.app');
    await page.fill('#recipient', 'user2@example.com');
    await page.fill('#amount', '5000'); // Too much!
    await page.click('#transfer-button');
    
    // Verify error handling
    await expect(page.locator('.error-message'))
      .toHaveText('Your account balance is too low');
    await expect(page.locator('.retry-button')).toBeVisible();
  });
});

Best Practices

Be Specific with URL Patterns

// ✅ Good - specific endpoint
await page.route('**/api/users/123', handler);

// ❌ Avoid - too broad
await page.route('**/api/**', handler);

Clean Up Between Tests

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

test.afterEach(async () => {
  await page.unrouteAll(); // Remove all handlers
});

Use Realistic Response Data

// ✅ Use realistic data structure
await route.fulfill({
  body: JSON.stringify({
    id: 1,
    name: 'Real Product',
    price: 29.99,
    inventory: 42
  })
});

Test Both Success and Error Paths

describe('API Tests', () => {
  test('success case', () => { /* mock 200 response */ });
  test('error case', () => { /* mock 400/500 response */ });
  test('network failure', () => { /* mock timeout */ });
});

⚠️ Warning: Avoid over-mocking! Excessive mocking can:

  • Create false positives if reality differs from mocks
  • Miss integration issues between services
  • Require maintenance as APIs evolve

Rule of thumb: Mock external dependencies, test internal integrations.

Debugging Tips

// Enable request logging
await page.route('**/api/**', async route => {
  console.log(`➡️ ${route.request().method()} ${route.request().url()}`);
  await route.continue();
});

// Or use Playwright's debug logging
DEBUG=pw:api npx playwright test

I added an example in the repository, check in the link below:

Link to GitHub projectGitHub Octocat

References

If you want to read more about mocking check these complete guides:

API Mocking Best Practices
Network Mocking Best Practices

Conclusion

API mocking is not about avoiding the backend - it's about creating reliable test environments. The key benefits:

Speed & Reliability

  • Tests run 10-100x faster without network calls
  • No more flaky tests due to network issues
  • Consistent results across all environments

Comprehensive Testing

  • Test all edge cases: errors, timeouts, empty responses
  • Simulate real-world scenarios: slow networks, server outages
  • Validate error handling and user experience

Maintenance Benefits

  • Parallel development: frontend and backend can work independently
  • Documentation: mocks serve as API contract examples
  • Debugging: isolate frontend vs backend issues

Remember: Mock strategically, not excessively. Use real APIs for critical paths and mocking for edge cases and performance.


Thank you for reading and see you in the next lesson! ☕💜