# 0x00、为什么写这篇文章
[测试驱动开发 - 维基百科,自由的百科全书](https://zh.wikipedia.org/wiki/%E6%B5%8B%E8%AF%95%E9%A9%B1%E5%8A%A8%E5%BC%80%E5%8F%91)
测试驱动开发,TDD。
测试在前,实现在后;测试代码决定业务代码;关注点分离;红绿循环……这都是我们耳熟能详的对TDD的描述。
TDD固然有很多的优点,但遵循TDD的实践同时也提高了对程序员的要求。
在网上随便一搜,TDD的优缺点随处都是。讲解如何实践TDD的优秀文章也大有所在。
[TDD已死?如何进行有效的TDD实践 | IDCF-技术圈](https://jishuin.proginn.com/p/763bfbd4c8c2)
而这次我是想结合自己对TDD的理解,和在项目上的实践,说说我自己的TDD是怎么样的。
只有四个多月的实践,肯定还有很多欠缺。但是我相信未来我自己肯定还会写一个新的《我的TDD》,看看那时的我和现在的我有什么不一样的理解。
# 0x01、TDD的个人理解
那些基于ATDD、UTDD的理论就不在这里详细讨论了,上面的刘冉老师我觉得已经讲得很清楚了。
这次我主要是想结合我自己的个人经历,说说我在这几个月对TDD的实践以及自己对TDD理解的变化。
在阐述我自己的TDD实践之前,我想要强调的是:
> **TDD是实践,更是一种思维**
不是因为我们在代码中先写测试,后写实现,就是TDD了;也不是因为我们没有严格遵循先有测试后有业务逻辑,就不是TDD了。
**TDD首先带出的应该是思维,一种关注点分离的思维,一种分层、分支思维;而后将我们的所想编码反应在测试上,最后根据测试——我们思维的具象体现,编写真正的业务逻辑代码。这就是我理解的一个完整的TDD。**在刘冉老师的文章里也有类似的例子。
下面的个人理解,很多都是基于上面的理解去发散的。
## 我自己的红绿循环
首先,是一个简化版的我自己理解的红绿循环。这个示意图是建立在具体项目上的。我所实践的架构采用六边形架构。
![image.png](https://cong-onion.cn/upload/2021/05/image-75341b289f764985bead97a5f4dfe700.png)
结合测试金字塔,目前我的实践可以总结为下面几点。
## 1、从大到小、从内到外地构建测试
刚开始上手TDD的时候,我总是习惯性地一次性地把所有测试写在API测试中(使用SpringBootTestStarter中的MockMVC)。
这虽然也是一种TDD,但是其实没有很好地将TDD中关注点分离、分层分支的思维体现在测试代码上。
我们项目的TL曾说:编写API测试对开发人员的要求并没有那么高,API测试写起来很“爽”,因为写的时候根本不需要考虑太多API内部实现是怎么样的(API测试是灰盒测试),只需要关注输入和输出即可。**但是如果同时需要写单元测试,就提高了对开发人员的要求,因为写单元测试的时候会迫使开发人员去思考分层、分支的逻辑,从而使得关注点分离**。
TL的话对我很受用,之后我慢慢地开始尝试对一些复杂逻辑的代码去编写单元测试,尝试将一些任务分解反应到不同的单元测试上,最后去根据这些单元测试边写代码。
到写这个博客为止,我的TDD实践大致可以总结为下面这张图。
![image.png](https://cong-onion.cn/upload/2021/05/image-9521fda370ad45889e1f3730f41ee278.png)
1. 构建API测试(或集成测试),将业务语言转化为技术语言——测试用例
2. 开始实现接口。当遇到一个新拆分的任务或模块,则停下来转去编写粒度更细的单元测试。(横向扩展测试用例)
3. 当遇到依赖的下层方法还未被实现时,则先去实现下层的方法,开启另一个小的红绿循环。(纵向扩展测试用例)
4. 每当完成一个小的红绿循环,则向上递归完成更大的红绿循环,直至所有红绿循环均被完成
用文字表述出来可能不太准确,图例我认为更能表现我的实践方式。
**事实上,如果把我的TDD比作一个数据结构,那么他就是一个入栈出栈过程。当寻找到一个没有被完成的红绿循环则将其入栈,并寻找其依赖的红绿循环,直至找到无法再被拆分的、最细粒度的红绿循环。随后完成栈顶的红绿循环,并将其弹出栈,直至栈中没有任何红绿循环为止。**
## 2、“测试金字塔”
跟随TL的话开始实践之后其实很快就会发现一个问题,那就是单元测试所覆盖的代码和集成测试所覆盖的代码必定是会有重复的部分的。
出于平衡质量和效率的考虑,**我觉得在保证逻辑正确的前提下,应该尽量减少对同一段代码进行同一套业务逻辑的测试。**
于是在后来的实践中,我尝试通过以下的逻辑去实践TDD:
- API测试或集成测试,覆盖所有需要消费者(前端或BFF)处理的逻辑分支,包括正常分支以及需要消费者处理的异常分支
- 单元测试主要覆盖每一个拆分出来的单元的主分支和异常分支,异常分支包括内部异常与外部异常。
通过上面的这一套逻辑,我所书写出来的测试逻辑会变得比较清晰,虽然仍然导致了一部分代码被重复测试,但是被重复测试的代码都是比较重要的正常逻辑分支;而一些情况比较极端的异常分支也会被单元测试覆盖到。
# 0x02 “测试”怎么写?
先来温习一下业界元老Robert C. Martin他自己总结提出的TDD三原则:
- 不允许编写任何产品代码,除非目的是为了让失败的测试通过;
- 不允许编写多于一个的失败测试,编译错误也是失败;
- 不允许编写多于恰好能让测试通过的产品代码,有效的减少返工。
想要严格地、持续地遵循三原则我认为是颇有难度的。下面我会讲一些我自己在实践过程中,对编写测试代码本身的一些实践。
## 测试是逐步构建的
本小节我想表达的思想是:**测试不是一下子就写好的,而是在逐步完成业务逻辑的过程中,逐渐根据需求丰富的。**
当我们在构建一套复杂的应用时,不可避免地会依赖到不属于应用本身的内容,这时候就需要我们按需去mock或准备数据。
根据上述特点以及TDD三原则,我在书写一个测试时(无论是集成测试还是单元测试),我所遵从的实践是:
- 编写完成核心业务逻辑的测试代码,包括数据准备、调用和验证,开启红绿循环中的“红”;
- 转到应用,编写业务逻辑代码,尝试性将红绿循环变“绿”;
- 回到测试,当发现有外部依赖时,向测试添加mock代码;
- 测试通过,红绿循环结束。
跟随着上面的实践会发现,只有核心逻辑测试代码是不变的,而其他的数据准备、mock等内容是可以根据需求变化的。
**所以我认为,测试是逐步构建的。**
下面举个不全面的例子:
针对一个业务代码,我会先从集成测试层面开始书写测试代码。这段测试,期望将输入的字符串中的英文转为大写。
在所有业务编码开始之前,我第一段写下的代码会是:
```java
// will fail as expect.
@Test
public void should_return_uppercase_when_get_text_uppercase() {
String text = "hello world!";
String result = given() // MockMvcRequestSpecification
.param("text", text)
.get("/text/uppercase")
.then().statusCode(200)
.extract().asString();
assertThat(result).isEqualTo("HELLO WORLD!");
}
```
随后转去完成业务逻辑。当业务逻辑完成之后,发现存在外部依赖,我会尝试用mock去解决外部依赖的问题:
```java
// will finally pass the intergration test.
@Test
public void should_return_uppercase_when_get_text_uppercase() {
String text = "hello world!";
when(outerClient.invoke(any()).thenReturn("ok"); // mock other client.
String result = given() // MockMvcRequestSpecification
.param("text", text)
.get("/text/uppercase")
.then().statusCode(200)
.extract().as(String.class);
assertThat(result).isEqualTo("HELLO WORLD!");
}
```
## 测试是可以改变的
当然,测试在整个软件的生命周期中是可变的。当一个业务逻辑发生变化之后,与之对应的原有的测试代码应该相应地发生改变。变更的步骤应该是这样的:
- 业务逻辑发生变更,应该首先反应在相对应的测试代码上
- 运行测试,测试失败,开启红绿循环中的“红”(当然不用运行我们都知道他会失败,除非原来的测试有问题)
- 开始变更原有的业务逻辑代码,使得红绿循环变“绿”
**注意:很有可能一次变更没有办法变更所有相关的测试。变更的时候也可以采取大小红绿循环的样式,在发现一处代码因为业务逻辑发生改变需要变更时,首先应该先去变更对应的测试。**
## 测试是辅助理清代码逻辑的
这一点是我在读《重构》的时候发现的。当发生以下情况时,业务和代码逻辑的上下文是很容易发生错位、缺失的:
- 当业务逻辑复杂
- 工期长,项目人员变动剧烈
- 团队庞大、所维护的代码量庞大
在这种(并不限于)情景下,测试代码是帮助后来人快速理清代码逻辑非常重要的资源。
**因此,我们在书写测试的同时,不仅仅是要让测试保证代码逻辑的正确性,更是要让代码可读、易读,减轻后期的理解、维护成本。**
# 0xFF、总结
我觉得每个人都会拥有自己的TDD。他不一定是最好的,但是一定是最合适自己的。在当前的项目下,结合TDD、DDD、Tasking等一系列技巧,我在完成业务代码的条理性是相比起之前有了很大的提高,不会像以前东做一块西做一块,做完了这个忘记了那个。希望自己能够循着这条路摸索下去,发现一个更加正确的、更加适合自己的TDD。
【心得】我的TDD