核心问题
我们怎么知道系统是对的?
Dimension 1 解决的是:
我们到底在构建什么?
Dimension 2 解决的是:
我们有什么证据证明它真的按预期工作?
这就是软件工程里的认识论。
认识论关心“知识如何成立”。映射到工程里,就是:
正确性不是一种感觉,而是一组可验证的证据。
测试不是为了证明代码没 bug
很多人对测试有一个误解:
测试是为了证明代码没有 bug。
这不可能。
测试只能证明:
在我们关心的场景里,系统行为符合某些明确承诺。
所以测试的重点不是覆盖率数字,而是系统承诺。
例如课程平台里,真正重要的承诺是:
- 用户支付成功后,应该获得课程访问权。
- 用户退款后,购买带来的访问权应该被撤销。
- 企业管理员只能给本组织成员分配课程。
- 被封禁账号不能继续访问课程。
- 课程下架后,普通用户不能继续购买。
这些才是测试应该固定的东西。
测试是在固定承诺
一个好测试的本质是:
把一个业务承诺变成可重复验证的证据。
比如:
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. 测行为,不测实现细节
坏测试:
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. 不依赖无关细节
测试越依赖无关实现,重构越痛苦。
好测试应该保护行为,同时允许内部结构演化。
测试也会成为技术债
测试不是越多越好。
坏测试会拖慢系统演化。
常见测试债:
- 大量 mock 内部实现。
- 测试名字含糊。
- setup 过重,没人敢改。
- E2E 覆盖太细,频繁 flaky。
- 测试固定了错误抽象。
- 断言太弱,只检查“不报错”。
测试债的本质是:
测试没有固定真实承诺,只固定了当前实现形状。
测试先于重构
当你要重构一个边界混乱的系统时,先不要急着改结构。
先补 characterization tests。
也就是:
先记录系统现在实际怎么表现。
例如重构 canAccessCourse 前,先写测试覆盖:
- 购买后可访问
- 退款后不可访问
- 有效订阅可访问
- 订阅过期不可访问
- 企业分配可访问
- 账号封禁后不可访问
这些测试不一定证明旧行为都是对的,但它们能防止你在迁移时无意改变行为。
小结
- Dimension 2 的核心问题是“我们怎么知道系统是对的”。
- 测试不是证明没有 bug,而是固定系统承诺。
- Unit Test 验证规则,Integration Test 验证边界协作,E2E Test 验证用户路径。
- 测试应该围绕风险,而不是围绕代码行。
- 好测试测行为,不测实现细节。
- 测试名应该像业务规格说明。
- 测试也会成为技术债,尤其是固定实现细节的测试。
- 重构前先补 characterization tests,保护已有行为。