news 2026/4/17 14:38:46

Playwright测试数据模拟:Mock Service Worker使用指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Playwright测试数据模拟:Mock Service Worker使用指南

1. 那个让我加班到凌晨两点的测试场景

去年我们团队接到一个紧急需求:测试一个预约挂号系统。一切都挺顺利,直到遇到这个场景——“当号源被抢光时,显示候补排队功能”。问题来了:我们怎么在自动化测试里模拟“号源瞬间被抢光”的状态?

最初我们尝试了各种歪门邪道:手动修改数据库、写脚本清空号源、甚至想用两个测试账号同时操作……直到周五晚上11点,第6次尝试失败后,我盯着控制台里那些真实的HTTP请求,突然意识到:我们一直在解决错误的问题。

真正的解决方案不是去操纵真实系统状态,而是拦截请求,直接返回我们想要的响应。这就是Mock Service Worker(MSW)进入我们技术栈的开始。

2. 为什么传统的Mock方法让我们痛苦不堪?

先看看我们曾经尝试过的几种方案:

方案A:直接修改业务代码

// ❌ 测试代码侵入业务逻辑 if (process.env.NODE_ENV === 'test') { mockData = require('./test-data.json'); return res.json(mockData); } // 生产环境代码...

方案B:在测试中覆写fetch

// ❌ 混乱不堪,难以维护 beforeEach(() => { window.fetch = jest.fn().mockImplementation(() => { return Promise.resolve({ json: () => Promise.resolve({ tickets: 0 }) }); }); });

方案C:搭建一个假的测试服务器

# ❌ 开发、维护成本太高 $ npm run start-mock-server $ npm run start-test-server $ npm run start-dev-server # 到底该启动哪个?!

这些方案要么污染生产代码,要么难以维护,要么需要复杂的本地环境。直到我们发现MSW,才真正解决了这些问题。

3. MSW的核心优势:像真实服务器一样工作

Mock Service Worker(MSW)是一个基于Service Worker的API mocking库。它的工作原理很巧妙:

  1. 在浏览器中注册Service Worker,拦截所有网络请求

  2. 匹配请求模式,决定是否要拦截

  3. 返回模拟的响应,而不是发送到真实服务器

// 这是MSW的基本工作原理示意图 // [你的应用] --> [fetch('/api/tickets')] // ↓ // [Service Worker 拦截] // ↓ // [匹配路由 handlers] // ↓ // [返回模拟响应 {tickets: 0}] // ↓ // [应用收到响应]

关键优势在于:你的应用完全不知道自己在被mock。它发送真实的HTTP请求,收到真实的HTTP响应,只是中间的过程被我们“偷梁换柱”了。

4. 一步步搭建Playwright + MSW环境

4.1 安装必要的包

# 安装MSW核心库 npm install msw --save-dev # Playwright测试工具 npm install @playwright/test --save-dev # 类型定义(TypeScript项目需要) npm install @types/msw --save-dev

4.2 创建模拟处理器

mocks/handlers.js

