5.3 可测试性设计:让代码天生好测


本节摘要:很多测试难写,根子不在测试而在代码本身不让你随便测。本节盘点"难测三件套"——硬编码依赖、静态调用、时间魔数——并给出一套让代码天生的好测的改造手法,因为它才是第 2 章依赖注入的地基。

测不动,先别怪测试框架

遇到"这测试怎么写都好难缠",多数人第一反应是换框架、上重型 Mock。可冷静想,难写往往是因为被测代码长了"难测体质":依赖写死在 new 里、调了静态方法、用了真实的当前时间。这些让测试被迫去追着外部世界跑。与其在测试端补刀,不如回源码端调整"体质"——这就是可测试性设计。它不是单独的技巧,而是第 2.2 节依赖注入在"设计阶段"的提前布局。

难测三件套:识别与破解

难测一:硬编码依赖。函数内部直接 new 数据库连接、文件读写、或直接调用某个服务。破解:把依赖改为可注入的参数或构造项(靠第 2.2 节的手法)。

# 难测:内部写死了数据库 def report_total(db): conn = create_db_conn() # 写死 ... # 好测:端口注入进来 def report_total(conn): ... # 生产传真,测试传替身

难测二:直接调静态方法或全局单例。静态方法难以替换,全局单例难以复位。破解:把静态调用抽成依赖端口,把单例显式作为注入参数传递,别让它们藏在地下四处乱钩。

难测三:时间魔数。逻辑里到处用 now() 拿真实当前时间,导致"过期判断"这类测试随一天里的时钟漂移而忽绿忽红。破解:注入一个"时钟"端口,测试里能播到任意时刻。

def is_expired(entity, clock): # clock 测试可拨 return entity.deadline < clock.now()

一张让代码好测的改造速查

症状 根因 改造动作
new 外部对象 硬编码依赖 改为构造注入或参数注入
调 static/单例 隐藏的全局依赖 抽成端口、显式传参
用真实 now 时间魔数 注入时钟接口
单次通话后才初始化 副作用时机难控 拆成无副作用纯函数 + 边界动作

下面把三件套和改造方向画成一张图。

05-03-fig01

可测试性与设计质量是同一件事

值得强调的是,可测试性不是测试圈的奢侈品,它与好设计是同一件事的两面相。既然"难测"的根子常是耦合和隐藏状态,那么"让它好测"的过程,往往就是解耦、显式化、控制副作用的过程——这些本身就是更高明的设计。反过来,那些死活测不动的代码,几乎都可以用"耦合过重、依赖藏得深"来总结。所以把"能不能很容易地测"写进设计评审的口径,能顺带把代码质量提上去。

一个完整改造示例

背景:库存扣减函数里直接调用了静态的日志网关和真实的系统时间。

操作:把日志网关和时钟都改成构造注入,测试里分别塞一个记录型替身和一个固定时钟。

结果:原本"一跑就碰它俩"的测试变成纯内存、可复跑、毫秒完成。

解读:改动只是把依赖从"内部够得到"移到"门外递进来",收益却是一次性把一条测试从"没有"变成"恒绿"。变式:团队进一步约定"核心业务对象只依赖接口端口",使新增接入任何外部服务都能沿同一条注入路径,测试扩张成本骤降。

别把"注入"玩成另一种病

可测试性设计把依赖从内部挪到门外,方向是对的,但过头同样会出事——这就是"过度注入"。一种表现是给根本没有外部依赖的纯函数也硬塞一堆参数,美其名曰"注入",结果是参数爆炸、调用处写满哑元;另一种是让所有对象都从构造函数里挤满依赖,测试写起来"注入十几个替身"比原来还累。这跟第 2.2 里提到的"为测一个函数写十几个替身往往不是单元测试难写"是一个道理。

分寸在哪里?一句话:只把"外部且需要替换"的依赖当成注入点,纯内存的能力就别喂参数。真正值钱的注入,是针对那些"测试时想换掉、生产时想传真"的接缝,而不是样本肝目击地把每个方法都改造成可注入。过度注入的可测试性不是资产,而是下一处难读难改的负担。

把"好不好测"写进设计评审

想让可测试性不在理论上空转,就要给它进设计评审的入场券。评审新接口时多问一句:如果我们想测它,写起来是什么手感?接到这个问题的契约,几乎都会自动往"依赖显式、副作用靠边、时间可拨"上靠——因为它被动地逼设计者把外部世界和纯逻辑分开。与其事后写一堆难缠的测试,不如在设计阶段就把"好不好测"列为验收红线之一,成本低得多。

落到做法上,有三条可立即生效:核心业务函数在命名时就标注"纯/非纯",让测试一眼知道该往哪层放;接外部 I/O 的封装收敛到有限的"端口"类里,别散落在业务里到处 new;在 CI 里给"每个新函数的可测性"加一个轻引导(如要求新签契约标注其纯核位置)。这三条手工也能做,不一定上工具。

善用"端口",给它起个明白的名字

可测试性设计经常差最后一步:依赖是注入了,却散落在构造函数参数里,看代码的人得一路翻才知道"这个类到底需要什么外部世界"。更清爽的收尾是把所有可替换的外部依赖,收敛到一个带明显命名的端口(port)——比如一个 Clock 端口、一个 LedgerRepo 端口——接口上写清它提供什么能力,实现类在测试替换时一目了然。这样注入带来的解耦不仅存在于筋骨里,还从命名上就对外可见,新同事看第一眼就懂"想测它,就往这个端口塞替身"。

命名稍用心,收益是持续的:测试里写 FakeClock().at("2026-01-01"),比 StubPort5() 强一百倍,因为前者把"这个替身是什么、用于什么"直接讲清了。可测试性不止是能不能测,更是让人愿意测——一个好命名的端口,往往就是"让人愿意测"的那个把手。

重构旧代码时顺手拉回可测试性

说了半天可测试性设计,多数是给新代码的。老代码积重难返怎么办?不必专门停几天去修,而是聪明地在正常重构里顺手"捡"回可测试性:每次因为业务变化不得不改某处时,就顺带把那个函数的依赖抽到注入口、把散落的静态调用挪到端口。改动不改就不碰、一碰就顺手整理,成本摊在每一次改动里,比一次性大扫除稳妥得多——这正是第 1.1 节"随改动补测、成本摊日常"的同一个道理。

守住一条心态:可测试性不是某个阶段的一锤子买卖,而是随代码一起呼吸的长期习惯。每次重构多收一点点,几个月后回头看,原本绕成一团的类会明显变得可注入、可拨钟、可替换,而你不必为此专门顶住一次"重构大停工"。

本节要点回顾

  • 难在体质不在框架:测不动先查是否难测三件套。
  • 三件套:硬编码 new、静态/单例、时间魔数。
  • 破解三招:注入端口、显式传参、注入时钟。
  • 即好设计:可测试性≈解耦 + 显式 + 副作用可控。
  • 端口要好命名:收敛外部依赖到明显端口,替身一眼可换。

下一节我们把这种"让代码天生好测"的改造,接到第 5.4 节 CI/CD 里——只有代码本身可控了,灯才有资格说"自动"。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U