Back to Sanity

Advanced Network Interception

.agents/skills/playwright-best-practices/advanced/network-advanced.md

5.20.010.6 KB
Original Source

Advanced Network Interception

Table of Contents

  1. Request Modification
  2. GraphQL Mocking
  3. HAR Recording & Playback
  4. Conditional Mocking
  5. Network Throttling

Request Modification

Modify Request Headers

typescript
test('add auth header to requests', async ({page}) => {
  await page.route('**/api/**', (route) => {
    const headers = {
      ...route.request().headers(),
      'Authorization': 'Bearer test-token',
      'X-Test-Header': 'test-value',
    }
    route.continue({headers})
  })

  await page.goto('/dashboard')
})

Modify Request Body

typescript
test('modify POST body', async ({page}) => {
  await page.route('**/api/orders', async (route) => {
    if (route.request().method() === 'POST') {
      const postData = route.request().postDataJSON()

      // Add test metadata
      const modifiedData = {
        ...postData,
        testMode: true,
        testTimestamp: Date.now(),
      }

      await route.continue({
        postData: JSON.stringify(modifiedData),
      })
    } else {
      await route.continue()
    }
  })

  await page.goto('/checkout')
  await page.getByRole('button', {name: 'Place Order'}).click()
})

Transform Response

typescript
test('modify API response', async ({page}) => {
  await page.route('**/api/products', async (route) => {
    // Fetch real response
    const response = await route.fetch()
    const json = await response.json()

    // Modify response
    const modified = json.map((product: any) => ({
      ...product,
      price: product.price * 0.9, // 10% discount
      testMode: true,
    }))

    await route.fulfill({
      response,
      json: modified,
    })
  })

  await page.goto('/products')
})

GraphQL Mocking

Mock by Operation Name

typescript
test('mock GraphQL query', async ({page}) => {
  await page.route('**/graphql', async (route) => {
    const postData = route.request().postDataJSON()

    if (postData.operationName === 'GetUser') {
      return route.fulfill({
        json: {
          data: {
            user: {
              id: '1',
              name: 'Test User',
              email: '[email protected]',
            },
          },
        },
      })
    }

    if (postData.operationName === 'GetProducts') {
      return route.fulfill({
        json: {
          data: {
            products: [
              {id: '1', name: 'Product A', price: 29.99},
              {id: '2', name: 'Product B', price: 49.99},
            ],
          },
        },
      })
    }

    // Pass through unmocked operations
    return route.continue()
  })

  await page.goto('/dashboard')
})

GraphQL Mock Fixture

typescript
// fixtures/graphql.fixture.ts
type GraphQLMock = {
  operation: string
  variables?: Record<string, any>
  response: {data?: any; errors?: any[]}
}

type GraphQLFixtures = {
  mockGraphQL: (mocks: GraphQLMock[]) => Promise<void>
}

export const test = base.extend<GraphQLFixtures>({
  mockGraphQL: async ({page}, use) => {
    await use(async (mocks) => {
      await page.route('**/graphql', async (route) => {
        const postData = route.request().postDataJSON()

        const mock = mocks.find((m) => {
          if (m.operation !== postData.operationName) return false

          // Optionally match variables
          if (m.variables) {
            return JSON.stringify(m.variables) === JSON.stringify(postData.variables)
          }
          return true
        })

        if (mock) {
          return route.fulfill({json: mock.response})
        }

        return route.continue()
      })
    })
  },
})

// Usage
test('dashboard with mocked GraphQL', async ({page, mockGraphQL}) => {
  await mockGraphQL([
    {
      operation: 'GetDashboardStats',
      response: {
        data: {stats: {users: 100, revenue: 50000}},
      },
    },
    {
      operation: 'GetUser',
      variables: {id: '1'},
      response: {
        data: {user: {id: '1', name: 'John'}},
      },
    },
  ])

  await page.goto('/dashboard')
  await expect(page.getByText('100 users')).toBeVisible()
})

Mock GraphQL Mutations

typescript
test('mock GraphQL mutation', async ({page}) => {
  await page.route('**/graphql', async (route) => {
    const postData = route.request().postDataJSON()

    if (postData.operationName === 'CreateOrder') {
      const {input} = postData.variables

      return route.fulfill({
        json: {
          data: {
            createOrder: {
              id: 'order-123',
              status: 'PENDING',
              items: input.items,
              total: input.items.reduce(
                (sum: number, item: any) => sum + item.price * item.quantity,
                0,
              ),
            },
          },
        },
      })
    }

    return route.continue()
  })

  await page.goto('/checkout')
  await page.getByRole('button', {name: 'Place Order'}).click()

  await expect(page.getByText('Order #order-123')).toBeVisible()
})

HAR Recording & Playback

Record HAR File

