Playwright API 测试:网络请求拦截与模拟的方法

你可能已经熟悉用Playwright做端到端的UI测试,但它的能力远不止于此。在实际项目中,前后端分离的架构让我们不得不面对一个现实:UI测试虽然直观,但往往脆弱且执行缓慢。而直接测试API,特别是能够控制网络请求的流向,才是提升测试效率的关键。

想象这些场景:前端页面依赖的后端接口尚未开发完成;第三方服务有调用频率限制或产生费用;某些边缘情况在生产环境中难以触发。在这些情况下,拦截和模拟网络请求就成了我们测试工具箱中的利器。

第一部分:理解Playwright的网络层

1.1 不只是浏览器自动化工具

很多人误以为Playwright只能操作浏览器,实际上它提供了完整的网络请求控制能力。每个浏览器上下文(browser context)都有自己的网络栈,这意味着你可以:

  • 监听所有进出请求

  • 修改请求参数和头信息

  • 拦截请求并返回自定义响应

  • 模拟网络条件和延迟

1.2 核心概念:Route与Fetch API

Playwright通过两个主要机制处理网络请求:

路由(Route)机制 :在请求到达服务器之前拦截并处理Fetch API:直接从测试代码发起HTTP请求,不经过浏览器界面

复制代码
// 路由拦截的基本模式
await page.route('**/api/users/*', async route => {
  // 在这里决定如何处理这个请求
  // 可以继续、中止或提供模拟响应
});

第二部分:实战拦截技术

2.1 基础拦截:修改请求与响应

让我们从一个实际例子开始。假设我们正在测试一个用户管理系统,需要验证前端是否正确处理API返回的数据。

复制代码
import { test, expect } from'@playwright/test';

test('拦截用户列表API并验证数据处理', async ({ page }) => {
// 监听特定的API端点
await page.route('**/api/users?page=1', async route => {
    // 获取原始请求信息
    const request = route.request();
    console.log(`拦截到请求: ${request.method()} ${request.url()}`);
    
    // 检查请求头
    const authHeader = request.headerValue('Authorization');
    expect(authHeader).toContain('Bearer');
    
    // 提供模拟响应
    const mockResponse = {
      data: [
        { id: 1, name: '张三', email: 'zhangsan@example.com', status: 'active' },
        { id: 2, name: '李四', email: 'lisi@example.com', status: 'inactive' },
        { id: 3, name: '王五', email: 'wangwu@example.com', status: 'active' }
      ],
      total: 3,
      page: 1,
      per_page: 20
    };
    
    // 返回模拟响应
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify(mockResponse)
    });
  });

// 导航到页面,触发API调用
await page.goto('/user-management');

// 验证前端是否正确显示模拟数据
await expect(page.locator('.user-list-item')).toHaveCount(3);
await expect(page.locator('text=李四')).toBeVisible();
await expect(page.locator('text=inactive')).toHaveClass(/status-inactive/);
});

2.2 条件拦截:基于请求内容的动态处理

不是所有请求都需要拦截,也不是所有拦截都需要相同的处理逻辑。我们可以根据请求内容决定如何响应。

复制代码
test('根据请求体内容动态拦截登录请求', async ({ page }) => {
await page.route('**/api/auth/login', async route => {
    const request = route.request();
    const postData = request.postData();
    
    if (!postData) {
      // 没有POST数据,继续原始请求
      return route.continue();
    }
    
    const credentials = JSON.parse(postData);
    
    // 根据不同测试用例模拟不同响应
    if (credentials.username === 'locked_user') {
      // 模拟账户被锁定
      await route.fulfill({
        status: 423,
        body: JSON.stringify({ 
          error: '账户已被锁定,请联系管理员'
        })
      });
    } elseif (credentials.username === 'expired_password') {
      // 模拟密码过期
      await route.fulfill({
        status: 200,
        body: JSON.stringify({ 
          token: 'temp_token',
          requires_password_change: true
        })
      });
    } else {
      // 其他情况继续原始请求
      await route.continue();
    }
  });

// 测试不同登录场景
await page.goto('/login');

// 测试锁定账户场景
await page.fill('#username', 'locked_user');
await page.fill('#password', 'anypassword');
await page.click('button[type="submit"]');
await expect(page.locator('.error-message'))
    .toContainText('账户已被锁定');

// 测试密码过期场景
await page.fill('#username', 'expired_password');
await page.fill('#password', 'oldpassword');
await page.click('button[type="submit"]');
await expect(page.locator('.password-change-prompt'))
    .toBeVisible();
});

