API Mock 与契约测试实战:MSW + Pact 全链路前后端解耦方案

深入解析 Mock Service Worker (MSW) 与 Pact 契约测试的完整实战方案,覆盖浏览器 Mock、Node.js 集成测试、消费者驱动契约与 CI 流水线集成,帮你彻底解决前后端联调效率低下的痛点。

前端开发 2026-05-29 18 分钟

根据 State of Frontend 2025 调查,67% 的开发团队将「前后端联调」列为项目延期的首要原因——接口没对齐、后端还没开发完、测试环境不稳定,这些问题日复一日地吞噬着开发效率。传统的解决方案是写一个静态 JSON 文件当 Mock,或者搭一个 Mock Server,但这些方案要么不够真实,要么维护成本高。Mock Service Worker (MSW) 用一种革命性的方式解决了这个问题:它在浏览器的 Service Worker 层拦截请求,让你的代码完全不知道自己在被 Mock——零侵入、零修改。再配合 Pact 的消费者驱动契约测试(Consumer-Driven Contract Testing),你就能保证 Mock 的行为和真实后端永远一致。

🔧 一、MSW 核心原理与浏览器端实战

1.1 为什么传统 Mock 方案都不够好?

在深入 MSW 之前,先看看我们踩过的坑:

方案 原理 优点 致命缺陷
静态 JSON 文件 手动维护 mock 数据 简单 数据脱离真实 API,接口变更时容易忘记更新
本地 Mock Server 启动一个 Express/JSON Server 灵活 需要额外进程、端口冲突、环境变量切换
代码内 Mock jest.mock('axios') 精确控制 Mock 逻辑侵入业务代码,无法在浏览器中使用
Proxy 代理 webpack-dev-server proxy 透明 依赖后端环境,后端挂了你也挂

⚠️ **警告:**上面这些方案的核心问题是——Mock 逻辑和真实请求走的是两条路径。你的代码在 Mock 环境下通过测试,不代表生产环境也能正常工作。

MSW 的核心创新在于:它在网络层拦截请求,而不是在代码层替换函数。浏览器发出的真实 fetch / XMLHttpRequest 请求会被 Service Worker 拦截,返回你定义的响应。你的业务代码完全不知道自己在被 Mock——这就是所谓的「网络层 Mock」。

1.2 MSW 浏览器端完整配置

第一步:安装与初始化

# 安装 MSW
npm install msw --save-dev

# 生成 Service Worker 文件(只需执行一次)
npx msw init public/ --save

💡 提示:--save 参数会把 init 命令写入 package.jsonpostinstall 脚本,确保团队成员安装依赖后自动同步 Service Worker 文件。

第二步:定义请求处理器(Handlers)

// src/mocks/handlers.js
// 定义所有需要 Mock 的 API 端点
import { http, HttpResponse, delay } from 'msw'

// 模拟数据库
const users = [
  { id: 1, name: '张三', email: 'zhangsan@example.com', role: 'admin' },
  { id: 2, name: '李四', email: 'lisi@example.com', role: 'user' },
  { id: 3, name: '王五', email: 'wangwu@example.com', role: 'user' },
]

