浅谈 TDD 测试驱动开发思想

1,039 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第2天,点击查看活动详情

开篇

随着系统复杂性的增加,工程的结构,可维护性,可读性都会受到极大的挑战。如果不能把控好整体业务架构和代码的关系,很容易出现一坨代码,没人知道当初怎么写的这么复杂,这么乱,大家都看不起,但没人敢动。除非必要,尽量不要重构。改错了要担责,改对了也没有什么明确的收益,而且往往很难去拆,因为做需求的人都走了。

TDD 这一套思想并不能完全解决上面提到的困境,但是能很大程度上让你养成一个好习惯,尽量去规避这些坑。

TDD 三原则定义

这里我们还是参考 Uncle Bob 的经典三标准:

  • Write production code only to pass a failing unit test.
  • Write no more of a unit test than sufficient to fail (compilation failures are failures).
  • Write no more production code than necessary to pass the one failing unit test.

很有意思,三个点全都在强调的一个点在于:少写代码。这一点是至理名言。想把代码写多很容易,想把代码写少很难。少而清晰,少而实现功能,少而可维护。想做到都不是易事。

用中文尝试解读一下:

  1. 先写单测,再实现功能;

  2. 单测要刚好能失败,不要过度,每一行测试代码都要有必须存在的意义;

  3. 需求代码要刚好能成功,只要能让单测通过,不要多写一行代码。

其实很好记,作为程序员,我们要写的无非是两类代码:功能代码 vs 测试代码。

第一点说明了顺序,要先写测试代码,这样你才能有一个清晰的方向,知道我接下来要实现的类/函数/方法/算法,要做到什么,预期的输入输出是什么;

第二和第三点强调的是【刚好】,这个度很重要。头上要有一根弦,【功能代码】和【测试代码】需要相辅相成,一定要同步。不能写了一堆测试,其实压根最后没实现功能,这是对心智的浪费。也不能写了一堆功能,没写单测,这样质量得不到保障。让二者保持同步很重要。

为什么要写单测

回到那个本心的问题,为什么我们要写单测呢?

首先绝不是给领导看,如果你是在这种单位,建议早点跑路。我听到最多的原因是为了单测覆盖率。

达到 80% 甚至更多的覆盖当然是好事,他会给让你在需要【政治正确】的场合挺直腰板,但仔细想想,你的测试,真的覆盖得全么?真的对于所有危险分支都 assert 了么?真的能让你安心么?单测到底是给谁写的呢?

之前在 Stack Overflow 上见到过一个很好的说明:单测存在的意义是让你对写出的代码更有自信。

自信!这一点太重要了。

公司花钱是请你来实现功能的。一句话:They pay for the service you developed, not the test you wrote.

想清楚这一点很重要。对一段自己有绝对自信的代码,洋洋洒洒写几十上百行测试,确实增加了覆盖率,但它的存在,对你自身并不会带来多少好处。

写对,写准,全覆盖,比盲目追求覆盖率更为重要。

同样的,如果你打算重构,在理清方案和计划后,要做的第一件事也绝不是上手写代码,提交。而是补齐老代码的测试case,一定要牢记,测试代码是帮你提升自信心的利器,不写无法帮自己提升自信的测试代码,也不去在测试代码没ready的情况下就洋洋洒洒写起功能代码。

三原则的本质

Write your tests and your production code simultaneously, virtually line by line. One line of test, followed by one line of production code, around, and around and around.

什么是 TDD 三原则的本质,用一个英语单词就是 simultaneously,同步。

先写测试,再写功能。同步进行,不要让两者脱钩。

每一个测试,必须有对应的功能。每一个功能,必须有对应的测试。最后你只需要看 _test.go 文件,就能清楚的知道功能代码的作用,覆盖的场景,用法。

TDD 的误区

TDD 实践者一个常见的误区是沉溺于一一映射,从而增大的系统的耦合程度。建议大家仔细阅读一下 Uncle Bob 的这篇博客:TDD Harms Architecture

如果你的测试代码,完完全全 copy 功能代码的结构,完全做到一一映射。意味着什么呢?强耦合!

一旦你的功能代码要修改,测试代码必然完全乱掉。

一旦出现横向的修改,比如加参数,所有测试case全部都需要修改。

一定要记住,测试代码,也是系统的一部分,也是一定意义上的功能,要被认真对待。TDD 只是标准,不是最终的解法,作为开发者,我们自己需要考虑好如何解耦,隔离。

有没有一些指导思路呢?

我们前面提到,TDD 如果操作不当,容易带来的问题在于,测试与代码实现强耦合。代码稍微一改动,就需要动测试。如果是一条链路需要加参数,意味着有一大批测试都可能需要改。

我们把【测试代码】当成一个 client,它是调用方,思考一下,这种情况下我们会希望调用方和具体实现强耦合么?当然不,你会希望在一个API之后,隔离双方的实现。

作为 client,我不在乎你 server 的实现怎么样,API 就是双方达成的协议,你按照协议来给我返回数据就 ok,你的实现我不关心,我在意的是 API。对于 server 来说也是如此。

那么解法的思想其实很简单,测试也要面向接口。只要接口不变,测试就可以不变。至于具体的 implementation,测试代码不关心。

As the tests get more specific, the production code gets more generic.

做到这一点后,测试就与接口绑定,而不是实现。进而沿着接口能力越来越通用,测试代码越来越具体的方向演进。而不是一行一行跟实现绑定。