第三部分:高级模拟技巧

3.1 创建可重用的模拟工具

在实际项目中,我们需要创建可维护的模拟工具。下面是一个实用的模拟工厂实现:

复制代码
// utils/api-mocks.ts
exportclass ApiMockBuilder {
private routes: Array<{
    urlPattern: string | RegExp;
    handler: Function;
  }> = [];

// 注册模拟规则
  register(urlPattern: string | RegExp, handler: Function) {
    this.routes.push({ urlPattern, handler });
    returnthis;
  }

// 应用到页面
async applyToPage(page) {
    for (const route of this.routes) {
      await page.route(route.urlPattern, async routeInstance => {
        await route.handler(routeInstance);
      });
    }
  }

// 常用模拟的快捷方法
static createUserMocks() {
    returnnew ApiMockBuilder()
      .register('**/api/users', async route => {
        const request = route.request();
        
        if (request.method() === 'GET') {
          // 模拟获取用户列表
          await route.fulfill({
            status: 200,
            contentType: 'application/json',
            body: JSON.stringify({
              data: [
                { id: 1, name: '测试用户1', role: 'admin' },
                { id: 2, name: '测试用户2', role: 'user' }
              ],
              total: 2
            })
          });
        } elseif (request.method() === 'POST') {
          // 模拟创建用户
          await route.fulfill({
            status: 201,
            headers: { 'Location': '/api/users/999' },
            body: JSON.stringify({
              id: 999,
              name: '新创建的用户',
              role: 'user'
            })
          });
        }
      })
      .register('**/api/users/*', async route => {
        const request = route.request();
        const userId = request.url().match(/\/(\d+)$/)?.[1];
        
        if (request.method() === 'DELETE') {
          // 模拟删除用户
          await route.fulfill({
            status: 204
          });
        }
      });
  }
}

// 在测试中使用
test('使用模拟构建器测试用户管理', async ({ page }) => {
const mocks = ApiMockBuilder.createUserMocks();
await mocks.applyToPage(page);

await page.goto('/users');
// ... 测试逻辑
});

3.2 部分模拟:混合真实与模拟数据

有时候我们不需要完全模拟API,只需要修改部分响应或添加额外数据。

复制代码
test('部分修改真实API响应', async ({ page }) => {
await page.route('**/api/products/*', async route => {
    // 先获取真实响应
    const response = await route.fetch();
    const originalBody = await response.json();
    
    // 修改特定字段用于测试
    if (originalBody.price > 100) {
      originalBody.discount_applied = true;
      originalBody.final_price = originalBody.price * 0.8;
      
      // 添加测试专用标记
      originalBody._test_note = '已应用测试折扣';
    }
    
    // 返回修改后的响应
    await route.fulfill({
      response,
      body: JSON.stringify(originalBody)
    });
  });

await page.goto('/product/123');

// 验证修改后的数据在前端的表现
const finalPrice = await page.locator('.final-price').textContent();
  expect(parseFloat(finalPrice)).toBeLessThan(100);
});

3.3 延迟和超时模拟

测试加载状态和超时处理是UI测试的重要部分。

复制代码
test('模拟API延迟和超时场景', async ({ page }) => {
// 模拟慢速响应
await page.route('**/api/analytics/report', async route => {
    // 延迟3秒响应,测试加载状态
    await page.waitForTimeout(3000);
    
    await route.fulfill({
      status: 200,
      body: JSON.stringify({ data: [/* 大量数据 */] })
    });
  });

// 模拟超时
await page.route('**/api/external-service/*', async route => {
    // 模拟永远不会响应的请求
    // 在实际测试中,我们可能设置一个超时后放弃
    // 这里我们直接中止请求,模拟超时
    await route.abort('timedout');
  });

await page.goto('/dashboard');

// 验证加载状态
await expect(page.locator('.loading-spinner')).toBeVisible();
await expect(page.locator('.loading-spinner')).toBeHidden({ timeout: 5000 });

// 验证超时错误处理
await page.click('#fetch-external-data');
await expect(page.locator('.error-toast'))
    .toContainText('请求超时');
});

