Playwright 第10章:网络拦截与 Mock

网络拦截是 Playwright 的强大功能之一。通过 page.route() 可以拦截、修改和模拟网络请求,实现 Mock API 响应、修改请求头、拦截资源加载等功能,使测试更加可控和高效。

page.route() 基础

请求拦截

import { chromium } from 'playwright';

const browser = await chromium.launch();
const page = await browser.newPage();

// 拦截所有请求
await page.route('**/*', (route) => {
  console.log('请求:', route.request().url());
  route.continue(); // 继续请求
});

// 拦截特定 URL 模式
await page.route('**/api/**', (route) => {
  console.log('API 请求:', route.request().url());
  route.continue();
});

await page.goto('https://example.com');
await browser.close();

路由拦截匹配规则

const page = await browser.newPage();

// 字符串匹配
await page.route('https://example.com/api/data', handler);

// 通配符匹配
await page.route('**/api/**', handler);
await page.route('*/api/users', handler);
await page.route('https://example.com/**', handler);

// 正则匹配
await page.route(/api\/users\/\d+/, handler);
await page.route(/\.json$/, handler);

// 函数匹配
await page.route((url) => {
  return url.pathname.startsWith('/api') && url.searchParams.has('token');
}, handler);

// 同时拦截多个域名
await page.route(/example\.com|test\.com/, handler);

Mock API 响应

返回模拟数据

const page = await browser.newPage();

// Mock GET 请求
await page.route('**/api/users', async (route) => {
  const json = {
    users: [
      { id: 1, name: '张三', email: '[email protected]' },
      { id: 2, name: '李四', email: '[email protected]' },
      { id: 3, name: '王五', email: '[email protected]' },
    ],
    total: 3,
  };
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    headers: { 'X-Custom': 'mock' },
    body: JSON.stringify(json),
  });
});

// Mock POST 请求
await page.route('**/api/login', async (route) => {
  const request = route.request();
  const postData = request.postDataJSON();

  if (postData.username === 'admin' && postData.password === 'pass123') {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        token: 'mock-token-abc123',
        user: { id: 1, name: 'Admin' },
      }),
    });
  } else {
    await route.fulfill({
      status: 401,
      contentType: 'application/json',
      body: JSON.stringify({
        error: '用户名或密码错误',
      }),
    });
  }
});

await page.goto('https://example.com');

返回错误状态

const page = await browser.newPage();

// Mock 500 错误
await page.route('**/api/data', async (route) => {
  await route.fulfill({
    status: 500,
    contentType: 'application/json',
    body: JSON.stringify({
      error: '服务器内部错误',
      code: 'INTERNAL_ERROR',
    }),
  });
});

// Mock 超时
await page.route('**/api/slow', async (route) => {
  await new Promise(resolve => setTimeout(resolve, 30000));
  await route.fulfill({ status: 504 });
});

// Mock 404
await page.route('**/api/notfound', async (route) => {
  await route.fulfill({
    status: 404,
    contentType: 'application/json',
    body: JSON.stringify({ error: '资源不存在' }),
  });
});

// 模拟网络错误
await page.route('**/api/fail', (route) => {
  route.abort('connectionrefused');
});

修改请求

修改请求头和参数

const page = await browser.newPage();

// 修改请求头
await page.route('**/api/**', async (route) => {
  const headers = {
    ...route.request().headers(),
    'Authorization': 'Bearer mock-token',
    'X-Testing': 'true',
  };
  await route.continue({ headers });
});