typescript
// Record network traffic
test('record HAR', async ({page, context}) => {
  // Start recording
  await context.routeFromHAR('./recordings/checkout.har', {
    update: true, // Create/update HAR file
    url: '**/api/**',
  })

  await page.goto('/checkout')
  await page.getByRole('button', {name: 'Place Order'}).click()

  // HAR file is saved automatically
})

Playback HAR File

typescript
// Use recorded HAR for offline testing
test('playback HAR', async ({page, context}) => {
  await context.routeFromHAR('./recordings/checkout.har', {
    url: '**/api/**',
    update: false, // Don't update, just playback
  })

  await page.goto('/checkout')

  // All API calls served from HAR file
  await expect(page.getByText('Order confirmed')).toBeVisible()
})

HAR with Fallback

typescript
test('HAR with live fallback', async ({page, context}) => {
  await context.routeFromHAR('./recordings/api.har', {
    url: '**/api/**',
    update: false,
    notFound: 'fallback', // Use real network if not in HAR
  })

  await page.goto('/dashboard')
})

Conditional Mocking

Mock Based on Request Body

typescript
test('conditional mock by body', async ({page}) => {
  await page.route('**/api/search', async (route) => {
    const body = route.request().postDataJSON()

    if (body.query === 'error') {
      return route.fulfill({
        status: 500,
        json: {error: 'Search failed'},
      })
    }

    if (body.query === 'empty') {
      return route.fulfill({
        json: {results: []},
      })
    }

    // Default response
    return route.fulfill({
      json: {
        results: [{id: 1, title: `Result for: ${body.query}`}],
      },
    })
  })

  await page.goto('/search')

  // Test different scenarios
  await page.getByLabel('Search').fill('error')
  await page.getByLabel('Search').press('Enter')
  await expect(page.getByText('Search failed')).toBeVisible()
})

Mock Nth Request

typescript
test('different response on retry', async ({page}) => {
  let callCount = 0

  await page.route('**/api/status', (route) => {
    callCount++

    if (callCount < 3) {
      return route.fulfill({
        status: 503,
        json: {error: 'Service unavailable'},
      })
    }

    // Succeed on 3rd attempt
    return route.fulfill({
      json: {status: 'ok'},
    })
  })

  await page.goto('/dashboard')

  // App should retry and eventually succeed
  await expect(page.getByText('Connected')).toBeVisible()
})

Mock with Delay

typescript
test('slow network simulation', async ({page}) => {
  await page.route('**/api/data', async (route) => {
    // Simulate 2 second delay
    await new Promise((resolve) => setTimeout(resolve, 2000))

    return route.fulfill({
      json: {data: 'loaded'},
    })
  })

  await page.goto('/dashboard')

  // Loading state should appear
  await expect(page.getByText('Loading...')).toBeVisible()

  // Then data appears
  await expect(page.getByText('loaded')).toBeVisible()
})

Network Throttling

Slow 3G Simulation

typescript
test('slow network experience', async ({page, context}) => {
  // Create CDP session for network throttling
  const client = await context.newCDPSession(page)

  await client.send('Network.emulateNetworkConditions', {
    offline: false,
    downloadThroughput: (500 * 1024) / 8, // 500 Kbps
    uploadThroughput: (500 * 1024) / 8,
    latency: 400, // 400ms
  })

  await page.goto('/')

  // Test loading states appear
  await expect(page.getByTestId('skeleton-loader')).toBeVisible()
})

Offline Mode

Use context.setOffline(true/false) to simulate network connectivity changes.

For comprehensive offline testing patterns:

  • Network failure simulation (error recovery, graceful degradation): See error-testing.md
  • Offline-first/PWA testing (service workers, caching, background sync): See service-workers.md

Network Throttling Fixture

typescript
// fixtures/network.fixture.ts
type NetworkCondition = 'slow3g' | 'fast3g' | 'offline'

const conditions = {
  slow3g: {downloadThroughput: 50000, uploadThroughput: 50000, latency: 2000},
  fast3g: {downloadThroughput: 180000, uploadThroughput: 75000, latency: 150},
}

type NetworkFixtures = {
  setNetworkCondition: (condition: NetworkCondition) => Promise<void>
}

export const test = base.extend<NetworkFixtures>({
  setNetworkCondition: async ({page, context}, use) => {
    const client = await context.newCDPSession(page)

    await use(async (condition) => {
      if (condition === 'offline') {
        await context.setOffline(true)
      } else {
        await client.send('Network.emulateNetworkConditions', {
          offline: false,
          ...conditions[condition],
        })
      }
    })

    // Reset
    await context.setOffline(false)
  },
})

Anti-Patterns to Avoid

Anti-PatternProblemSolution
Mocking all requestsTests don't reflect realityMock only what's necessary
No cleanup of routesRoutes persist across testsUse fixtures with cleanup
Ignoring request methodMock applies to wrong requestsCheck route.request().method()
Hardcoded mock responsesBrittle, hard to maintainUse factories for mock data