// 模拟处理器 - 定义各种API的mock响应 import { http, HttpResponse } from'msw'; exportconst handlers = [ // 1. 模拟获取号源列表 http.get('/api/tickets', ({ request }) => { const url = new URL(request.url); const date = url.searchParams.get('date'); const department = url.searchParams.get('department'); console.log(`[MSW] 拦截请求: /api/tickets?date=${date}&department=${department}`); // 根据日期和科室返回不同数据 if (date === '2024-06-15' && department === 'cardiovascular') { // 模拟心内科号源已抢光 return HttpResponse.json({ success: true, data: { available: false, tickets: 0, waitingCount: 42, nextAvailableDate: '2024-06-20' }, message: '号源已满,可加入候补' }); } // 默认返回有号源的情况 return HttpResponse.json({ success: true, data: { available: true, tickets: 12, waitingCount: 0, nextAvailableDate: null } }); }), // 2. 模拟提交预约 http.post('/api/appointments', async ({ request }) => { const body = await request.json(); console.log('[MSW] 创建预约:', body); // 模拟10%的失败率,测试异常流程 const shouldFail = Math.random() < 0.1; if (shouldFail) { return HttpResponse.json( { success: false, error: 'SYSTEM_BUSY', message: '系统繁忙,请稍后重试' }, { status: 503 } ); } // 成功响应 return HttpResponse.json({ success: true, data: { appointmentId: `APT${Date.now()}`, status: 'PENDING', queuePosition: body.waitList ? 15 : null, estimatedTime: body.waitList ? '2-3工作日' : '立即确认' } }); }), // 3. 模拟取消预约 http.delete('/api/appointments/:id', ({ params }) => { const { id } = params; // 模拟特定的预约ID不能取消 if (id === 'APT_NO_CANCEL') { return HttpResponse.json( { success: false, error: 'CANCELLATION_NOT_ALLOWED', message: '该预约已过取消截止时间' }, { status: 400 } ); } return HttpResponse.json({ success: true, message: '预约已取消' }); }), // 4. 模拟GraphQL请求(如果项目使用) http.post('/graphql', async ({ request }) => { const { query, variables } = await request.json(); if (query.includes('GetPatientInfo')) { return HttpResponse.json({ data: { patient: { id: variables.id, name: '测试用户', idCard: '110101199001011234', phone: '13800138000' } } }); } return HttpResponse.json({ data: {} }); }), // 5. 模拟文件上传 http.post('/api/upload', async () => { // 模拟上传进度 awaitnewPromise(resolve => setTimeout(resolve, 500)); return HttpResponse.json({ success: true, url: 'https://mock-cdn.com/uploads/test-image.jpg', size: 204800, filename: 'test-upload.jpg' }); }) ];

4.3 配置Service Worker

mocks/browser.js

// 浏览器环境使用的MSW设置 import { setupWorker } from'msw/browser'; import { handlers } from'./handlers'; // 创建worker实例 exportconst worker = setupWorker(...handlers); // 开发工具:在控制台暴露一些工具函数 if (typeofwindow !== 'undefined') { window.__MSW = { // 动态修改mock响应 overrideHandler: (method, path, newResponse) => { // 这里可以实现动态修改handlers的逻辑 console.log(`[MSW Debug] 覆盖 ${method} ${path}`); }, // 查看当前拦截的请求 getRequestLog: () => { returnwindow.__mswRequests || []; }, // 模拟网络错误 simulateNetworkError: (shouldFail = true) => { window.__mswNetworkError = shouldFail; } }; }

4.4 为Playwright创建专用配置

tests/msw-setup.js

// Playwright专用的MSW配置 import { createServer } from'http'; import { setupServer } from'msw/node'; import { handlers } from'../mocks/handlers'; // 创建Node.js环境下的mock server exportconst server = setupServer(...handlers); // 扩展handlers,添加一些测试专用的mock exportconst testHandlers = { // 强制让某个接口失败 forceFail: (method, url) => { server.use( http[method.toLowerCase()](url, () => { returnnew Response(null, { status: 500 }); }) ); }, // 延迟响应,测试loading状态 delayResponse: (method, url, delayMs) => { server.use( http[method.toLowerCase()](url, async () => { awaitnewPromise(resolve => setTimeout(resolve, delayMs)); return HttpResponse.json({ delayed: true }); }) ); }, // 验证请求参数 captureRequests: (method, url) => { const requests = []; server.use( http[method.toLowerCase()](url, async ({ request }) => { const body = await request.text(); requests.push({ url: request.url, method: request.method, body: body ? JSON.parse(body) : null, headers: Object.fromEntries(request.headers.entries()), timestamp: newDate().toISOString() }); return HttpResponse.json({ captured: true }); }) ); return requests; } }; // 启动和停止server的实用函数 exportconst startMSW = async (page) => { // 在页面中注入Service Worker await page.addInitScript(() => { // 这里可以注入一些全局的mock配置 window.__TEST_MODE = true; window.__MOCK_API = true; }); // 启动mock server server.listen({ onUnhandledRequest: (request) => { // 对于未处理的请求,根据情况决定是否报错 const url = request.url.toString(); // 忽略静态资源请求 if (url.includes('.css') || url.includes('.js') || url.includes('.ico')) { return; } // 忽略某些特定的API(如果有的话) if (url.includes('/api/health-check')) { return; } // 其他未处理的请求打印警告 console.warn(`[MSW] 未处理的请求: ${request.method} ${url}`); } }); }; exportconst stopMSW = () => { server.close(); };

