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

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

相关推荐
aitoolhub2 小时前
AI设计重构创作逻辑:从技能执行到创意表达
人工智能·aigc·视觉传达
凯子坚持 c2 小时前
从 DeepSeek 的服务器繁忙到 Claude Code 全栈交付:2025 年 AI 原生开发实录
运维·服务器·人工智能
Lian_Ge_Blog2 小时前
prompt 工程学习总结
人工智能·深度学习·prompt
Watermelo6172 小时前
面向大模型开发:在项目中使用 TOON 的实践与流式处理
javascript·数据结构·人工智能·语言模型·自然语言处理·数据挖掘·json
沛沛老爹2 小时前
从Web到AI:金融/医疗/教育行业专属Skills生态系统设计实战
java·前端·人工智能·git·金融·架构
老蒋每日coding2 小时前
AI Agent 设计模式系列(十)——模型上下文协议 (MCP)
人工智能·设计模式
Robert--cao2 小时前
ubuntu22.04使用Isaac Sim 4.5.1与Isaac Lab 2.1.0完成BeyondMimic 环境
人工智能·算法·机器人
乾元2 小时前
黑盒之光——机器学习三要素在安全领域的投影
运维·网络·人工智能·网络协议·安全·机器学习·架构
Jouham2 小时前
正式上线!瞬维AI获客新功能解锁:客资自动沉淀+数据可视化,告别手动低效管理
人工智能