我这人吧,技术大牛是不可能技术大牛的,这辈子都不可能技术大牛的。
但是,就算现在远离代码一线 6 年了,谁要说代码写得过我,那他一定有好几把刷子。
没错,你猜对了,我就是写「低技术含量」的业务代码的。如果你自认是行业前百分之十的人才,专门给程序员写框架写工具,不可能碰业务代码,那不用往下看了。
否则,不妨听听,一个写业务代码的老黄牛,讲讲 TDD 带给我的野百合的春天。
我朋友圈说「我要不是会 TDD,薪资只有现在的 70%」,实在是太小瞧 TDD 了。其实说 50% 都不夸张,为啥呢?
因为 TDD 对我的设计能力,工程能力,业务能力,和技术领导力,都有着潜移默化而深远的影响。
我将用几篇文章来简述一下我与 TDD 不得不说的故事。
这篇先讲设计能力。说说在 TDD 的路上,碰到的那些问题,以及寻求这些问题的答案的过程中,需要修炼的那些设计能力。
01问题一:被测代码依赖太多。
举个例子:
我想测 A,可是 A 依赖于 B,B 依赖于 C,C 还是个第三方的类。怎么搞?
这边需要考虑的问题有:
把 B 抽一个接口,让 A 依赖于 InterfaceB?还是把 C 抽一个接口,让 B 依赖于 InterfaceC?(再复杂点,如果调用链有 ABCDE 呢?怎么决定在哪里抽接口?)
测试 A 时要不要知道 B 这个自己写的类的逻辑?那 C 这个外部类的逻辑呢?
如果 B 是有返回值的,那么 A 是可以感知到的;但如果 C 没有返回值的,它只是执行了一个方法(比如写了一条 log 到文件里),测试 A 时如何知道它是否被执行了呢?
为此,我必须要研究测试的两种风格。
测试类的状态。不管 A 调用了谁,我只测 A 执行之后的状态(A 的各属性的值)是什么。
测试类的行为。我测 A 最终必须调用 C 一次。
然后我就要深入研究 Stub 和 Mock 的区别了,比如,其中一种定义是:
Stub:提供依赖的输入。
Mock:验证依赖的输出。
还得思索
为啥大牛都说要慎用 Mock?
「单元测试」里「单元」如何界定?
02 问题二:被测代码太长,逻辑太多
举个例子:
测试 A 类的 Foo()方法,它有 300 行。前 n 行的业务规则一有 5 个场景,中间的业务规则二有 4 个场景,结尾处的业务规则三有 3个场景。三种业务规则的所有场景的排列组合就有 5 * 4 * 3 = 60 个。难道我要写 60 个用例?
咱们一定要记住一个铁律:我们碰到的任何问题,都不是新鲜的问题,前人早就碰到并且给出方案了。
显然,爱好看经典书籍的我,很容易就从这本书里找到了答案。
答案就是 SOLID 里的 Single Responsibility Principle。
上面的例子里,把 3 个业务规则分别写到 3 个类里,只要 5 + 4 + 3 个用例就测全了,可能再加 1 个用例把 3 个规则串起来测一下,over。
当然,不是看到字面就能轻易理解「单一职责」里「职责」的意思的。Bob 大叔说:「职责就是变化的原因。」可谓切中要害。
但我见过不少技术高手,因为没有经过 TDD 的洗礼,对 SOLID 也是一知半解的。
这就是为什么十几年后的今天,我仍在每天夜里祈祷:赐我一个精通 Stub/Mock 和 SOLID 的程序员吧,这要求高吗?太高了!
03 问题三:贫血模型,业务逻辑要么在 SQL,要么在 UI。
我首先思考的是,什么是最值得写单元测试的?UI 都是各种框架,Database 也都是各种框架,只有业务逻辑是没有框架,老子一个字符一个字符码出来的,不测它测谁?
PEAA 这本书里面对此问题的答案是「分层」和「领域模型」。
基本的分层是这样的:
应用层:负责应用逻辑。
业务层:负责业务逻辑。
数据访问层:负责存储逻辑。
不要觉得这个很简单,太多的代码都分不清这三种逻辑。比如,考考自己:启动事务属于什么逻辑?
TDD 在现实项目中落地的最大障碍之一,就是没有一个丰满独立的领域模型所构建的业务层,导致要么不好测,要么测的收益不大。
关于领域模型,9 年前,我在博客园写的《为什么要让我们的“领域模型”裸奔?》上下两篇,算是一个总结。(有兴趣的搜索「领域模型+裸奔」即可。)
至此,写代码对我来说无非就两件事:
这行代码该写在哪个类/方法里?(单一职责原则)
这个类该属于哪一层?(分层)
能把这两件事搞清 sang 的朋友,不要怀疑,您已经是高手了。
04 问题四:领域模型与其他层的耦合太高。
十几年前,很多团队所谓的「单元测试」是连接数据库(或文件系统,第三方 API 等外部依赖)的,这样的结果就是跑一次单元测试要几个小时。
但是没关系,那时候的单元测试也就是要发布的时候跑一下,而发布的周期也是以月计的。
所以贫血模型也好,与数据库的耦合也好,也很常见。
你看,堕落是全方位的。
有追求的我当然不甘堕落。我努力把业务逻辑写到领域模型里后,面临的下一个问题自然是「领域模型」和「数据访问层」的解耦。
这时候我要是不找到 DDD 这本书,那就不是我了。
Repository 和 IOC 相得益彰,轻松化解这个耦合问题。
再下来,说到 IOC 容器,一个有意思的小话题就出现了:每次 Resolve 某对象时,你希望容器每次都新建一个实例?还是希望容器有个单例,每次都把这个单例给你?
在大多数情况下,应该是 Transient,也就是每次创建一个新实例,这是为了保持「无状态」的设计。
可是 ORM 里面的 UnitOfWork 呢?你得希望它在一次 HTTP Request 里,只有一个实例。
这就是容器的生命周期管理。每个细节钻进去,都有很多东西可以学。而每个细节加起来,就是让一个程序员成为不一样的程序员。
比如,对 Bounded Context 的理解,会让几年后的我,面对「微服务」的兴起,会心一笑。
而Aggregate 的概念,让我回忆到以前对「单元」的纠结时,也感到了释怀。
05 问题五:面对祖传代码,如何心态不崩?
这个行业残酷的事实之一就是,20 年前一个写代码写到秃的某程序员,留下来的代码,20 年后我们还得维护到秃,这就叫祖传代码。
有些代码甚至不知道它的逻辑了,也不知道它为啥要那么写了,可是还得在上面加功能。
这时候为了保留自己茂密的秀发,不得来一本《重构》吗?
重构的意义在于:我不需要了解原有代码是如何工作的,仍然可以把它变得更可读,还能为它加上自动化测试。
就算是结合上面的问题,逻辑分散在好几层,也有办法先用「端对端」的自动化测试加上一个保护层,再对里面的代码进行重构。
进一步地,我习惯了使用「童子军法则」:每次提交代码前,都会顺手做一点重构,让它比我签出代码时更干净了一点点。
重构给了我驾驭烂代码的快感,替代了被烂代码驾驭的憋屈。
06 问题六:如何避免制造祖传代码?
随着软件吞噬世界的加快,已经不能用 20 年来定义祖传代码了,很可能昨天写的代码,今天就变成祖传代码。点解?因为它没有测试啊!
「所谓祖传代码,就是没有测试的代码。」
有没有听过,有一种叫 GOOS 的方法?GOOS 就是这本书的缩写,这本书描述了用「验收测试」和「单元测试」驱动开发的过程。
老实讲我只看了这本书的开头,看得我于我心有戚戚焉。因为我刚在一个项目里成功实践了 ATDD 和 TDD。
而这个项目有个后续的高光故事。
若干年后,公司来了两位日薪 2 万的澳洲咨询师,汤姆和杰瑞。
有一天,这两人突然冲到我的面前,满脸兴奋的红光,语速很快地呱唧呱唧,我以为我摊上什么大事了。
听完才知道,这两位翻出了我那个用 ATDD 和 TDD 驱动的代码,激动地跟挖到了宝一样,说代码就特么应该这么写!
算起来,这也算是我小小滴为国争光了一次?
07 做个设计师
台湾人民把程序员叫做「设计师」,深得要义。
其实上面碰到的问题,只要你不写或硬写单元测试,都可以不是问题,码农后浪接前浪,混混得了。
但是当我要 TDD 时,那些问题都变成了必须解决的问题。我要深入理解和掌握,以下但不限于以下列表:
- Mock 和 Stub
- SOLID
- Domain Model
- 依赖反转和依赖注入
- DDD
- 重构
- 测试金字塔
这就是为什么TDD 是一种设计,不是测试。把单元测试当测试,属于还没入门。
可以说,给我加薪的也许不是 TDD 本身,而是 TDD 所需要和促进的设计能力。
也许你要反驳了:我不用 TDD 照样有这些设计能力啊。
是吗?敢不敢用 TDD 表演个后空翻,检验一下?
下一篇预告:我不是码农,我是「软件工程师」。
文档信息
- 本文作者:蔡建斌
- 本文链接:https://johncai.github.io/2020/06/08/TDD-as-design/
- 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)