Mock 数据骗过了测试,我们在拦截层补了一层契约校验才把假请求管住

前端测试里 mock API 不是什么新鲜事,但 mock 数据跟真实接口脱节这个坑,踩一次就够疼的。我们最近一个线上故障就是典型案例:组件测试全绿,QA 手工回归通过,结果发布后用户在某个表单提交场景直接白屏。回滚、定位、复盘,最后发现罪魁祸首是 mock 数据里字段类型和真实接口返回不一致——接口返回的 userIdnumber,mock 里写成了 string,组件里一个 === 严格比较直接短路。

这个问题暴露的不只是某个 case 的疏漏,而是整个测试策略的盲区。我们最后在拦截层加了一层自动化的契约校验,才把这类假请求真正管住。这篇文章把这个方案的思路和落地说清楚。

为什么 mock 数据骗过了三层防线

我们的测试流程里,针对这个表单组件其实有三层验证:

  1. 单元测试:用 MSW mock 了 API,返回固定的 JSON 数据,组件渲染、交互逻辑全部覆盖;
  2. 集成测试:同样用 MSW,但模拟了多个接口的调用序列,验证完整用户流程;
  3. QA 手工回归:在 staging 环境走了一遍核心路径。

三层全过,但问题还是漏出去了。复盘时发现,单元测试和集成测试用的 mock 数据是同一个 fixture 文件手写的,而 QA 手工回归的 staging 环境恰好用了另一套后端服务,那个版本还没部署这次的字段类型变更。换句话说,没有任何一个环节真正验证过“前端期望的数据结构”和“后端实际返回的数据结构”是否一致。

这就是 mock 的本质缺陷:mock 只验证“如果后端返回了这样的数据,前端能不能正常工作”,却不验证“后端到底会不会返回这样的数据”。当 mock 数据由前端开发者凭记忆或文档手写时,它本质上是一份“前端对后端的猜测”。猜测一旦出错,测试全绿只是证明了代码在错误假设下能跑。

更隐蔽的是,这类问题往往不出现在核心路径上。核心路径的数据结构大家记得牢,手写 mock 时不容易出错。真正容易出问题的是边界场景:可选字段缺失时返回什么类型?列表为空时是 [] 还是 null?错误响应体的 code 字段是 number 还是 string?这些细节在 mock 里经常随手写成“方便测试”的形态,跟真实接口渐行渐远。

拦截层契约校验的设计思路

解决问题的方向很明确:不让手写 mock 数据成为唯一的真相来源。我们需要一个机制,在拦截层自动校验 mock 数据是否和后端接口定义一致。

这里的“拦截层”指的是前端测试中拦截 HTTP 请求的那一层。我们用的是 MSW(Mock Service Worker),但原理同样适用于 nock、miragejs 或其他方案。核心思路是:在 mock handler 返回数据之前,对响应体做一次结构校验,不符合契约的直接抛错,让测试在第一时间失败,而不是静默通过。

具体实现上,我们引入了 JSON Schema 作为契约描述。每个 API 的响应体对应一份 schema 文件,由后端维护(或者由前后端共同维护,放在共享仓库里)。MSW handler 在返回 mock 数据时,先用 ajv 之类的校验库跑一遍 schema 校验:

// handlers.ts
import { http, HttpResponse } from 'msw';
import Ajv from 'ajv';
import userProfileSchema from './schemas/userProfile.schema.json';
import { mockUserProfile } from './fixtures/userProfile';

const ajv = new Ajv();
const validateUserProfile = ajv.compile(userProfileSchema);

export const handlers = [
  http.get('/api/user/profile', () => {
    const data = mockUserProfile();
    const valid = validateUserProfile(data);

    if (!valid) {
      throw new Error(
        `契约校验失败: ${ajv.errorsText(validateUserProfile.errors)}`
      );
    }

    return HttpResponse.json(data);
  }),
];

这样一来,任何 mock 数据如果跟 schema 定义不一致——字段类型错误、必填字段缺失、格式不符——测试阶段立即炸掉,而且错误信息精确到具体字段。之前那个 userId 类型错误,在这套机制下会在第一次跑测试时就暴露,而不是等到生产环境用户触发。

契约校验的三层价值

这套机制带来的收益不只是“提前发现问题”这么简单。它重构了前后端协作的信任模型。

第一层:强制 mock 数据与契约同步。 过去 mock 数据是独立演进的,后端改了接口,前端只要不更新 mock 文件,测试照样过。现在 schema 成为唯一的事实来源,mock 数据必须符合 schema 才能通过校验。当后端修改了接口定义并更新 schema 后,前端拉取最新 schema 跑测试,不符合的 mock 数据直接爆红。这个反馈循环是自动的,不需要人工沟通“你那个接口是不是改了字段”。