5. 在Playwright测试中使用MSW

5.1 基础测试示例

tests/appointment.spec.js

import { test, expect } from'@playwright/test'; import { startMSW, stopMSW, testHandlers } from'./msw-setup'; // 在每个测试文件开始时启动MSW test.beforeAll(async () => { // 这里可以初始化一些全局的mock数据 console.log('[Test Setup] 启动MSW Mock Server'); }); // 在每个测试用例前设置 test.beforeEach(async ({ page }) => { // 启动MSW await startMSW(page); // 跳转到测试页面 await page.goto('/appointment'); // 等待必要的元素加载 await page.waitForSelector('[data-testid="appointment-container"]'); }); // 测试用例1:正常预约流程 test('用户成功预约挂号', async ({ page }) => { // 页面已经加载了默认的mock数据(有号源) // 1. 选择日期 await page.click('[data-testid="date-2024-06-10"]'); // 2. 选择科室 await page.selectOption('[data-testid="department-select"]', 'internal'); // 3. 验证号源显示正确 const ticketCount = await page.textContent('[data-testid="ticket-count"]'); expect(parseInt(ticketCount)).toBeGreaterThan(0); // 4. 选择医生 await page.click('[data-testid="doctor-1001"]'); // 5. 填写患者信息 await page.fill('[data-testid="patient-name"]', '张三'); await page.fill('[data-testid="patient-id"]', '110101199001011234'); // 6. 提交预约 await page.click('[data-testid="submit-appointment"]'); // 7. 验证成功提示 await expect(page.locator('[data-testid="success-message"]')).toBeVisible(); await expect(page.locator('[data-testid="appointment-id"]')).toContainText('APT'); }); // 测试用例2:号源已抢光的情况 test('当号源被抢光时显示候补排队', async ({ page }) => { // 动态修改mock:让心内科2024-06-15的号源为0 // 这里我们需要用另一种方式,因为MSW在Node环境下运行 // 我们可以通过query参数来触发特定的mock场景 // 1. 直接访问特定日期和科室的组合 await page.goto('/appointment?date=2024-06-15&department=cardiovascular'); // 2. 验证显示"号源已满" await expect(page.locator('[data-testid="no-tickets-alert"]')).toBeVisible(); // 3. 验证候补排队按钮显示 await expect(page.locator('[data-testid="waitlist-button"]')).toBeVisible(); // 4. 点击加入候补 await page.click('[data-testid="waitlist-button"]'); // 5. 填写候补信息 await page.fill('[data-testid="waitlist-phone"]', '13800138000'); await page.click('[data-testid="confirm-waitlist"]'); // 6. 验证候补成功 await expect(page.locator('[data-testid="waitlist-success"]')).toBeVisible(); const position = await page.textContent('[data-testid="queue-position"]'); expect(position).toMatch(/第\d+位/); }); // 测试用例3:网络异常处理 test('当API请求失败时显示错误信息', async ({ page }) => { // 使用testHandlers强制让预约接口失败 // 注意:这里需要MSW支持动态修改handlers // 简化方案:通过特定参数触发错误 await page.goto('/appointment?forceError=true'); // 尝试提交预约 await page.fill('[data-testid="patient-name"]', '李四'); await page.click('[data-testid="submit-appointment"]'); // 验证错误提示 await expect(page.locator('[data-testid="error-toast"]')).toBeVisible(); await expect(page.locator('[data-testid="error-message"]')).toContainText('系统繁忙'); // 验证重试按钮可用 await expect(page.locator('[data-testid="retry-button"]')).toBeEnabled(); }); // 测试用例4:取消预约的限制条件 test('处理不能取消的预约', async ({ page }) => { // 查看一个特殊的预约(不能取消的) await page.goto('/appointment/detail/APT_NO_CANCEL'); // 验证取消按钮不可用或有特殊提示 const cancelButton = page.locator('[data-testid="cancel-button"]'); await expect(cancelButton).toBeDisabled(); // 或者验证有提示信息 await expect(page.locator('[data-testid="cancel-notice"]')).toContainText('已过取消时间'); }); // 清理 test.afterEach(async () => { // 重置MSW handlers,避免测试间相互影响 server.resetHandlers(); }); test.afterAll(() => { stopMSW(); });

5.2 高级用法:动态Mock场景

