测试:可测试性即上下文成本
可测试性,就是获取验证行为所需上下文的成本。本章通过验证上下文(入口、状态、依赖、副作用与观测)把测试与 CMP 联系起来,并说明 TDD 为什么能尽早施加可测试性压力。
本文为中文版,根据英文原版改写,而非逐句直译。
工程师很早就注意到:难测试的代码,往往也难维护。人们通常把它当作测试层面的实际问题——依赖太多、隐藏状态太多、副作用太多。这些观察没有错,但它们只是症状,背后还有更深的结构。
测试一个行为,需要上下文。
要写出有意义的测试,开发者必须知道:怎样触发这个行为,需要预先存在什么状态,哪些依赖会影响结果,哪些副作用必须控制,以及什么样的可观测结果能证明行为正确。如果这些上下文小、显式、容易控制,行为就容易测试;如果它们庞大、隐蔽或不稳定,行为就难以测试。
由此,可测试性(testability)在 CMP 中有了直接的解释:
可测试性,就是获取验证行为所需上下文的成本。
这正是测试与上下文最小化原则(Context Minimization Principle,CMP)之间的主要联系。测试本身是修改的安全网;可测试性则揭示了设计是否把行为塑造成了一种验证上下文容易获取的形态。这也解释了工程师为何长期把可测试性视为设计信号:易于测试的设计往往也易于修改,原因相同——相关行为可以通过有界的上下文被触达、控制和观测。
测试需要验证上下文
测试有时被简单描述为”检查代码能不能跑”。但有意义的测试远不止执行代码,它需要足够的上下文来验证行为。
这种上下文由几个反复出现的部分构成。
入口上下文:触达行为的成本。能否通过清晰的函数、服务、API 或组件边界触发这个行为?还是必须把整个应用跑起来,行为才会出现?
状态上下文:准备行为运行情境的成本。所需状态能否在本地构造?还是依赖隐藏的全局状态、陈旧的数据库记录、后台任务或环境配置?
依赖上下文:控制协作者的成本。依赖能否显式传入、替换或隔离?还是行为直接伸手去调用基础设施、时钟、网络、文件系统、队列或外部服务?
副作用上下文:隔离副作用的成本。行为产生的是集中的结果,还是把后果散落到日志、缓存、事件、数据库写入、后台任务和远程调用里?
观测上下文:判断行为是否正确的成本。有没有稳定的输出、状态迁移、事件或 API 响应可供断言?还是唯一可见的证据只是一串内部调用顺序?
这些不是测试细节,而是测试暴露出来的设计事实。可测试的设计让这些上下文容易获取;不可测试的设计则让写测试的人在触达被测行为之前,就得先收集大量外围上下文。
可测试性是一种设计信号
这种设计信号在局部可测试性上体现得最清楚:也就是在局部边界上验证一个单元、函数、组件或服务行为的能力。
局部行为通常应当有局部的验证路径。一条小规则不应需要启动整个应用才能触达;一个服务行为不应需要无关的准备工作才能测试;一个组件应当暴露可以断言的结果,而不必检查它的私有步骤。
由此可以给局部可测试性定一个简单标准:
如果局部行为能在局部以足够的信心被验证,设计的局部可测试性就更高。
清晰的入口、可构造的状态、显式的依赖、有限的副作用和可观测的结果,都会降低局部测试的上下文成本。它们同时说明,行为已经被塑造进一个更容易理解和修改的边界之中。
局部可测试性差,也能解释许多糟糕的测试。当有意义的行为难以触发、控制或观测时,覆盖率压力会把开发者推向廉价的替代品:断言私有辅助函数的调用、mock 一长串协作者、冻结巨大的快照、测试无关紧要的分支。这些测试能拉高覆盖率数字,却建立不起可信的安全网。它们是验证上下文成本过高的症状。
因此,可测试性是 CMP 在日常工作中最清晰的信号之一。易于测试的代码不一定设计良好;但如果普通应用逻辑一直难以测试,往往意味着设计让验证上下文超出了行为本身所需。同样多余的上下文,通常也会让未来的修改更加困难。
TDD:提前施加的可测试性压力
TDD 常被当作一种测试技巧来介绍:先写一个失败的测试,让它通过,再重构。它更深层的设计作用在于:在实现上的便利占据主导之前,先把验证上下文的成本暴露出来。
如果测试在实现之后才写,实现已经摆在那里:私有辅助函数、分支结构、数据流、协作者以及各种偶然细节都已成形。测试往往会顺着这个形状来写,结果确认的是当前实现怎样工作,而不是哪些行为应当保持稳定。
TDD 把顺序倒了过来。在实现存在之前,开发者必须先回答:
- 应该触发什么行为?
- 需要什么状态?
- 必须控制哪个依赖?
- 应该能观测到什么结果?
- 出现什么失败能证明行为是错的?
这些都是关于验证上下文的问题。如果答案很难给出,设计压力就会提前出现:开发者需要更好的入口、更简单的准备工作、更显式的依赖、更少的隐藏副作用,或者更清晰的可观测结果。TDD 让这些需求在实现固化成不易测试的形状之前就显现出来。
这就是 TDD 常常能改善设计的原因。不是因为先写的测试有什么魔力,而是因为代码必须从行为的外部向内设计。实现要服务于一个可测试的契约,而不是逼着测试去迁就偶然形成的实现结构。
对 AI 编程智能体来说,这种压力尤其有用。智能体可以很快写出一个看似合理的补丁,然后生成只是顺着刚写好的代码走的测试。测试先行的工作流改变了这个顺序:在修改实现之前,智能体必须先说明要验证什么行为、什么可观测结果能证明它。这本身并不能让智能体变得可靠,但它减少了智能体所写测试的一种常见失败模式:测试验证的是实现,而不是行为。
用 CMP 的话说,TDD 不是一条新的设计原则,而是一种把验证上下文成本往低处推的早期压力。
测试:从侧面观察 CMP
工程师早已把可测试性当作衡量设计质量的实用信号。CMP 解释了这个信号为何可靠:要测试一个行为,就必须获取触发、控制和观测它所需的上下文。这个成本低时,行为往往具有清晰的边界、显式的依赖、局部的状态、稳定的契约和可观测的效果——而这些恰恰是让修改更容易的设计品质。
所以结论很简单:
可测试性不只关乎测试,它是上下文成本的一种可观测形式。
要测试行为,就必须获取触发、控制和观测它所需的上下文。这些上下文代价高昂时,测试会变得脆弱、充斥 mock、流于表面,或者干脆被跳过;代价低廉时,同一个行为通常也更容易理解和修改。
这就是 CMP 对测试的解读:测试提供安全网,可测试性揭示搭建这张安全网的上下文成本,而 TDD 把这种压力提前到足以影响设计的时刻。