使用Playwright进行API测试:拦截与模拟网络请求
你可能已经熟悉用Playwright做端到端的UI测试,但它的能力远不止于此。在实际项目中,前后端分离的架构让我们不得不面对一个现实:UI测试虽然直观,但往往脆弱且执行缓慢。而直接测试API,特别是能够控制网络请求的流向,才是提升测试效率的关键。
想象这些场景:前端页面依赖的后端接口尚未开发完成;第三方服务有调用频率限制或产生费用;某些边缘情况在生产环境中难以触发。在这些情况下,拦截和模拟网络请求就成了我们测试工具箱中的利器。
第一部分:理解Playwright的网络层
1.1 不只是浏览器自动化工具
很多人误以为Playwright只能操作浏览器,实际上它提供了完整的网络请求控制能力。每个浏览器上下文(browser context)都有自己的网络栈,这意味着你可以:
- 监听所有进出请求
- 修改请求参数和头信息
- 拦截请求并返回自定义响应
- 模拟网络条件和延迟
1.2 核心概念:Route与Fetch API
Playwright通过两个主要机制处理网络请求:
路由(Route)机制:在请求到达服务器之前拦截并处理Fetch API:直接从测试代码发起HTTP请求,不经过浏览器界面
<pre data-tool="mdnice编辑器" style="-webkit-tap-highlight-color: rgba(0, 0, 0, 0); margin: 10px 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; border-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px; text-align: left; visibility: visible;">// 路由拦截的基本模式 await page.route('**/api/users/*', async route => { // 在这里决定如何处理这个请求 // 可以继续、中止或提供模拟响应 }); </pre>
第二部分:实战拦截技术
2.1 基础拦截:修改请求与响应
让我们从一个实际例子开始。假设我们正在测试一个用户管理系统,需要验证前端是否正确处理API返回的数据。
<pre data-tool="mdnice编辑器" style="-webkit-tap-highlight-color: rgba(0, 0, 0, 0); margin: 10px 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; border-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px; text-align: left;">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.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/); }); </pre>
2.2 条件拦截:基于请求内容的动态处理
不是所有请求都需要拦截,也不是所有拦截都需要相同的处理逻辑。我们可以根据请求内容决定如何响应。
<pre data-tool="mdnice编辑器" style="-webkit-tap-highlight-color: rgba(0, 0, 0, 0); margin: 10px 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; border-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px; text-align: left;">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](javascript:;)', 'locked_user'); await page.fill('[#password](javascript:;)', 'anypassword'); await page.click('button[type="submit"]'); await expect(page.locator('.error-message')) .toContainText('账户已被锁定'); // 测试密码过期场景 await page.fill('[#username](javascript:;)', 'expired_password'); await page.fill('[#password](javascript:;)', 'oldpassword'); await page.click('button[type="submit"]'); await expect(page.locator('.password-change-prompt')) .toBeVisible(); });</pre>
Playwright mcp技术学习交流群
伙伴们,对AI测试、大模型评测、质量保障感兴趣吗?我们建了一个 「Playwright mcp技术学习交流群」,专门用来探讨相关技术、分享资料、互通有无。无论你是正在实践还是好奇探索,都欢迎扫码加入,一起抱团成长!期待与你交流!👇
[图片上传失败...(image-ab4a5d-1767863169876)]
第三部分:高级模拟技巧
3.1 创建可重用的模拟工具
在实际项目中,我们需要创建可维护的模拟工具。下面是一个实用的模拟工厂实现:
<pre data-tool="mdnice编辑器" style="-webkit-tap-highlight-color: rgba(0, 0, 0, 0); margin: 10px 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; border-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px; text-align: left;">// 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'); // ... 测试逻辑 }); </pre>
3.2 部分模拟:混合真实与模拟数据
有时候我们不需要完全模拟API,只需要修改部分响应或添加额外数据。
<pre data-tool="mdnice编辑器" style="-webkit-tap-highlight-color: rgba(0, 0, 0, 0); margin: 10px 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; border-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px; text-align: left;">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); }); </pre>
3.3 延迟和超时模拟
测试加载状态和超时处理是UI测试的重要部分。
<pre data-tool="mdnice编辑器" style="-webkit-tap-highlight-color: rgba(0, 0, 0, 0); margin: 10px 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; border-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px; text-align: left;">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](javascript:;)-external-data'); await expect(page.locator('.error-toast')) .toContainText('请求超时'); }); </pre>
第四部分:集成测试策略
4.1 API与UI测试的完美结合
真正的力量在于将API模拟与UI操作结合起来,创建既可靠又快速的集成测试。
<pre data-tool="mdnice编辑器" style="-webkit-tap-highlight-color: rgba(0, 0, 0, 0); margin: 10px 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; border-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px; text-align: left;">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](javascript:;)', 'test@example.com'); await page.click('[#send](javascript:;)-code'); // 验证UI状态 await expect(page.locator('.verification-code-input')).toBeVisible(); // 填写验证码并注册 await page.fill('[#verification](javascript:;)-code', global.testVerificationCode); await page.fill('[#password](javascript:;)', 'SecurePass123!'); await page.click('[#register](javascript:;)-button'); // 验证注册成功 await expect(page).toHaveURL('/dashboard'); await expect(page.locator('.welcome-message')) .toContainText('test@example.com'); }); </pre>
4.2 测试文件上传和下载
文件处理是API测试中的常见需求。
<pre data-tool="mdnice编辑器" style="-webkit-tap-highlight-color: rgba(0, 0, 0, 0); margin: 10px 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; border-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px; text-align: left;">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'); }); </pre>
第五部分:最佳实践与调试技巧
5.1 组织模拟代码的建议
- 按功能模块组织模拟:将相关的API模拟放在一起
- 创建模拟数据的工厂函数:避免硬编码测试数据
- 使用环境变量控制模拟行为:便于在不同环境切换
<pre data-tool="mdnice编辑器" style="-webkit-tap-highlight-color: rgba(0, 0, 0, 0); margin: 10px 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; border-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px; text-align: left;">// 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); // ... 测试逻辑 }); </pre>
5.2 调试网络请求
当模拟不按预期工作时,需要有效的调试手段:
<pre data-tool="mdnice编辑器" style="-webkit-tap-highlight-color: rgba(0, 0, 0, 0); margin: 10px 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; border-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px; text-align: left;">test('调试API请求与响应', async ({ page }) => { // 监听所有网络请求,记录到控制台 page.on('request', request => { console.log(>> {request.url()}
); }); page.on('response', response => { console.log(<< {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'); }); </pre>
选择合适的测试策略
拦截和模拟网络请求是强大的技术,但就像所有工具一样,需要明智地使用。以下是一些指导原则:
- 优先测试真实API:模拟是为了解决特定问题,不是替代真实的集成测试
- 保持模拟简单:过度复杂的模拟可能掩盖真实问题
- 定期验证模拟:确保模拟行为与真实API保持一致
- 与团队共享模拟:创建团队共享的模拟库,保持一致性
记住,最好的测试策略是分层的:单元测试确保组件逻辑正确,API模拟测试确保前端处理逻辑正确,而完整的端到端测试确保整个系统协同工作。
通过掌握Playwright的网络拦截和模拟能力,你不仅能够创建更快速、更可靠的测试,还能在前后端并行开发时保持高效。这才是现代Web应用测试应有的样子——智能、灵活且强大。