你可能已经熟悉用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 组织模拟代码的建议
-
按功能模块组织模拟:将相关的API模拟放在一起
-
创建模拟数据的工厂函数:避免硬编码测试数据
-
使用环境变量控制模拟行为:便于在不同环境切换
// 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');
});
选择合适的测试策略
拦截和模拟网络请求是强大的技术,但就像所有工具一样,需要明智地使用。以下是一些指导原则:
-
优先测试真实API:模拟是为了解决特定问题,不是替代真实的集成测试
-
保持模拟简单:过度复杂的模拟可能掩盖真实问题
-
定期验证模拟:确保模拟行为与真实API保持一致
-
与团队共享模拟:创建团队共享的模拟库,保持一致性
记住,最好的测试策略是分层的:单元测试确保组件逻辑正确,API模拟测试确保前端处理逻辑正确,而完整的端到端测试确保整个系统协同工作。
通过掌握Playwright的网络拦截和模拟能力,你不仅能够创建更快速、更可靠的测试,还能在前后端并行开发时保持高效。这才是现代Web应用测试应有的样子------智能、灵活且强大。