export const handlers = [
  // ✅ GET 请求:获取用户列表(支持分页和搜索)
  http.get('/api/users', async ({ request }) => {
    const url = new URL(request.url)
    const page = parseInt(url.searchParams.get('page') || '1')
    const pageSize = parseInt(url.searchParams.get('pageSize') || '10')
    const keyword = url.searchParams.get('keyword') || ''

    await delay(200) // 模拟网络延迟

    let filtered = users
    if (keyword) {
      filtered = users.filter(u =>
        u.name.includes(keyword) || u.email.includes(keyword)
      )
    }

    const start = (page - 1) * pageSize
    const paged = filtered.slice(start, start + pageSize)

    return HttpResponse.json({
      code: 0,
      data: {
        list: paged,
        total: filtered.length,
        page,
        pageSize,
      },
    })
  }),

  // ✅ GET 请求:获取单个用户(路径参数)
  http.get('/api/users/:id', async ({ params }) => {
    await delay(100)
    const user = users.find(u => u.id === parseInt(params.id))

    if (!user) {
      return HttpResponse.json(
        { code: 404, message: '用户不存在' },
        { status: 404 }
      )
    }

    return HttpResponse.json({ code: 0, data: user })
  }),

  // ✅ POST 请求:创建用户(模拟验证错误)
  http.post('/api/users', async ({ request }) => {
    const body = await request.json()

    if (!body.name || !body.email) {
      return HttpResponse.json(
        { code: 400, message: '名称和邮箱为必填项' },
        { status: 400 }
      )
    }

    const newUser = {
      id: users.length + 1,
      ...body,
      createdAt: new Date().toISOString(),
    }
    users.push(newUser)

    return HttpResponse.json({ code: 0, data: newUser }, { status: 201 })
  }),

  // ✅ 模拟网络错误场景
  http.get('/api/flaky-endpoint', () => {
    return HttpResponse.error()
  }),
]

第三步:启动 Mock 服务

// src/mocks/browser.js
// 浏览器环境入口
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'

export const worker = setupWorker(...handlers)
// src/main.js(应用入口)
// 只在开发环境启动 Mock
async function bootstrap() {
  if (import.meta.env.DEV) {
    const { worker } = await import('./mocks/browser')
    await worker.start({
      onUnhandledRequest: 'warn', // 未处理的请求打印警告
      serviceWorker: {
        url: '/mockServiceWorker.js',
      },
    })
  }

  // 启动你的应用(Vue / React / 等)
  createApp(App).mount('#app')
}

bootstrap()

📌 **记住:**MSW 不会修改你的 fetchaxios 代码。你的 API 调用代码在有 Mock 和没有 Mock 时完全一样——这才是 MSW 的核心价值。

1.3 与 Vue 3 / React 集成的最佳实践

MSW 与框架无关,但在实际项目中,你通常需要根据环境变量控制 Mock 的开关:

// vite.config.js
// 通过环境变量控制 Mock 开关
export default defineConfig({
  define: {
    __ENABLE_MOCK__: process.env.ENABLE_MOCK === 'true',
  },
})
// .env.development
ENABLE_MOCK=true
VITE_API_BASE_URL=/api
// .env.production
ENABLE_MOCK=false
VITE_API_BASE_URL=https://api.example.com

这种方案的优势是:同一套代码,同一套接口定义,Mock 和真实 API 之间切换只需要改一个环境变量

🧪 二、Node.js 端集成测试与场景覆盖

2.1 MSW 在 Vitest 中的配置

浏览器端 Mock 用于开发调试,Node.js 端 Mock 用于自动化测试。MSW 同时支持两个环境:

// src/mocks/server.js
// Node.js 环境入口(用于测试)
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)
// vitest.setup.ts
// 测试全局配置
import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from './mocks/server'

// 启动 Mock Server
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))

// 每个测试后重置处理器(避免测试间相互影响)
afterEach(() => server.resetHandlers())

// 所有测试结束后关闭
afterAll(() => server.close())

2.2 测试中的高级用法:动态覆盖处理器

在测试中,你经常需要模拟不同的后端响应(成功、失败、超时)。MSW 的 server.use() 方法让你在单个测试中临时覆盖默认行为:

// user.test.ts
// 测试不同 API 响应场景
import { describe, it, expect } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
import { server } from './mocks/server'
import { fetchUser, createUser } from './api/user'

