核心问题

我们怎么知道系统是对的?

Dimension 1 解决的是:

我们到底在构建什么?

Dimension 2 解决的是:

我们有什么证据证明它真的按预期工作?

这就是软件工程里的认识论。

认识论关心“知识如何成立”。映射到工程里,就是:

正确性不是一种感觉,而是一组可验证的证据。

测试不是为了证明代码没 bug

很多人对测试有一个误解:

测试是为了证明代码没有 bug。

这不可能。

测试只能证明:

在我们关心的场景里,系统行为符合某些明确承诺。

所以测试的重点不是覆盖率数字,而是系统承诺。

例如课程平台里,真正重要的承诺是:

  1. 用户支付成功后,应该获得课程访问权。
  2. 用户退款后,购买带来的访问权应该被撤销。
  3. 企业管理员只能给本组织成员分配课程。
  4. 被封禁账号不能继续访问课程。
  5. 课程下架后,普通用户不能继续购买。

这些才是测试应该固定的东西。

测试是在固定承诺

一个好测试的本质是:

把一个业务承诺变成可重复验证的证据。

比如:

it("grants course access after a successful purchase", async () => {
  const account = await createAccount()
  const course = await createCourse()

  await completePurchase({ accountId: account.id, courseId: course.id })

  await expect(canAccessCourse(account.id, course.id)).resolves.toBe(true)
})

这个测试不只是测函数。

它固定了一个承诺:

成功购买会带来课程访问权。

如果未来有人改了购买流程,导致访问权没有发放,这个测试就会阻止系统悄悄背叛承诺。

测试金字塔重新理解

常见测试分层:

  • Unit Test
  • Integration Test
  • End-to-End Test

不要把它们理解成“越下面越低级,越上面越高级”。

应该理解成:

不同测试回答不同层次的“我们怎么知道”。

Unit Test:规则是否正确

单元测试适合验证纯规则、状态流转和边界条件。

例如:

describe("canGrantCourseAccessFromPurchase", () => {
  it("returns false when purchase is refunded", () => {
    const purchase = refundedPurchase()

    expect(canGrantCourseAccessFromPurchase(purchase)).toBe(false)
  })
})

它回答的是:

这条规则本身是否正确?

适合单元测试的对象:

  • 状态机
  • 价格计算
  • 权限规则
  • 访问权判断
  • 输入校验
  • 时间窗口判断

单元测试的优势是快、稳定、定位准。

但它不能证明模块之间真的接好了。

Integration Test:边界是否协作

集成测试适合验证多个模块、数据库、队列、外部适配器之间是否正确协作。

例如:

it("creates purchase and access grant in the same payment flow", async () => {
  const payment = await createSucceededPayment()

  await handlePaymentSucceeded(payment.id)

  const purchase = await findPurchaseByPaymentId(payment.id)
  const grant = await findCourseAccessGrant(purchase.accountId, purchase.courseId)

  expect(grant.source).toBe("purchase")
})

它回答的是:

这些边界接起来以后,系统行为是否仍然正确?

适合集成测试的对象:

  • 数据库事务
  • Repository 和 Service 协作
  • Outbox 写入
  • 后台 worker
  • API handler 到数据库的路径
  • 权限规则和真实数据组合

集成测试通常比单元测试慢,但它能抓住很多真实系统错误。

End-to-End Test:用户路径是否成立

端到端测试从用户视角验证完整路径。

例如:

用户登录 -> 购买课程 -> 进入课程页 -> 可以播放第一节课。

它回答的是:

从用户角度看,这个关键流程是否真的可用?

E2E 测试适合覆盖少量高价值路径:

  • 注册/登录
  • 支付/购买
  • 核心使用流程
  • 管理后台关键操作
  • 权限边界

不要用 E2E 测试覆盖所有细节。它成本高、速度慢、容易受环境影响。

不同测试对应不同信心

可以这样理解:

测试类型主要验证优势盲区
Unit单条规则快、定位准不证明模块协作
Integration模块和边界更接近真实系统较慢、 setup 更重
E2E用户路径最接近用户体验慢、脆、定位难

所以成熟测试策略不是“多写某一种测试”,而是:

用最低成本的测试,获得足够强的信心。

测试应该围绕风险,而不是围绕代码行

不要问:

这个文件覆盖率多少?

先问:

这里坏了会造成什么后果?

高风险区域应该优先测试:

  1. 钱:支付、退款、订阅、发票。
  2. 权限:谁能看、谁能改、谁能删。
  3. 数据:迁移、删除、状态流转。
  4. 安全:登录、鉴权、输入校验。
  5. 核心体验:用户最关键的路径。

低风险、低变化、低价值的代码,不值得追求形式上的高覆盖率。

测试资源应该向风险倾斜。

好测试的特征

1. 测行为,不测实现细节

坏测试:

expect(accessService.buildGrantPayload).toHaveBeenCalled()

好测试:

expect(await canAccessCourse(accountId, courseId)).toBe(true)

前者锁死内部实现,后者固定外部承诺。

2. 名字表达业务承诺

坏名字:

it("works")
it("handles user")
it("test access")

好名字:

it("revokes purchase-based course access after refund")
it("prevents organization managers from assigning courses outside their organization")

测试名应该像一条规格说明。

3. 失败时能告诉你什么坏了

如果测试失败后你不知道系统背叛了哪个承诺,这个测试价值就很低。

4. 不依赖无关细节

测试越依赖无关实现,重构越痛苦。

好测试应该保护行为,同时允许内部结构演化。

测试也会成为技术债

测试不是越多越好。

坏测试会拖慢系统演化。

常见测试债:

  1. 大量 mock 内部实现。
  2. 测试名字含糊。
  3. setup 过重,没人敢改。
  4. E2E 覆盖太细,频繁 flaky。
  5. 测试固定了错误抽象。
  6. 断言太弱,只检查“不报错”。

测试债的本质是:

测试没有固定真实承诺,只固定了当前实现形状。

测试先于重构

当你要重构一个边界混乱的系统时,先不要急着改结构。

先补 characterization tests。

也就是:

先记录系统现在实际怎么表现。

例如重构 canAccessCourse 前,先写测试覆盖:

  • 购买后可访问
  • 退款后不可访问
  • 有效订阅可访问
  • 订阅过期不可访问
  • 企业分配可访问
  • 账号封禁后不可访问

这些测试不一定证明旧行为都是对的,但它们能防止你在迁移时无意改变行为。

小结

  1. Dimension 2 的核心问题是“我们怎么知道系统是对的”。
  2. 测试不是证明没有 bug,而是固定系统承诺。
  3. Unit Test 验证规则,Integration Test 验证边界协作,E2E Test 验证用户路径。
  4. 测试应该围绕风险,而不是围绕代码行。
  5. 好测试测行为,不测实现细节。
  6. 测试名应该像业务规格说明。
  7. 测试也会成为技术债,尤其是固定实现细节的测试。
  8. 重构前先补 characterization tests,保护已有行为。