第四部分:集成测试策略

4.1 API与UI测试的完美结合

真正的力量在于将API模拟与UI操作结合起来,创建既可靠又快速的集成测试。

复制代码
test('完整的用户注册流程测试', async ({ page, request }) => {
// 模拟验证码API
let capturedEmail = '';

await page.route('**/api/send-verification', async route => {
    const requestData = JSON.parse(route.request().postData() || '{}');
    capturedEmail = requestData.email;
    
    // 在实际项目中,这里可以存储验证码供后续使用
    const testVerificationCode = '123456';
    global.testVerificationCode = testVerificationCode;
    
    await route.fulfill({
      status: 200,
      body: JSON.stringify({ success: true })
    });
  });

// 模拟注册API
await page.route('**/api/register', async route => {
    const requestData = JSON.parse(route.request().postData() || '{}');
    
    // 验证业务逻辑
    if (requestData.verification_code !== global.testVerificationCode) {
      await route.fulfill({
        status: 400,
        body: JSON.stringify({ error: '验证码错误' })
      });
      return;
    }
    
    // 模拟成功注册
    await route.fulfill({
      status: 201,
      body: JSON.stringify({
        user_id: 1001,
        email: capturedEmail,
        access_token: 'mock_jwt_token_here'
      })
    });
  });

// 使用Playwright的Request API直接测试后端逻辑
// 这不是模拟,而是真实的API调用
const response = await request.post('/api/send-verification', {
    data: { email: 'test@example.com' }
  });
  expect(response.ok()).toBeTruthy();

// 进行UI测试
await page.goto('/register');
await page.fill('#email', 'test@example.com');
await page.click('#send-code');

// 验证UI状态
await expect(page.locator('.verification-code-input')).toBeVisible();

// 填写验证码并注册
await page.fill('#verification-code', global.testVerificationCode);
await page.fill('#password', 'SecurePass123!');
await page.click('#register-button');

// 验证注册成功
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('.welcome-message'))
    .toContainText('test@example.com');
});

4.2 测试文件上传和下载

文件处理是API测试中的常见需求。

复制代码
test('模拟文件上传和下载API', async ({ page }) => {
// 模拟文件上传
await page.route('**/api/upload', async route => {
    const request = route.request();
    
    // 验证上传的文件信息
    const postData = request.postDataBuffer();
    // 这里可以验证文件内容
    
    await route.fulfill({
      status: 200,
      body: JSON.stringify({
        file_id: 'mock_file_123',
        url: 'https://mock-cdn.example.com/files/mock.pdf',
        size: 1024 * 1024// 1MB
      })
    });
  });

// 模拟文件下载
await page.route('**/api/download/*', async route => {
    // 创建模拟的PDF文件
    const mockPdfBuffer = Buffer.from('%PDF-1.4 mock pdf content...');
    
    await route.fulfill({
      status: 200,
      contentType: 'application/pdf',
      headers: {
        'Content-Disposition': 'attachment; filename="document.pdf"'
      },
      body: mockPdfBuffer
    });
  });

await page.goto('/documents');

// 测试文件上传
const filePath = 'test-data/sample.pdf';
await page.setInputFiles('input[type="file"]', filePath);
await expect(page.locator('.upload-success')).toBeVisible();

// 测试文件下载
const downloadPromise = page.waitForEvent('download');
await page.click('text=下载文档');
const download = await downloadPromise;

// 验证下载的文件
  expect(download.suggestedFilename()).toBe('document.pdf');
});

第五部分:最佳实践与调试技巧