describe('用户 API', () => {
  it('正常获取用户', async () => {
    const user = await fetchUser(1)
    expect(user.name).toBe('张三')
  })

  it('用户不存在时返回 404', async () => {
    // 临时覆盖处理器:模拟 404 响应
    server.use(
      http.get('/api/users/:id', () => {
        return HttpResponse.json(
          { code: 404, message: '用户不存在' },
          { status: 404 }
        )
      })
    )

    await expect(fetchUser(999)).rejects.toThrow('404')
  })

  it('网络超时时的降级处理', async () => {
    // 临时覆盖:模拟 5 秒超时
    server.use(
      http.get('/api/users/:id', async () => {
        await delay(5000)
        return HttpResponse.json({ code: 0, data: {} })
      })
    )

    // 你的代码应该有超时处理逻辑
    const result = await fetchUser(1, { timeout: 3000 })
    expect(result).toBeNull() // 超时返回 null(降级策略)
  })

  it('服务端 500 错误时的重试逻辑', async () => {
    let callCount = 0

    server.use(
      http.get('/api/users/:id', () => {
        callCount++
        if (callCount < 3) {
          return HttpResponse.json(
            { code: 500, message: 'Internal Server Error' },
            { status: 500 }
          )
        }
        // 第三次调用成功
        return HttpResponse.json({
          code: 0,
          data: { id: 1, name: '张三' },
        })
      })
    )

    const user = await fetchUser(1, { retry: 3 })
    expect(user.name).toBe('张三')
    expect(callCount).toBe(3) // 验证确实重试了
  })
})

⚠️ 警告:server.resetHandlers() 会清除所有 server.use() 的临时覆盖,但不会清除初始化时传入的 handlers。务必在 afterEach 中调用,否则测试之间会相互污染。

2.3 MSW 处理器的组织架构

当项目规模增长后,把所有 handler 放在一个文件里会变得难以维护。推荐按领域模块拆分:

src/mocks/
├── index.ts              # 统一导出
├── browser.ts            # 浏览器入口
├── server.ts             # Node.js 入口
└── handlers/
    ├── auth.ts           # 认证相关
    ├── users.ts          # 用户相关
    ├── products.ts       # 商品相关
    └── payments.ts       # 支付相关
// src/mocks/index.ts
// 统一收集所有 handler
import { authHandlers } from './handlers/auth'
import { userHandlers } from './handlers/users'
import { productHandlers } from './handlers/products'
import { paymentHandlers } from './handlers/payments'

export const handlers = [
  ...authHandlers,
  ...userHandlers,
  ...productHandlers,
  ...paymentHandlers,
]

📋 三、Pact 消费者驱动契约测试

3.1 为什么有了 MSW 还需要契约测试?

MSW 解决了「前端开发不依赖后端」的问题,但它有一个致命弱点:你定义的 Mock 数据是你自己猜的。如果后端改了接口字段名、改了返回格式,你的 Mock 还是旧的——直到联调时才发现不一致。

消费者驱动契约测试(Consumer-Driven Contract Testing,CDCT) 的思路是:由前端(消费者)定义自己期望的接口格式,后端(提供者)验证自己的 API 满足所有消费者的期望

前端测试 → 生成契约文件(Pact 文件)→ 上传到 Pact Broker
                                          ↓
后端测试 ← 从 Pact Broker 下载契约 ← 验证 API 是否满足契约

3.2 前端:定义消费者契约

// tests/contract/user.consumer.pact.test.ts
// 前端作为消费者,定义期望的 API 契约
import { Pact } from '@pact-foundation/pact'
import path from 'path'
import { fetchUser, createUser } from '../../src/api/user'

const provider = new Pact({
  consumer: 'FrontendApp',
  provider: 'UserAPI',
  dir: path.resolve(process.cwd(), 'pacts'),
  logLevel: 'warn',
})

