Published on

测试的浪费来自选错层:用能证明行为的最小层,mock 接缝别 mock 系统

Authors
  • avatar
    Name
    Jack Qin
    Twitter

测试最大的浪费,不是测试太少,而是写了一堆很容易通过、却根本覆盖不到真实失败模式的测试。它们让覆盖率数字好看,让 CI 一片绿,但真出 bug 时一个都没拦住——因为它们测的根本不是会出错的那个地方。

这篇想拆的是两个判断,它们决定了一个测试是有信号还是纯负担:这个测试该坐在哪一层,以及该 mock 什么、不该 mock 什么。两个判断都有同一个底层原则在背后——测试要去触碰那个真正可能失败的行为,既不绕过它(选层太低),也不为了碰它而拉起整个世界(选层太高、mock 太少或太多)。

选对层:用能证明行为的最小层

测试分层最常见的两个错误是对称的。一个是把所有后端测试都默认塞进 host 级集成测试——慢、重,每改一点都要拉起整个系统。另一个相反,是该上集成测试的地方却用了过度 mock,结果测试绿着、契约却悄悄漂移了。两个错误的根都是同一个:没有问"证明这个行为,需要的最小层是哪一层"。

后端按目的分层,而不是一个通用测试桶。apps/api/tests/ 下:

用途
Architecture/边界规则(Api/Worker 分离、BuildingBlocks 独立性、契约层纯净)
Hosts/host 级集成行为(启动、中间件、认证、基础设施接线)
Modules/模块聚焦的端点、job、service 测试
ApiContract/对外可见的 HTTP 契约漂移/回归检查

选层原则:选能证明行为的最小层,且不绕过重要边界。 这句话有两半,缺一不可。"最小层"是为了快和稳;"不绕过重要边界"是为了别把要验证的东西恰好测没了。

  • 架构测试用来保护项目和依赖边界——比如强制 Api 与 Worker 分离、BuildingBlocks 不依赖具体基础设施、契约层纯净。
  • host 测试只在行为依赖应用启动、中间件、认证或基础设施接线时用;当前用 WebApplicationFactory + PostgreSQL/RabbitMQ 的 Testcontainers + 配置覆盖。注意"依赖"这个词——如果行为不依赖这些,上 host 测试就是杀鸡用牛刀。
  • 模块测试用于模块自有的端点、job 逻辑、DB 行为和 consumer,贴着拥有它的模块命名空间,且不依赖不相关的模块。
  • 契约测试用于对外可见的 HTTP 契约稳定性,在前端消费者或生成客户端依赖稳定形状时尤其重要。

前端用 Vitest 做单元/组件测试,Playwright 做端到端。

  • 单元测试通常与源文件就近放置(colocated)。
  • 组件测试用 React Testing Library 模式。
  • E2E 测试放 apps/web/e2e/
  • 测试优先验证可观察行为和可访问查询。

Vitest 配置:环境 jsdom,setup 文件 src/test/setup.ts,覆盖率阈值 80 行 / 80 函数 / 80 语句 / 75 分支。已在用的模式有 describe/itvi.spyOn/vi.mock/vi.stubEnv,断言针对渲染的 UI 或公共函数行为。

交互风格优先测用户可观察的行为:用 role、label 或可见文本查询,优先用可访问选择器而非脆弱 DOM 遍历,断言结果而非内部实现细节。

E2E 用 Playwright(Chromium + Firefox),通过 global-setup.ts 复用认证状态,失败或重试时捕获 trace/截图/视频。E2E 适合:登录/登出、认证重定向、多页工作流、浏览器/运行时集成行为。但 E2E 不能替代聚焦的单元/组件测试——它太慢太重,不该用来证明一段纯逻辑。

Mock:mock 接缝,别 mock 整个系统

mock 的取舍是测试里最容易翻车的地方,因为它有两个相反的坑。mock 太少,测一段窄逻辑却拉起整个真实系统,慢且脆;mock 太狠,把恰恰要验证的那个契约边界给 mock 没了,测试绿着、契约却漂了。

仓库选择性地使用 mock、fake 和真实基础设施,大致三种策略:

  • 前端单元测试大量 mock 浏览器/网络/模块边界。
  • 后端 job/service 测试针对窄逻辑常用 fake 或内存依赖
  • host 级后端测试偏好通过 Testcontainers 用真实基础设施,而不是 mock 掉整个系统。

判断的关键是分清接缝系统。接缝是一个窄的、定义清晰的协作点——一个 fetch 调用、一个 command dispatch、一个 HTTP 客户端。mock 这种接缝是合理的,因为你要测的逻辑在接缝的这一侧,接缝的另一侧用 fake 顶替不影响你验证的东西。

适合 mock 的场景:测试前端对 fetch 的 API 包装;测试单个 hook/组件而它有昂贵的外部协作者;测试后端 job 逻辑而 dispatch 副作用可用 fake dispatcher 捕获;在窄接缝处测 HTTP 客户端或 AI/外部提供方。例如 vi.spyOn(globalThis, "fetch")FakeCommandDispatcherMockHttpMessageHandler

