MSW(Mock Service Worker)实战:API Mock 的正确姿势与常见陷阱

本文从实践经验角度讲解MSW(Mock Service Worker)在API Mock中的正确用法,涵盖handlers组织、延迟与错误模拟、测试环境配置,以及Service Worker缓存、匹配顺序、schema脱节等常见陷阱,并对比了与其他mock方案的优缺点。

前端开发最难等的是什么?很多时候不是设计稿,而是接口。前后端并行开发时,前端代码已经写了一大半,后端接口还停留在Swagger文档里。我见过的团队,通常会准备一套本地mock数据,把响应直接写死在请求函数里,或者启动一个json-server。这些方式都能解决问题,但都有各自的别扭——mock数据和真实请求路径对不上,或者每次切换环境都要临时改配置。后来我在项目里引入了MSW(Mock Service Worker),才找到了更接近真实环境的API Mock方式。不过,在用了半年多之后,我也踩了不少坑。这篇文章就把实践中的经验整理出来,重点说说MSW的正确姿势和常见陷阱。

AI technology illustration

MSW到底解决了什么问题

传统前端Mock的痛点,在于mock只覆盖了业务代码层,并没有覆盖网络层。比如用axios拦截器返回假数据,本地代码在运行时确实拿到了一样的响应,但你永远不知道这些数据是否真的能通过HTTP到达前端,也无法在DevTools的Network面板中看到对应的请求,更不用提真实请求负载和响应头差异带来的坑。

MSW则不同。它把mock逻辑放在Service Worker中,浏览器发出的fetch请求会先经过Service Worker,由我们提前注册的handler决定返回mock数据还是转发到网络。这种拦截发生在网络层,对业务代码完全透明。前端不需要关心数据是mock的还是真实的,只要接口路径正确,就能拿到对应的响应。

这里也顺带解释了为什么MSW在测试环境也很受欢迎——它提供了一个Node环境下的server入口,可以在单元测试或者组件测试中启动一个标准的拦截层,让你的请求代码和组件完全不用感知mock的存在。

在实际落地时,MSW比较适合三类场景:一是后端接口尚未稳定,前端需要独立开发;二是团队有Storybook或组件库,需要用不同数据状态展示组件;三是自动化测试必须稳定且可控,不能依赖外部环境。反过来,如果你只是需要一个纯静态的页面原型,或者接口本身已经有完整的mock server,MSW并不会比json-server更省事。

学会用正确姿势搭建MSW

MSW上手很简单,但要在真实项目里保持可维护性,需要花点心思。下面是我总结的三个关键点。

按业务域组织handlers,不要写成一个巨型文件

很多人刚开始用MSW时,习惯把所有接口写在一个mock文件里,看起来像这样:

import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/user', () => {
    return HttpResponse.json({ id: 1, name: 'Alice' })
  }),
  http.get('/api/posts', () => {
    return HttpResponse.json([
      { id: 1, title: 'Hello' },
    ])
  }),
]

这种写法在接口少的时候没毛病,但接口数量超过十几个以后,文件会变得极其难维护。更合理的做法是按业务域拆分,比如user、post、cart各放一个文件,最后在index里组合。

同时,对于路径中的动态参数,要用路径参数而不是正则匹配所有:

http.get('/api/users/:userId', ({ params }) => {
  return HttpResponse.json({ id: params.userId, name: 'User ' + params.userId })
})

这样做的好处是,当你需要模拟某个用户不存在或者权限不足时,可以直接在handler里判断参数,而不是开启另一个mock入口。一个明显的标志是,当你想找一个接口的mock定义却需要打开三四个文件时,说明当前的组织方式已经开始拖慢效率了。尽早按业务域拆分,比事后重构要舒服得多。

模拟延迟和错误状态,让前端处理异常逻辑

好的mock不是只返回成功数据,还要覆盖所有边界情况。如果前端完全不处理500错误和超时,等联调时才会发现一堆问题。MSW支持返回标准Response,配合delay函数可以模拟网络耗时:

import { delay, http, HttpResponse } from 'msw'

http.get('/api/order/:id', async ({ params }) => {
  await delay(500)
  return HttpResponse.json({ status: 'pending' })
})

http.get('/api/config', () => {
  return new HttpResponse('Service Unavailable', { status: 503 })
})

在写mock的时候,除了正常路径,至少要把401、403、500、空数组这四种情况覆盖到。它们分别对应着一类前端逻辑:重新登录、无权限、服务器故障和列表为空。让mock数据能模拟这些场景,前端开发才能真正“自测”完边界条件。例如专门mock一个带随机失败率的接口,用来验证前端重试机制是否有效,这在真实联调时反而很难复现。

在测试环境中使用MSW Server

MSW在浏览器中使用navigator.serviceWorker,但在Jest或Vitest这类Node环境里,你需要启用msw/node的setupServer。这样测试运行时,请求会被拦截,不会打到真实网络:

import { setupServer } from 'msw/node'
import { handlers } from './handlers'

const server = setupServer(...handlers)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

这里有一个容易被忽略的细节:必须调用server.resetHandlers()。因为测试之间可能会动态添加handler,如果不重置,上一个测试的handler会泄漏到下一个测试,造成偶发式失败。另外,如果你的测试代码里用了userEvent或waitFor,要注意MSW的请求处理是异步的,尽量使用waiting/findBy来等待数据渲染,避免在请求未完成时就断言。

这些MSW陷阱,我们团队都踩过

陷阱一:Service Worker缓存让你怀疑人生

第一次在项目里使用MSW时,最常遇到的一个现象是:改了mock数据,页面刷新后却不生效,或者干脆没有任何mock响应。这时候大概率是Service Worker缓存了旧的代码。和普通的JavaScript文件不同,Service Worker一旦注册就会被浏览器缓存起来,MSW官方也特别提醒过这件事。