describe('用户 API 契约', () => {
  beforeAll(() => provider.setup())
  afterAll(() => provider.finalize())

  describe('GET /api/users/:id', () => {
    beforeAll(async () => {
      await provider.addInteraction({
        state: '用户 ID 1 存在',
        uponReceiving: '获取用户详情的请求',
        withRequest: {
          method: 'GET',
          path: '/api/users/1',
          headers: {
            Accept: 'application/json',
            Authorization: 'Bearer test-token',
          },
        },
        willRespondWith: {
          status: 200,
          headers: { 'Content-Type': 'application/json' },
          body: {
            code: 0,
            data: {
              id: provider.like(1),           // 类型匹配,不要求精确值
              name: provider.like('张三'),     // 字符串类型
              email: provider.term({           // 正则匹配
                generate: 'test@example.com',
                matcher: '^[\\w.-]+@[\\w.-]+\\.\\w+$',
              }),
              role: provider.stringMatching(/^(admin|user|moderator)$/),
            },
          },
        },
      })
    })

    it('返回符合契约的用户数据', async () => {
      const user = await fetchUser(1, {
        baseUrl: provider.mockService.baseUrl,
        token: 'test-token',
      })

      expect(user.id).toBeDefined()
      expect(user.name).toBeDefined()
      expect(user.email).toMatch(/@/)
      expect(['admin', 'user', 'moderator']).toContain(user.role)
    })
  })

  describe('POST /api/users', () => {
    beforeAll(async () => {
      await provider.addInteraction({
        state: '系统允许创建新用户',
        uponReceiving: '创建用户的请求',
        withRequest: {
          method: 'POST',
          path: '/api/users',
          headers: {
            'Content-Type': 'application/json',
            Authorization: 'Bearer test-token',
          },
          body: {
            name: '新用户',
            email: 'new@example.com',
          },
        },
        willRespondWith: {
          status: 201,
          body: {
            code: 0,
            data: {
              id: provider.like(4),
              name: provider.like('新用户'),
              email: provider.like('new@example.com'),
              createdAt: provider.iso8601DateTime(),
            },
          },
        },
      })
    })

    it('创建用户成功', async () => {
      const result = await createUser(
        { name: '新用户', email: 'new@example.com' },
        { baseUrl: provider.mockService.baseUrl, token: 'test-token' }
      )

      expect(result.id).toBeDefined()
      expect(result.createdAt).toBeDefined()
    })
  })
})

运行测试后,Pact 会在 pacts/ 目录生成一个 JSON 契约文件:

{
  "consumer": { "name": "FrontendApp" },
  "provider": { "name": "UserAPI" },
  "interactions": [
    {
      "description": "获取用户详情的请求",
      "providerState": "用户 ID 1 存在",
      "request": {
        "method": "GET",
        "path": "/api/users/1"
      },
      "response": {
        "status": 200,
        "body": {
          "code": 0,
          "data": { "id": 1, "name": "张三", "email": "test@example.com" }
        }
      }
    }
  ]
}

3.3 后端:验证提供者契约

// tests/contract/user.provider.pact.test.js
// 后端作为提供者,验证是否满足前端的契约
import { Verifier } from '@pact-foundation/pact'
import { startServer } from '../../src/server'

describe('User API Pact 验证', () => {
  let server

  beforeAll(async () => {
    server = await startServer({ port: 3001 })
  })

  afterAll(async () => {
    await server.close()
  })

  it('满足所有消费者的契约', async () => {
    await new Verifier({
      providerBaseUrl: 'http://localhost:3001',
      pactUrls: ['./pacts/FrontendApp-UserAPI.json'],
      stateHandlers: {
        '用户 ID 1 存在': async () => {
          // 在测试数据库中插入测试数据
          await db.users.insert({ id: 1, name: '张三', email: 'zhangsan@example.com' })
        },
        '系统允许创建新用户': async () => {
          // 确保测试环境允许创建用户
          await db.users.deleteMany({})
        },
      },
      logLevel: 'warn',
    }).verifyProvider()
  })
})

⚠️ **警告:**不要把 Pact 契约文件提交到 Git。应该通过 Pact Broker 在 CI/CD 流水线中自动交换。在 .gitignore 中添加 pacts/ 目录。

3.4 MSW + Pact 协作架构

MSW 和 Pact 不是互斥的,而是互补的。它们在不同阶段发挥作用:

阶段 工具 作用 谁验证
本地开发 MSW 前端独立开发,不依赖后端 开发者自己
单元/集成测试 MSW 快速测试各种 API 响应场景 自动化测试
契约测试 Pact 保证前后端接口格式一致 CI/CD 流水线
E2E 测试 真实 API 验证完整链路 测试环境