而当测试的价值就在接线本身时,mock 掉系统等于把要测的东西测没了——这时必须用真依赖。

适合用真依赖的场景:API host 启动与中间件、PostgreSQL 与 RabbitMQ 连接、认证与 Cookie 行为、queue/health/readiness 行为。这些行为只有在组件真正接在一起时才会出现,用 mock 数据库去测"DB 往返语义",恰恰避开了那个只在真实往返时才暴露的 bug。

核心一句话:mock 接缝,不要 mock 整个系统。 窄的命令/事件捕获优先用 fake;历史上只有组件接线在一起才出现的 bug,优先用真集成边界。要避开的是那种"让测试通过却掩盖契约漂移"的 mock——它给你绿灯,却把信号一起 mock 掉了。

测试数据:就近、最小、显式

测试数据通常就近内联创建,只做场景所需的最小设置。这条约定防的是一类隐蔽的可读性损失:当测试数据藏在一个不透明的 buildEverything() helper 里,读测试的人根本看不出这个场景的关键值是什么——时区?边界值?都被 helper 吞掉了。

  • 后端测试常直接 seed EF context(如把 schedule 直接 seed 进 EmailDbContext)。
  • 前端测试内联构造小而聚焦的 payload。
  • E2E 依赖配置好的合成/dev 凭据。
  • 共享外部测试库在仓库级有文档,但很多自动化后端测试通过 Testcontainers 保持自包含。

指导:优先小的、场景专属的 seed,而不是巨型 fixture 图;让 seed 值显式,使测试意图一目了然;当时间逻辑重要时,显式包含时区敏感值——时区相关的 bug 几乎都源于某个时间值被默默假设成了本地时区。凭据上用合成或文档化的 dev/test 用户,不要把生产密钥硬编码进测试。

质量门禁

测试是质量门禁的一部分,不只是本地信心检查。当前自动门禁包括:前端单元测试与覆盖率、前端构建与 lint、后端测试工作流、后端架构检查、API 契约检查、密钥扫描。

评审测试时该问:这个测试坐对层了吗?它真的去触碰那个可能失败的行为了吗?它有没有避免脆弱选择器或过度 mock?如果契约变了,契约/集成测试一起更新了吗?

反例

// 反例 1(前端):脆弱 DOM 遍历,产品一改结构就红
const btn = container.querySelectorAll('div')[3].children[0] // ❌
fireEvent.click(btn)

// 正确:可访问查询
fireEvent.click(screen.getByRole('button', { name: 'Submit' })) // ✅
// 反例 2(后端):把简单模块逻辑塞进 host 集成测试,又慢又重
public class EmailScheduleTests : IClassFixture<CustomWebApplicationFactory> // ❌ 杀鸡用牛刀
{
    // 仅验证一段纯 job 逻辑,却拉起整个 host + 容器
}
// 正确:模块测试 + FakeCommandDispatcher 捕获 dispatch 即可
// 反例 3:mock 掉了恰恰要验证的边界
var fakeDb = new Mock<IDbThing>(); // ❌ 这个 bug 只在真实 DB 往返时出现
// 正确:对 DB 往返语义,用 Testcontainers 的真实 PostgreSQL
// 反例 4:测试数据藏在不透明 helper 里,看不出场景关键值
const data = buildEverything() // ❌ 时区?边界值?都看不到
// 正确:内联显式 seed,意图清晰(含时区敏感值)

其它要避免的:不要默认把每个后端测试都做成 host 集成测试;不要绕过架构测试去改边界;不要测一个模块时穿过另一模块的内部;不要端点形状变了却忘了契约测试;不要把前端测试搬到远处的全局目录;不要在有用户可见断言可用时去测实现细节;不要让 flaky 测试苟着而不解决根因。

落地建议

  1. 先问"最小层在哪":能用模块测试证明的别上 host;能用单元证明的别上 E2E。
  2. mock 接缝而非系统:窄逻辑用 fake/内存依赖;接线本身就是测试价值时,上 Testcontainers 真依赖。
  3. 测试数据就近、最小、显式:小 seed 胜过巨型 fixture;时间逻辑务必显式写时区。
  4. 断言用户可见行为:role/label/可见文本优先,远离脆弱 DOM 遍历。
  5. 把覆盖率与架构/契约测试当门禁,不是装饰:契约一变,契约和集成测试一起更新;flaky 了就查根因,别让它常驻红/绿之间。

可迁移的那一层

抛开 Vitest 和 Testcontainers 的具体工具,测试真正可迁移的认知是:一个测试的价值,取决于它和"真正会失败的那个行为"之间的距离。 选层太低、mock 太狠,测试就站到了失败行为的旁边而非身上;选层太高、什么都用真依赖,测试又慢又脆,最后没人愿意跑。

写每个测试前,与其先想"怎么让它通过",不如先定位那个会出错的行为,然后问:证明这个行为不被破坏,所需的最小、又不绕过它的那一层在哪? 把测试精确地坐在那一层、只 mock 通往它的接缝,覆盖率才从一个数字变成真正的安全网。