testing-philosophy 是一套个人单元测试规范,与具体技术栈和测试运行器无关。文中的示例使用 Vitest,但每条规则都可直接移植到 Jest、pytest 等工具。该规范把两个问题分开处理:什么样的测试值得保留,以及测试究竟在什么时候运行。将两者混为一谈,会导致要么跳过本应覆盖的内容,要么在工作流的某些节点运行测试而只增加噪音。
一、哪些层写单元测试
规范以分层表列出取舍:
- 含真实分支或编排逻辑的业务服务层:写单元测试。这里是行为真正发生变化的地方,值得用回归测试守护。
- UI 组件(Angular/React/Vue 等):不写。组件测试在实践中通常是价值最低、维护成本最高的一层;即使组件含有条件逻辑也跳过,因为该逻辑应通过它委托的服务或 store 层来验证。
- 仓储/DAO 类(薄数据库查询封装):不单独写。通过调用它们的高层服务测试来覆盖其效果,而非隔离测试。
- 薄的出站 API 客户端封装(方法体只是 await client.post(...)):不写。价值在于调用方如何使用结果,而非重新测试 HTTP 调用的形状。
- 纯映射器(行/载荷转领域类型,除 ?? 默认值外无分支):不写。正确性已由类型系统的结构检查保证,运行时断言只会复述映射。
- 其余含决策点的代码(守卫、校验、降级逻辑、错误处理分支):默认写测试。上述排除项是一份具体而短的清单,不是“觉得无聊就跳过”的通用许可。
被跳过的层必须被间接覆盖。“仓储不写单元测试”不代表仓储行为无人验证,而是验证它的测试位于上一层:在调用它的服务测试中 mock 仓储,并针对仓储返回的各种值(找到、未找到、服务会做出反应的分支)断言服务行为。
二、覆盖形状:负面场景并非可选
失败分支、校验分支、降级逻辑和集成错误处理应视为覆盖的默认组成部分,而不是有余力才补的内容。一个含 try/catch、空值检查或条件提前返回的方法,天然至少有两个测试用例,而不是一个。
对任何与异步依赖(队列、外部 API、定时器)集成的代码:只要代码中存在相应分支,就要覆盖超时、上游失败、非 Error 的拒绝,以及意外结果形状,而不只是成功路径。
三、断言风格
不要索引 mock 的调用记录(mock.calls[0][1]、mock.mock.calls[n][m])。始终使用为此设计的匹配器:最后一次或唯一一次调用用 toHaveBeenCalledWith(...),调用序列中的某次用 toHaveBeenNthCalledWith(n, ...)。索引写法既难读,又会在调用顺序变化时悄悄出错。
不要断言 logger 或 console 调用,包括 catch-and-log 分支中除日志本身外唯一可观察效果是外围调用不抛出的情况。应断言日志周围的行为:promise 仍然 resolve、返回值反映失败路径、本不该发生的兄弟写入没有发生。若某分支除日志外确实没有其他效果,那是合理的较弱回归守护,接受它,而不要用 logger mock 断言替代真实覆盖。
四、同义反复检查
保留一个测试前先问:如果把方法体替换为 return mockResult,这个测试是否仍会通过?具体禁止以下形状:
- 只断言调用返回其自身 mock 设定的返回值(repo.find.mockResolvedValue(null) → expect(await service.find()).toBeNull(),且无其他断言)。
- 只断言语言内置的强制转换(Number('99') === 99)。
- 任何删掉方法真实逻辑、硬编码预期返回值后仍不会失败的测试。
一个测试靠断言逻辑产出的东西来证明自己的价值:以正确参数发起的下游调用、依赖分支的返回形状、与特定输入相关的异常类型。
五、执行时机
这里回答两个独立问题:
- 谁写测试:为任务编写生产代码的代理/ pass 不负责编写该任务的测试。把写测试交给单独的 pass:专门的写测试代理,或同一计划中明确的后续任务。默认这不是可选项:新增或变更的行为要随工作一并获得测试覆盖,只是不由写实现的那双手编写。
- 何时运行测试:绝不在编辑后自动运行。只在用户明确要求时运行,且仅当该任务所有计划内的代码改动都已写完:不按文件运行,也不在多步计划中按任务运行。多任务计划只在最后运行一次测试,覆盖全部内容。
例外:代码审查可直接编写测试。上述“单独 pass”规则只有一个例外:审查 pass 发现行为已变、覆盖缺失,且未计划单独的写测试任务时,可以直接新增或更新测试。这是审查与写测试在同一 pass 中发生的唯一情形。
六、测试环境与 fixture 复用
环境配置(运行器支持按套件配置环境时):在混合栈仓库中,只为真正运行在浏览器中的应用配置类 DOM 环境(如 jsdom);其余(后端服务)保持普通 Node 环境。无谓混用会拖慢整个套件的启动时间,还可能在本不需要 DOM 的代码中暴露环境相关缺陷。
写新 mock 前先复用已有 fixture/mock。手写 mock 前,检查项目共享测试工具包是否已有现成的,留意附近的 create-* 命名约定。重复的临时 mock 会彼此漂移,也会与它们模拟的真实形状脱节。
七、常见错误对照
- 为组件、仓储或映射器“求完整”写 .spec:跳过,该行为的覆盖属于上一层。
- expect(fn.mock.calls[0][0]).toBe(x):改为 expect(fn).toHaveBeenCalledWith(x)。
- 在 catch-and-log 测试中 expect(logger.error).toHaveBeenCalledWith(...):改为断言周围可观察行为(返回值、不抛出地 resolve、未触发的兄弟调用)。
- 含 try/catch 或校验分支的方法只写成功路径测试:补上失败分支测试,这不是额外加分项。
- 任务中途每改一个文件就运行测试套件:等整个任务的代码写完,再按请求运行一次。
- 同一代理一次 pass 同时写功能和测试:拆成单独的写测试 pass。
- 在截止日期或评审压力下,为本规范排除的层补测试“以展示完整覆盖”:按本约定,完整覆盖意味着这些层没有 spec 文件才是正确而完整的状态,而不是缺口。