第二层:边界场景的覆盖率被动提升。 Schema 里定义的约束——比如 minLengthenumtypenullable——往往覆盖了手写 mock 时容易忽略的边界。以前 mock 一个用户列表,为了省事直接返回 3 条固定数据;现在 schema 要求 items 满足某种结构,而且可能通过 minItems 约束了最小条目数。为了通过校验,开发者不得不构造更贴近真实分布的 mock 数据。这不是靠自觉,是靠校验逼出来的。

第三层:响应体演进的可追溯性。 Schema 文件纳入版本控制后,每次接口变更都有 diff 记录。测试失败时,看一眼 schema 的变更历史就能定位是后端改了契约还是前端 mock 数据过时了。这比翻聊天记录或者查 API 文档高效得多。

落地时的几个关键决策

方案说起来简单,实际落地有几个点不处理好,很容易变成形式主义。

Schema 谁来维护? 我们试过两种模式:后端维护 schema,前端只消费;前后端共同维护在 monorepo 的共享包里。最终选了后者,因为后端维护的 schema 往往滞后于实际代码,而且格式偏向服务端视角(比如包含了前端不关心的字段)。共享包模式要求每次接口变更时,修改 schema 成为 PR 的强制部分,CI 里跑前后端的契约测试。这个流程成本高一点,但换来的确定性值得。

Schema 的粒度怎么定? 一开始我们试图对所有接口做全量 schema 校验,结果 schema 文件维护成本爆炸,团队怨声载道。后来调整为只对核心数据流接口(用户信息、权限、交易数据等)做严格校验,其他接口做宽松校验(只检查 type 不检查具体字段)。这样把有限的精力投在高风险区域,ROI 最高。

校验失败后的处理策略。 我们的规则是:契约校验失败,测试必须 fail,不允许降级为 warning。这听起来激进,但如果不这么做,团队很快会养成“忽略 warning”的习惯,校验就形同虚设。唯一例外是本地开发时可以通过环境变量关闭校验,方便快速调试——但 CI 上绝不放过。

和 TypeScript 类型的关系。 有人会问:既然前端已经用 TypeScript 定义了接口返回类型,为什么还要 JSON Schema 校验?TypeScript 类型在编译后消失,mock 数据是运行时生成的,编译期检查不到 mock 数据和类型定义的一致性。而 schema 校验是运行时的,直接拿实际的 mock 数据做验证。两者是互补关系,不是替代关系。我们后来还写了个脚本,从 TypeScript 类型自动生成 JSON Schema 的基础结构,减少手动维护 schema 的成本。

这个方案不解决什么问题

诚实地说,拦截层契约校验解决的是“mock 数据结构和真实接口不一致”的问题,但它不解决“mock 数据的行为逻辑和真实接口不一致”的问题。比如,真实接口在连续调用时可能返回不同的数据(分页、状态变更),mock 如果只是返回固定数据,契约校验是发现不了的。这类问题需要靠集成测试或端到端测试来覆盖,不能指望一套方案包打天下。

另外,schema 本身也可能出错。如果后端实现了一个接口,但 schema 写错了——比如把 nullable 标成了 required——那么契约校验反而会把正确的 mock 数据拦下来。所以 schema 的准确性是整个方案的前提,这需要流程保证,不是技术能解决的。

常见问题

为什么不直接用端到端测试替代 mock?

端到端测试确实能发现 mock 数据不一致的问题,但执行速度太慢,不适合在每次提交时跑全量。我们的策略是:单元测试和组件测试用 mock + 契约校验保证快速反馈,端到端测试在 CI 的合并门禁里跑核心路径。两层互补,而不是二选一。

JSON Schema 写起来太繁琐,有没有更轻量的方案?

如果团队觉得手写 JSON Schema 成本高,可以考虑用工具从 OpenAPI/Swagger 文档自动生成 schema,或者用 TypeScript 类型结合 zod/yup 这类运行时校验库。核心原则不变:在拦截层做自动校验,只是校验规则的编写方式可以灵活选。

契约校验会影响测试的独立性吗?

不会。测试的独立性指的是不同测试用例之间不互相依赖、不共享状态。契约校验只是改变了单个测试内部的数据准备方式——从“手写任意数据”变成“手写符合 schema 的数据”。测试之间的隔离性没有受到影响。

如果后端接口还没开发完,前端先写 mock 和测试,schema 从哪来?

这种情况 schema 由前端先起草,放在共享仓库里,作为前后端的“接口协议提案”。后端开发时以这份 schema 为目标实现接口,前端继续用这份 schema 校验 mock。等后端实现完成后,再跑一轮契约测试确认双方一致。这个流程本质上把 schema 当成了接口设计的先行产物,而不是事后的文档补录。