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
    });

  3. 可视化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在我们项目中成功了,因为它确实解决了测试数据控制的痛点。

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

相关推荐
NAGNIP1 天前
一文搞懂深度学习中的通用逼近定理!
人工智能·算法·面试
冬奇Lab1 天前
一天一个开源项目(第36篇):EverMemOS - 跨 LLM 与平台的长时记忆 OS,让 Agent 会记忆更会推理
人工智能·开源·资讯
冬奇Lab1 天前
OpenClaw 源码深度解析(一):Gateway——为什么需要一个"中枢"
人工智能·开源·源码阅读
AngelPP1 天前
OpenClaw 架构深度解析:如何把 AI 助手搬到你的个人设备上
人工智能
宅小年1 天前
Claude Code 换成了Kimi K2.5后,我再也回不去了
人工智能·ai编程·claude
九狼1 天前
Flutter URL Scheme 跨平台跳转
人工智能·flutter·github
ZFSS1 天前
Kimi Chat Completion API 申请及使用
前端·人工智能
天翼云开发者社区1 天前
春节复工福利就位!天翼云息壤2500万Tokens免费送,全品类大模型一键畅玩!
人工智能·算力服务·息壤
知识浅谈1 天前
教你如何用 Gemini 将课本图片一键转为精美 PPT
人工智能
Ray Liang1 天前
被低估的量化版模型,小身材也能干大事
人工智能·ai·ai助手·mindx