聊聊单元测试

过去的一个星期里,一直在做一些重构工作(因为这部分代码年久失修导致出问题的时候排查非常困难),因为关联的业务逻辑比较多,加上业务流程太长等原因,费了好大的劲才把其中一部分逻辑从原来散落在各个 service 里面抽离出来。前两天把这部分代码重构完之后,一开始想着自己测试一遍,没问题就交给测试测,这是相对容易的一种方式。但其实重构后的代码还是有不少的问题,只不过没有原来那么大的问题,所以内心隐隐觉得后面如果这部分代码出 bug 还是会不太好修。

为什么写单元测试?

重构完这一部分代码之后,想着就按 plan A 来测试吧,然后就跑去看了下《聊聊架构》,看到了里面第20章的单元测试之后,对于单元测试多年以来的一些困惑得到了答案。比如里面讲到的“单元测试测什么”,很简单的一句话,就是测软件工程师自己写的逻辑,对于服务代码、存储代码、别人写的代码是不需要测试的(这里的意思是在测自己的逻辑的时候,如果需要调用外部的方法,我们不需要再去验证这个外部调用是否正确,因为这一步验证是应该放在那个外部方法对应的单元测试里面实现,这也是书里一直强调的“权责对等”的问题)。

其实一开始写现在重构的这部分代码的时候,就已经尝试过写“单元测试”,这里的单元测试是带有引号的,因为以前觉得那是的单元测试,但实际上那叫集成测试,那时候写的是对接口的测试,就是通过模拟请求,测试请求的结果准确性。这样做的问题是,先要生成模拟数据,但是这部分代码需要的关联实在是太多了,导致写完生成模拟数据的代码就已经花了很多时间了,而且这样有个问题是,对数据库强依赖,实在是难以处理,所以那时候的测试只写了一个版本,后续就没有维护了。毕竟实在是没法维护,依赖太多,耦合太多,流程太长,要做到响应需求的变化非常的困难。

看完这书之后,加上前段时间看了《重构》,所以想着再尝试一下写写单元测试。毕竟《重构》里面提到“重构的第一块基石是自测试代码”(里面很多地方都提到这一点,而且里面介绍的重构步骤也频繁提到“重构->测试”这种模式),也就是说,如果你要对代码进行重构,你的代码首先要有对应的测试代码,这样才能保证你的每一步重构的操作不会对原有代码造成破坏,这样的重构才是真正的重构。

另外一个原因是,用在代码维护的时间往往会比开发时间多很多,如果有了单元测试,可以在后续的维护过程中放心地做一些修改,因为单元测试会告诉你你的修改有没有影响到原有的代码,这也就省下了很多做回归测试的时间。这样可以释放一部分脑容量去思考一些更值得花时间思考的东西,同时我们只需要专注于新的代码,过去的代码不再对我们的思想造成负担。正如《聊聊架构》里面说为什么要做单元测试:“写的代码越多,不安全感累积会越多,最后会发觉自己对自己所写的代码完全没有把握,这是非常影响生活质量的”。

写单元测试过程发现的一个问题

聊完为什么写单元测试之后,再聊聊这几天在单元测试上的一些实践及从中得到的一些启发吧。重构完之后,得到的代码总感觉有点不太完善,但是就是说不出来哪里不对。暂时先不管吧,先去写单元测试代码了。

写单元测试的过程中,重构之后的那一部分代码的问题开始暴露出来了,比如,其中包含了不少对依赖类的属性的使用,大概格式如下面这种:

1
$obj->a->b->c

又或者像下面这种,对依赖对象里面属性下方法的调用:

1
$obj->a->b()

这样有什么问题呢?一眼看上去好像没有啥问题。但是在我写单元测试的时候发现一个问题,对于 $obj->a->b->c 这种代码,想给它们写测试的话,必须先进行一些复杂的 mock 操作,但关键是,a 里面的 b 及 b 里面的 c 属性都不是我当前类关注的内容,现在却要我去 mock 依赖对象的内部实现细节。而且如果我另一个类也需要获取 c 这个属性的时候,给这个新的类写单元测试又要 mock 一堆对象,这明显就会导致产生重复的单元测试操作。如果多个类都需要用到这个 c 属性,那样岂不是会产生一大堆重复的代码。

现在来让我们概括一下,这里的问题就是我需要了解依赖内部才能用好这个依赖的对象。我本来只是想使用依赖的对象,但上面这一种写法却需要我们去了解依赖对象内部的结构,因为对于我来说,$obj 是跟我直接交互的对象,你这个对象里面的细节我并不关心。也就是说, 上面这种写法违背了面向对象封装的特性,主要影响就是,对象的细节实现散落在对象以外的地方,也就不是所谓高内聚的代码了。如果后续这个对象的细节需要改动的时候,就需要去找到所有用到了这个细节的地方去修改,改动的地方可能会非常多。另一方面,如果原来的代码缺乏单元测试,改动越多,改动带来的风险就越大,这些潜在的风险往往会给我们带来一些心智上的负担。可能也会在上线之后不定期给我们带来惊喜。