// 修改请求体
await page.route('**/api/submit', async (route) => {
  const postData = route.request().postDataJSON();
  // 修改请求数据
  postData.timestamp = Date.now();
  postData.source = 'test';

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

// 修改 URL
await page.route('**/api/data', async (route) => {
  const url = new URL(route.request().url());
  url.searchParams.set('mock', 'true');
  url.searchParams.set('lang', 'zh-CN');

  await route.continue({
    url: url.toString(),
  });
});

资源拦截

拦截图片/样式/脚本

const page = await browser.newPage();

// 拦截所有图片以加速测试
await page.route('**/*.{png,jpg,jpeg,gif,svg,webp}', (route) => {
  route.abort();
});

// 拦截 Google Analytics
await page.route('**/analytics.js', (route) => {
  route.abort();
});

// 拦截字体文件
await page.route('**/*.{woff,woff2,ttf,eot}', (route) => {
  route.abort();
});

// 拦截特定 CDN 的样式
await page.route('**/cdn.example.com/**', (route) => {
  route.abort();
});

// 有条件地拦截
await page.route('**/*.css', (route) => {
  const url = route.request().url();
  if (url.includes('bootstrap')) {
    route.abort(); // 不加载 Bootstrap
  } else {
    route.continue();
  }
});

修改响应内容

const page = await browser.newPage();

// 修改 HTML 响应
await page.route('**/index.html', async (route) => {
  const response = await route.fetch();
  let body = await response.text();

  // 替换页面内容
  body = body.replace(
    '<title>Example</title>',
    '<title>测试页面</title>'
  );
  body = body.replace(
    '</body>',
    '<div class="test-banner">测试环境</div></body>'
  );

  await route.fulfill({
    response,
    body,
  });
});

// 修改 JSON 响应
await page.route('**/api/config', async (route) => {
  const response = await route.fetch();
  const json = await response.json();

  // 修改配置
  json.features.newFeature = true;
  json.debug = true;
  json.timeout = 0; // 禁用超时

  await route.fulfill({
    response,
    body: JSON.stringify(json),
  });
});

离线测试

模拟离线状态

const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();

// 模拟离线
await context.setOffline(true);

// 测试离线页面
await page.goto('https://example.com').catch(() => {});
await expect(page.locator('.offline-message')).toBeVisible();

// 恢复在线
await context.setOffline(false);
await page.reload();
await expect(page.locator('.online-content')).toBeVisible();

网络请求验证

监听和断言网络请求

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

test('验证网络请求', async ({ page }) => {
  // 监听特定 API
  const apiPromise = page.waitForResponse(
    response => response.url().includes('/api/data') && response.status() === 200
  );

  await page.goto('/');
  await page.click('.load-data');

  // 等待并验证响应
  const response = await apiPromise;
  const data = await response.json();
  expect(data.success).toBe(true);
  expect(data.items).toHaveLength(10);
});

test('验证请求参数', async ({ page }) => {
  // 捕获请求
  let capturedRequest: Request | null = null;

  page.on('request', (request) => {
    if (request.url().includes('/api/search')) {
      capturedRequest = request;
    }
  });

  await page.goto('/');
  await page.fill('#search', 'Playwright');
  await page.click('.search-btn');

  // 验证请求参数
  expect(capturedRequest).toBeTruthy();
  const postData = capturedRequest!.postDataJSON();
  expect(postData.query).toBe('Playwright');
  expect(postData.page).toBe(1);
});

完整示例:Mock 完整的 CRUD API

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

test.describe('用户管理 CRUD 测试', () => {
  let mockUsers = [
    { id: 1, name: '张三', email: '[email protected]' },
    { id: 2, name: '李四', email: '[email protected]' },
  ];

  test.beforeEach(async ({ page }) => {
    // Mock 用户列表接口
    await page.route('**/api/users', async (route) => {
      if (route.request().method() === 'GET') {
        await route.fulfill({
          status: 200,
          contentType: 'application/json',
          body: JSON.stringify(mockUsers),
        });
      } else if (route.request().method() === 'POST') {
        const data = route.request().postDataJSON();
        const newUser = { id: mockUsers.length + 1, ...data };
        mockUsers.push(newUser);
        await route.fulfill({
          status: 201,
          body: JSON.stringify(newUser),
        });
      }
    });

    await page.goto('/users');
  });

  test('显示用户列表', async ({ page }) => {
    await expect(page.locator('.user-row')).toHaveCount(2);
  });

  test('创建新用户', async ({ page }) => {
    await page.click('.add-user');
    await page.fill('#name', '王五');
    await page.fill('#email', '[email protected]');
    await page.click('.save-btn');
    await expect(page.locator('.user-row')).toHaveCount(3);
  });
});

总结

网络拦截是 Playwright 测试中极其强大的功能。通过 page.route() 可以模拟各种 API 响应、网络状态和错误场景,使测试不依赖后端服务即可运行。资源拦截可以加速测试运行,请求修改可以模拟认证和特殊参数。在实际项目中,建议将 Mock 数据封装为独立模块,方便在不同测试间复用。