遇到这种情况,打开DevTools的Application面板,在Service Workers选项中点击Unregister,并且勾选Update on reload再刷新页面。如果你正在使用Vite或Webpack开发服务器,还要确认public目录下正确生成了mockServiceWorker.js文件,否则worker注册会静默失败。

如果你在修改handler后依然看不到效果,先强制刷新DevTools中的Service Worker,这是最常见的MSW假故障。

陷阱二:handler匹配顺序和passthrough

MSW的handlers是按注册顺序匹配的。如果前一个handler命中了请求,后续的handler就不会再执行。很多团队在初期图省事,写了一个宽泛的http.get(‘*’),结果其他所有接口都被它吃掉,所有请求都返回了同一条假数据。正确的做法是给特定的接口写具体的path,并且合理使用passthrough来放出真实请求。

import { passthrough, http } from 'msw'

http.get('/api/external/*', () => passthrough())

这里也要注意,不要把passthrough放在handlers数组的最前面,否则所有请求都会被放走,mock就失效了。我见过一个比较隐蔽的问题:项目中使用了Express-style的路径,比如’/api/user?’,MSW不会把它当成query参数。应该使用query匹配或直接解析请求对象。如果你想根据query参数返回不同结果,可以在handler里解析请求url,而不是写多条正则。

陷阱三:mock数据的schema和真实接口脱节

MSW本身只负责“拦截后返回什么”,并不负责保证数据结构和后端一致。很多团队手写mock数据时,字段经常跟后端的OpenAPI文档对不上。一旦后端调整了字段,前端mock没有同步,测试就会在错误的数据快照里通过,到了联调阶段集中爆发。

要避免这个陷阱,最简单的方法是让mock数据生成和类型定义共源。你可以在项目里引入openapi-typescript,或者干脆在handlers里使用TypeScript类型约束返回数据,以便在编译期发现不一致。如果团队有共享的API SDK,mock数据也应该直接消费SDK中的类型。当然,共享类型不是唯一办法,你也可以用专门的mock数据库工具,比如用faker生成随机数据,再配合schema做校验。关键是让mock数据有一个可审查的契约,而不是拍脑袋写死。

陷阱四:在测试中与其他Mock工具混用

有些项目里之前用了Mock.js或者手动拦截,引入MSW之后并没有完全移除旧方案。看起来多了一层保障,实际上会让问题变得更难排查。比如某处请求先被Mock.js改写了,又被MSW拦截,返回结果就可能在意料之外。建议在同一个运行环境里只保留一种mock核心,其他工具只用作生成随机数据或处理数据模型,而不要重复拦截网络。

MSW和其他Mock方案怎么选

谈到API Mock,很多团队会纠结MSW、Mock.js、json-server怎么选。我的判断标准是:拦截位置、真实度和测试友好性。下面的表格可以做一个快速参考:

方案 拦截位置 真实度 测试友好性 适用场景
MSW Service Worker/Node网络层 高(支持Node server) 前端开发、Storybook、自动化测试
Mock.js 业务代码/XMLHttpRequest 较低(依赖前端执行) 快速生成随机数据
json-server 独立HTTP服务 中等 一般(需要额外进程) 后端接口未就绪时的原型开发
手写拦截函数 业务代码 差(导致测试mock耦合) 临时修改本地数据

如果你的项目很依赖后端交互,希望mock不影响业务代码,同时又需要考虑测试,MSW基本是现阶段最均衡的选择。但如果只是写一个小demo或者临时演示界面,json-server反而更直观。另外,是否选择MSW,主要看团队是否已经具备前后端分离的开发环境和测试基础设施。如果连Storybook都没有,Mock.js可能更轻;如果已经有测试体系,MSW的投入是值得的。

把MSW真正落地到团队里

理论再多,不如直接落地。我建议团队按下面几个步骤推进:

  1. 第一步,在开发环境接入MSW,只mock暂时没有后端的接口,其他请求仍然走真实网络,这样可以平滑过渡。
  2. 第二步,把handlers从业务代码中抽出来,作为独立模块统一维护,并接入Storybook,用于组件场景预览。
  3. 第三步,在测试环境中配置MSW server,替换掉现有的请求层mock,让测试用例稳定且不依赖网络。
  4. 第四步,如果后端有OpenAPI,可以尝试用工具自动生成handlers和类型定义,让mock数据与后端契约保持同步。

落地过程中,建议采用条件加载的方式,避免在生产环境启动MSW。以Vite为例,可以在入口文件里根据环境变量判断:

if (import.meta.env.MODE === 'development') {
  const { worker } = await import('./mocks/browser')
  await worker.start()
}

这样开发构建时自动启动mock,生产构建时完全不会加载相关代码。另外还有几个注意事项:不要在生产环境启动MSW,它只属于开发与测试工具;不要在同一个项目里维护多套mock环境,环境切换会增加认知负担;不要只把MSW当作随机数据生成器,更应该把它当作一条贯穿开发、测试、调试的Mock链路。

MSW让我重新理解了“Mock”这件事。它不只是发送一个假响应,而是一种能够更贴近真实网络环境的前端基础设施。它把API Mock从业务代码中解放出来,让开发、Storybook和测试都能站在同一个抽象层上。不过它也不是银弹,Service Worker的缓存、handler匹配顺序、数据schema同步这些问题,都需要用正确姿势去规避。

如果你正在搭建前端Mock体系,可以先用两三天小范围试点,跑通一条完整的开发链路。等团队熟悉了MSW的运行机制,再把所有场景逐步迁移过来。毕竟,工具好不好,不在于功能多少,而在于用起来能不能减少摩擦。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/738/

(0)
上一篇 1小时前
下一篇 40分钟前

相关推荐