5.1 组织模拟代码的建议

  1. 按功能模块组织模拟:将相关的API模拟放在一起

  2. 创建模拟数据的工厂函数:避免硬编码测试数据

  3. 使用环境变量控制模拟行为:便于在不同环境切换

    // mocks/auth-mocks.ts
    exportconst createAuthMocks = (options = {}) => {
    const defaults = {
    enable2FA: false,
    accountLocked: false,
    passwordExpired: false
    };

    const config = { ...defaults, ...options };

    returnasync (page) => {
    await page.route('**/api/auth/login', async route => {
    // 根据配置返回不同的模拟响应
    if (config.accountLocked) {
    return route.fulfill({ status: 423, /* ... */ });
    }
    // ... 其他条件
    });
    };
    };

    // 在测试中使用
    test('测试双重认证流程', async ({ page }) => {
    const authMocks = createAuthMocks({ enable2FA: true });
    await authMocks(page);
    // ... 测试逻辑
    });

5.2 调试网络请求

当模拟不按预期工作时,需要有效的调试手段:

复制代码
test('调试API请求与响应', async ({ page }) => {
// 监听所有网络请求,记录到控制台
  page.on('request', request => {
    console.log(`>> ${request.method()} ${request.url()}`);
  });

  page.on('response', response => {
    console.log(`<< ${response.status()} ${response.url()}`);
    
    // 对于特定API,记录响应体(小心处理大响应)
    if (response.url().includes('/api/')) {
      response.text().then(text => {
        try {
          const json = JSON.parse(text);
          console.log('响应体:', JSON.stringify(json, null, 2).substring(0, 500));
        } catch {
          console.log('响应体:', text.substring(0, 500));
        }
      });
    }
  });

// 或者使用Playwright的调试工具
await page.route('**/*', async route => {
    const request = route.request();
    
    console.log('请求头:', request.headers());
    console.log('请求方法:', request.method());
    
    if (request.postData()) {
      console.log('POST数据:', request.postData());
    }
    
    // 继续原始请求
    await route.continue();
  });

await page.goto('/your-page');
});

选择合适的测试策略

拦截和模拟网络请求是强大的技术,但就像所有工具一样,需要明智地使用。以下是一些指导原则:

  1. 优先测试真实API:模拟是为了解决特定问题,不是替代真实的集成测试

  2. 保持模拟简单:过度复杂的模拟可能掩盖真实问题

  3. 定期验证模拟:确保模拟行为与真实API保持一致

  4. 与团队共享模拟:创建团队共享的模拟库,保持一致性

记住,最好的测试策略是分层的:单元测试确保组件逻辑正确,API模拟测试确保前端处理逻辑正确,而完整的端到端测试确保整个系统协同工作。

通过掌握Playwright的网络拦截和模拟能力,你不仅能够创建更快速、更可靠的测试,还能在前后端并行开发时保持高效。这才是现代Web应用测试应有的样子------智能、灵活且强大。

相关推荐
HBYKKJ1 天前
格雷希尔:G15F-KFYK-FD39 定制款快速密封连接器,适配自动化产线,赋能电驱动通讯接口的自动化密封测试
自动化·集成测试·气密性测试·新能源汽车·格雷希尔·快速密封连接器·电驱动测试
阿蔹1 天前
泰和昌商城接口自动化项目框架介绍
运维·自动化
b***25111 天前
圆柱锂电池双面点焊机:新能源制造的核心工艺装备
人工智能·自动化
Blossom.1181 天前
强化学习推荐系统实战:从DQN到PPO的演进与落地
人工智能·python·深度学习·算法·机器学习·chatgpt·自动化
cute_ming1 天前
浅谈提示词工程:企业级系统化实践与自动化架构(三)
人工智能·ubuntu·机器学习·架构·自动化
仙仙学姐测评1 天前
开题报告PPT自动化生成工具研究
运维·自动化·powerpoint
北京耐用通信1 天前
工业通信中的“工业战狼”!耐达讯自动化CAN转PROFIBUS网关
网络·人工智能·物联网·网络协议·自动化·信息与通信
可爱分享1 天前
锂电设备用哪种直线模组更稳?威洛博在极片、卷绕、模切工站的应用
自动化·线性回归·直线模组·机器人末端执行器·线性导轨
Blossom.1181 天前
知识图谱增强大模型:构建可解释的行业智能搜索引擎
运维·人工智能·python·智能手机·自动化·prompt·知识图谱