💡 四、CI/CD 集成与生产最佳实践

4.1 GitHub Actions 集成

# .github/workflows/contract-test.yml
name: 契约测试
on:
  pull_request:
    branches: [main]

jobs:
  consumer:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci

      # 运行消费者测试,生成 Pact 文件
      - run: npm run test:pact:consumer

      # 上传到 Pact Broker
      - run: |
          npx pact-broker publish ./pacts \
            --consumer-app-version=${{ github.sha }} \
            --branch=${{ github.head_ref }} \
            --broker-base-url=$PACT_BROKER_URL \
            --broker-token=$PACT_BROKER_TOKEN
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}

  provider:
    runs-on: ubuntu-latest
    needs: consumer
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci

      # 启动后端服务
      - run: npm run start:test &
      - run: sleep 5

      # 从 Pact Broker 获取契约并验证
      - run: npm run test:pact:provider
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
          PACT_CONSUMER_TAG: ${{ github.head_ref }}

4.2 常见坑点与避坑指南

坑点 1:MSW 在生产构建中被打包

// ❌ 错误:MSW 被打包到生产 bundle 中
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'

const worker = setupWorker(...handlers)
worker.start()
// ✅ 正确:通过环境变量和动态导入确保生产环境不含 MSW
if (import.meta.env.DEV) {
  const { worker } = await import('./mocks/browser')
  await worker.start({
    onUnhandledRequest: 'bypass', // 未匹配的请求直接放行
  })
}

💡 **提示:**在 Vite 中,import.meta.env.DEV 在生产构建时会被 tree-shake 移除整个 import() 表达式。如果你用 Webpack,需要配合 DefinePluginIgnorePlugin

坑点 2:Pact 契约过于严格

// ❌ 错误:期望精确值(后端数据变了就失败)
body: {
  id: 1,
  name: '张三',
  createdAt: '2026-05-30T10:00:00Z',
}
// ✅ 正确:使用匹配器(只要求类型和格式)
body: {
  id: provider.like(1),
  name: provider.like('张三'),
  createdAt: provider.iso8601DateTime(),
}

坑点 3:测试间数据污染

// ❌ 错误:handler 中使用闭包变量,测试间共享状态
let counter = 0
const handlers = [
  http.get('/api/count', () => {
    counter++
    return HttpResponse.json({ count: counter })
  }),
]
// ✅ 正确:每个 handler 独立,使用 request 信息决定响应
const handlers = [
  http.get('/api/count', ({ request }) => {
    const url = new URL(request.url)
    const seed = url.searchParams.get('seed') || '0'
    return HttpResponse.json({ count: parseInt(seed) })
  }),
]

4.3 性能与团队协作建议

MSW Handler 的懒加载:当 handler 超过 30 个时,建议按路由模块动态加载,减少初始化时间。

Pact Broker 的选型:小团队可以直接用 Pactflow(SaaS 版,免费层足够 5 人团队使用);大团队自建 Pact Broker(Docker 一键部署)。

渐进式落地策略

  • 第一步:给核心 API(登录、支付、主流程)添加 MSW Mock
  • 第二步:将 MSW handler 的数据结构同步到 Pact 契约
  • 第三步:在 CI 中加入契约验证,阻断不兼容的接口变更
  • 避免:一次性给所有 API 做 Mock,维护成本会压垮团队

🎯 总结

API Mock 和契约测试不是「锦上添花」,而是大型项目前后端协作的基础设施。MSW 在开发和测试阶段提供零侵入的网络层 Mock,Pact 在 CI/CD 阶段保证前后端接口契约一致。两者结合,形成从开发到部署的完整解耦方案。

⚡ **关键结论:**MSW 解决的是「开发效率」问题——让前端不等后端;Pact 解决的是「接口一致性」问题——让前后端变更可追溯。两者缺一不可。

相关工具推荐:

📚 相关文章