也许可以用另外一个例子说明这个问题。我们去银行存钱的时候,跟我们交互的是银行的工作人员,具体怎么存我们不需要去干涉(我们也干涉不了),我们只需要告诉他们我要存钱然后将我们的钱和银行卡给工作人员,他们会帮我们完成具体的存钱操作(而不需要我们去告诉工作人员第一步怎么做,第二步怎么做,因为具体怎么操作是工作人员的职责范围内的事,我们不能去干涉)。其实归根到底还是 SOLID 里面的 SRP(单一职责原则)。

那该如果应对这种问题呢?目前我的做法是,将这些对依赖内部细节的实现挪动到对象本身里去,给它们定义对应的方法(它们配得上一个独立的方法)。这也是《聊聊架构》里面多次强调的权责对等,细节的东西本来属于你,现在只是把这一部分职责归还给了你。这样的结果就是,我们想了解的所有细节,都可以在一个地方找到,就是我们依赖的那个对象里面。对于外部来说,能看到的只是那个对象提供的功能(public 方法),但是这就已经足够了。

所以,单元测试一方面可以保证我们代码的可靠程度,同时在写测试的时候你会发现你代码设计得不好的地方。有个有趣的评判标准是,编写单元测试的难度与代码质量成反比。最近真的是深有体会。

单元测试之后

准确来讲,这个操作是在我写单元测试的过程中完成的,多亏我们的运维同学,给 gitlab 加上了 runner,让我得以实现在 ci 里面加上单元测试的操作(同时之前 build 的 docker 镜像又一次可以派上用场了)。这样一来,好像开始有那味了,多年以前的一些想法得以实现了。同时,看着 gitlab ci 的 job 都 passed 蛮有意思的,哈哈。

经历了重构、写单元测试、再重构的过程之后,感觉单元测试这件事也许没那么难,如果觉得单元测试写不出来,很可能是因为很多代码出现在了它们不该出现的地方。用专业一点的话来说就是,设计上有缺陷,对于这个目前也还不是太了解。不过有一条很基本的原则,可以做到的话,可能会给我们带来极大的好处,上面也有提到,就是 SRP。个人感觉这可能是所有软件设计的最基本的原则吧。

对个人而言,关于单元测试的很多困惑、认识上的误区已经解决了,接下来其实问题没那么多了,该写单元测试的时候可以写一写了,可以更好地保障一下代码质量了(但是对于生活,依然有很多困惑,诶)。

关于《聊聊架构》这本书

这本书之前听都没听过,在同事吹爆了之后,还是去看了,感觉还是有其独特的价值(可能对于很多不同角色的人都能在书里得到一些启发)。里面一些观点的角度都挺独特的,如果没有很深刻的经验、经历等想不到那些点,又或者可能是因为我读的书少吧。总之,可以一翻。

最后,分享一下最近看的其他书

《代码整洁之道》,这本书其实应该早点看的,操作性不强,但是看完之后会让你看到自己写的代码里面很多不太好的地方,也就是所谓的坏味道(code smells)。比如,之前总是强迫性地给每个方法、属性加上注释,读完之后发现很多注释是没有必要的,一方面可以通过好的命名解决,另一方面代码本身足够简单到不需要注释。

《重构》,Martin Fowler 的经典之作,看的是第二版,实操性很强,在我们需要重构的时候可以当做工具类的书籍来翻阅(不过目前他们有提供在线网站,上面也列出了所有的重构方法,可以直接到他们网站上看)。如果说《代码整洁之道》只是列出 code smells 的话,那这本《重构》就是教你怎么去除这些 code smells。同样可以在本书里发现现有代码写法上的缺陷,因为里面每一种重构的方法都给出了重构的动机,也就是为什么要用那种重构方法。

《编程的逻辑》,没有前面两本经典,但是里面讲到的从需求到最终实现的一些实践方法值得尝试一下。跟软件设计有点关系,但关注的是整一个流程,所以每一个点都不会太深入。

总结

总结一下,聊了些什么东西。
1、代码维护的时间远远大于开发的时间,单元测试的存在可以让我们放心地对代码进行一些修改。
2、单元测试一方面可以保证我们代码的可靠程度,同时在写测试的时候你会发现你代码设计得不好的地方。

其他,关于单元测试的一些认识误区,可以去《聊聊架构》里面看看单元测试这一章。

后记

其实一个多星期之前就想写一下看完《重构》之后的一些想法,但是想着虽然看完了,但是还没有实践过,似乎不是太好。现在好歹也真正地实践过了,可以写一写了,但是之前关于重构的一些想法好像没有了。没有就没有了吧,也许这就是生活吧,有些东西失去了就再也找不回来了。

另外,关于写代码这件事,真的需要很多考量的地方,需要抛却很多主观上的想法,然后对自己写的代码作出客观的评价,而这可能不是一件容易的事。不过是一件值得尝试的事。