tests/msw-dynamic.spec.js

import { test, expect } from'@playwright/test'; import { server } from'./msw-setup'; import { http, HttpResponse } from'msw'; // 动态修改mock响应的测试 test.describe('动态Mock场景', () => { let capturedRequests = []; test.beforeEach(async ({ page }) => { // 清空之前捕获的请求 capturedRequests = []; // 动态添加一个handler来捕获请求 server.use( http.post('/api/appointments', async ({ request }) => { const body = await request.json(); capturedRequests.push({ url: request.url, body, timestamp: newDate().toISOString() }); // 根据不同的测试数据返回不同的响应 if (body.patientName === '特殊用户') { return HttpResponse.json({ success: true, special: true, priority: true }); } return HttpResponse.json({ success: true }); }) ); await page.goto('/appointment'); }); test('验证请求参数是否正确发送', async ({ page }) => { // 填写表单 await page.fill('[data-testid="patient-name"]', '测试用户'); await page.fill('[data-testid="symptoms"]', '头痛发热'); // 提交 await page.click('[data-testid="submit-appointment"]'); // 验证捕获的请求 expect(capturedRequests).toHaveLength(1); expect(capturedRequests[0].body).toMatchObject({ patientName: '测试用户', symptoms: '头痛发热' }); }); test('模拟慢速网络', async ({ page }) => { // 添加一个延迟响应的handler server.use( http.get('/api/tickets', async () => { awaitnewPromise(resolve => setTimeout(resolve, 2000)); // 2秒延迟 return HttpResponse.json({ success: true, data: { tickets: 5 } }); }) ); // 验证loading状态显示 await page.click('[data-testid="refresh-tickets"]'); // 应该显示loading await expect(page.locator('[data-testid="loading-spinner"]')).toBeVisible(); // 2秒后loading应该消失 await expect(page.locator('[data-testid="loading-spinner"]')).not.toBeVisible({ timeout: 3000 }); }); });

6. 实际项目中的最佳实践

6.1 目录结构建议

project/ ├── mocks/ │ ├── handlers/ # 按功能分组的handlers │ │ ├── appointment.js │ │ ├── user.js │ │ └── payment.js │ ├── fixtures/ # mock数据文件 │ │ ├── users.json │ │ └── tickets.json │ ├── utils.js # 工具函数 │ └── browser.js # 浏览器配置 ├── tests/ │ ├── msw-setup.js # Playwright MSW配置 │ ├── appointment.spec.js │ └── user.spec.js └── playwright.config.js

6.2 在团队中推广的经验

  1. 建立Mock数据契约:与后端团队约定API响应格式,确保mock数据与真实API一致

  2. 创建Mock数据生成器

// mocks/factories/appointment.js exportconst createMockAppointment = (overrides = {}) => ({ id: `APT${Date.now()}`, patientName: overrides.patientName || '测试用户', department: overrides.department || 'internal', doctor: overrides.doctor || '张医生', status: overrides.status || 'PENDING', appointmentTime: overrides.appointmentTime || '2024-06-15 09:00', createdAt: newDate().toISOString(), ...overrides });
  1. 可视化Mock管理界面(高级需求):

// 可以创建一个简单的UI来管理mock状态 // 在测试环境中添加一个浮动面板 if (process.env.NODE_ENV === 'development') { // 注入mock控制面板 const mockPanel = document.createElement('div'); mockPanel.id = 'msw-control-panel'; // ... 实现mock状态切换的UI }

7. 遇到的坑和解决方案

坑1:Service Worker缓存问题

// 解决方案:在测试开始前清理缓存 await page.addInitScript(() => { if ('serviceWorker' in navigator) { navigator.serviceWorker.getRegistrations().then((registrations) => { for (const registration of registrations) { registration.unregister(); } }); } });

坑2:跨域请求拦截失败

// 解决方案:确保MSW正确处理跨域 export const handlers = [ http.get('https://api.example.com/*', () => { // 需要完整URL匹配 return HttpResponse.json({ mocked: true }); }) ]; // 或者在Playwright配置中设置baseURL // playwright.config.js use: { baseURL: 'https://api.example.com', }

坑3:测试间的状态污染

// 解决方案:每个测试后重置handlers test.afterEach(async () => { server.resetHandlers(); // 同时清除页面状态 await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); }); });

8. 效果评估:值不值得投入?

实施MSW三个月后,我们的数据变化:

  • 测试执行时间:从平均45分钟减少到12分钟

  • 测试稳定性:因后端不稳定导致的测试失败减少92%

  • 开发体验:前端开发不再需要启动完整的后端服务

  • 测试覆盖率:边缘场景的测试覆盖率从30%提升到85%

更重要的是,我们现在可以轻松测试那些“罕见但重要”的业务场景:服务器错误、网络超时、数据边界情况……

9. 开始你的MSW之旅

如果你也想开始使用MSW,我建议:

  1. 从一个小功能开始:选择一个API相对独立的模块

  2. 先mock只读接口:GET请求比POST/DELETE更安全

  3. 建立团队共识:确保大家理解为什么要用MSW

  4. 逐步替换旧的mock方案:不要试图一次性重写所有测试

记住,任何技术方案的目标都是解决问题,而不是增加复杂度。MSW在我们项目中成功了,因为它确实解决了测试数据控制的痛点。

如果你在实施过程中遇到问题,或者有更好的实践方案,欢迎随时交流——在测试这条路上,我们都在不断学习和改进。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/17 12:32:57

Qwen3-VL-2B部署优化:内存占用降低50%的配置技巧

Qwen3-VL-2B部署优化&#xff1a;内存占用降低50%的配置技巧 1. 背景与挑战&#xff1a;多模态模型在边缘环境下的部署瓶颈 随着大模型从纯文本向多模态演进&#xff0c;视觉语言模型&#xff08;Vision-Language Model, VLM&#xff09;正逐步成为智能交互系统的核心组件。Q…

作者头像 李华
网站建设 2026/4/16 12:07:19

基于PMU的电源管理系统架构全面讲解

电源管理的艺术&#xff1a;深入理解现代PMU系统架构与实战设计你有没有遇到过这样的场景&#xff1f;系统上电瞬间&#xff0c;CPU莫名其妙死机&#xff1b;电池明明还有30%&#xff0c;设备却突然关机&#xff1b;轻载时续航短得离谱……这些看似“玄学”的问题&#xff0c;背…

作者头像 李华
网站建设 2026/4/10 13:37:21

AI超清画质增强进阶:结合OpenCV进行预处理与后处理

AI超清画质增强进阶&#xff1a;结合OpenCV进行预处理与后处理 1. 技术背景与核心价值 随着数字图像在社交媒体、安防监控和文化遗产修复等领域的广泛应用&#xff0c;低分辨率、模糊或压缩失真的图片已成为影响用户体验的重要瓶颈。传统的插值放大方法&#xff08;如双线性、…

作者头像 李华
网站建设 2026/4/13 14:22:30

Hunyuan模型加载失败?HY-MT1.8B分词器配置问题解决指南

Hunyuan模型加载失败&#xff1f;HY-MT1.8B分词器配置问题解决指南 1. 问题背景与场景分析 在使用 Tencent-Hunyuan/HY-MT1.5-1.8B 翻译模型进行二次开发时&#xff0c;不少开发者反馈在调用 AutoTokenizer.from_pretrained() 加载分词器时出现异常&#xff0c;导致模型无法正…

作者头像 李华
网站建设 2026/4/6 12:02:19

从零开始学AI自动化:UI-TARS-desktop新手入门教程

从零开始学AI自动化&#xff1a;UI-TARS-desktop新手入门教程 1. 学习目标与前置知识 1.1 教程目标 本教程旨在帮助初学者快速掌握 UI-TARS-desktop 的基本使用方法&#xff0c;理解其作为多模态 AI Agent 在桌面自动化中的核心能力。通过本指南&#xff0c;您将能够&#x…

作者头像 李华
网站建设 2026/4/14 21:20:56

Youtu-2B医疗场景应用:病历摘要生成系统搭建教程

Youtu-2B医疗场景应用&#xff1a;病历摘要生成系统搭建教程 1. 引言 1.1 业务场景描述 在现代医疗信息系统中&#xff0c;医生每天需要处理大量非结构化的临床记录&#xff0c;如门诊记录、住院日志和检查报告。这些文本信息虽然详尽&#xff0c;但难以快速提取关键诊疗信息…

作者头像 李华