Shallow Dream

Keep It Simple and Stupid!

0%

如何进行TDD

此事已有定论!争论已经结束。

GOTO是有害的。TDD确实可行!

​ —— 《代码整洁之道:程序员的职业素养》


什么是 TDD ?

TDD: Test Driven Development

测试驱动开发是敏捷开发中的一项核心实践和技术,也是一种设计方法论。TDD 的原理是在开发功能代码之前,先编写单元测试用例代码测试代码确定需要编写什么产品代码

TDD 的基本思路就是通过测试来推动整个开发的进行,但测试驱动开发并不只是单纯的测试工作,而是把需求分析,设计,质量控制量化的过程

TDD 首先考虑使用需求(对象、功能、过程、接口等),主要是编写测试用例框架对功能的过程和接口进行设计,而测试框架可以持续进行验证。

可以参考下面这个例子:


夏天到了

T:“我需要一个冰箱,他能装一些东西。”

D:一个盒子,能够装东西,满足这个要求

T:“他有三层,上面一层保持在4到5度,中间零下4到5度,下面零下20度。”

D:把盒子分三层,控制不同的温度,满足

T:“我希望这个盒子的门一打开能有个灯亮,这样晚上就可以看得见东西了”

D:加个能亮的灯

T:“不只是这样,我希望是门开灯亮,门关灯灭”

D:改灯的逻辑

...


通过对冰箱的不同测试,我们满足冰箱不同的功能和要求,不断重复这个过程

这个周期也叫“红-绿-重构”

  1. 写一个测试(红)
  2. 让测试通过(绿)
  3. 优化设计(重构)

实践案例

先介绍实践案例是来帮助你更快上手 TDD,如果你想知道怎么进行 TDD 只看这一部分即可

通过一个实践案例来展示如果进行单元测试驱动开发

需要实现一个简易计算器

准备工作

使用 IDEA,Java 语言演示

需要导入依赖

1
2
3
4
5
6
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.9.3</version>
<scope>test</scope>
</dependency>

1. 编写一个测试用例

对于这个简易计算器,我们希望能够有一个 add() 方法,读入两个 int 类型数,能返回对应的结果

1
2
3
4
5
@Test
void testAddInt() {
int result = calculator.add(2, 3); // 计算 2 + 3 = 5
assert result == 5 : result; // 断言 result = 5
}

你看下面这个图你就知道为啥这个步骤叫

此时我们并没有 Calculator 这个类,也没有对应的 add() 方法,单元测试自然也是没有通过的

2. 让测试通过

看到刚刚那些了嘛

知道接下来要干什么嘛?

唉,对了!

绿

鼠标悬浮,对应位置创建类

选择正确的位置

创建类后,编写方法

你只需要让代码恰好能够让当前失败的单元测试成功通过就好,不要多写

恰好 成功通过 不要多写

能明白我的意思嘛?

1
2
3
public int add(int v, int v1) {
return v + v1;
}

是的你没看错,就是这样

不需要边界判断,不需要异常处理

你只需要让这个代码,恰好能够通过你刚刚那个单元测试就好

结果是显然的

下一个测试用例

我们希望当 add() 方法的两个数,相加溢出的时候能够抛出异常,而不是返回错误的值

1
2
3
4
5
6
7
8
@Test
void testAddOverflow() {

assertThrows(ArithmeticException.class, ()->{
calculator.add(Integer.MAX_VALUE, 1);
});

}

新的测试用例

那么就该重构我们的 add() 方法,让他通过我们的新测试用例

重构

重构方法,让他能恰好通过新的测试用例(当然之前也要通过)

1
2
3
4
5
6
7
8
public int add(int v, int v1) {
int result = v + v1;

if (((v ^ result) & (v1 ^ result)) < 0) {
throw new ArithmeticException("integer overflow");
}
return result;
}

单元测试通过

循环

接下来就是循环上面的步骤

“我想要我的计算器有个新功能!”

“写个单元测试,然后我会改代码,让这个单元测试也能通过”

不断迭代,从而实现所有需求


TDD 的三项法则

  1. 在编好失败单元测试之前,不要编写任何产品代码
  2. 只要有一个单元测试失败了,就不要再写测试代码;无法通过编译也是一种失败情况
  3. 产品代码恰好能够让当前失败的单元测试成功通过即可,不要多写

TDD 的优势

确定性

在修改代码后,运行手头有的全部测试,如果单元测试全部通过,那么差不多可以确信修改没有破坏任何东西

缺陷注入率

TDD 能够显著降低缺陷

勇气

你为什么不敢去重构一些看起来不合理的代码?因为你不知道改完,程序还能不能正常运行。为什么不知道?因为没有编写单元测试覆盖整个代码

如果你能确定自己的整理工作没有破坏东西,你会不会敢于去整理代码呢?

TDD 能够让你拥有一套值得信赖的测试,打消你对于修改代码的恐惧。当你不害怕去整理代码的时候,你就会去整理不合理的逻辑,设计,这样代码就会整洁,易于理解,易于修改,缺陷也就少了

文档

代码不会撒谎

每一个单元测试都是一个示例,描述系统的使用方法

回想开篇的例子,你只需要看看 T 的想法,你就知道这个冰箱都有什么功能

单元测试就是文档,描述了系统设计的最底层设计细节

设计

测试代码的一个问题就是必须隔离出要测试的代码。

所以为了编写测试,你必找出将这个待测函数和其他函数解耦的办法。为了编写测试,会倒逼你去考虑什么是好的设计

测试先行,促使你做出松耦合的设计

事后当然也可以测试,但这本质上是不同的,先写的测试是进攻,后写的测试是防守。后写的测试受制于已有代码,因为你已经知道问题如何解决

与采用测试先行的方式编写的测试代码比起来,后写的测试在深度和捕获错误的灵敏度方面都要逊色很多


TDD 的局限

不适合 TDD 方式的场景

  1. 无法迭代开发的

    都 2024 年了,无法迭代的开发,好好好

  2. 无法快速测试的

    如果测试一次就要等半天,那还是老老实实的先开发后测试吧

  3. 存在交互边界的

    硬件的开发,存在物理的边界

TDD 对团队本身有一定要求

我都吃泡面了,我还在乎健康?

人家都不在乎代码质量,只需要能跑

你说你非要强行 TDD 多少有点自大

同时 TDD 需要有相对可迭代的拆分需求,拆分任务时需要对任务的粒度和可持续性有较高的要求

对重构要有理解,对协作也要有理解


更多的测试代码并不是 TDD 的缺点

你知道嘛

如果说为了完成一个简单的加法器

我本来只需要直接做到边界测试,溢出异常抛出,功能实现

现在我 TDD 需要额外的去多写很多测试代码?

记住代码多不是 TDD 的缺点,那是你的缺点

TDD 编写测试单元的时间,是在用你直接开发,报错,调试,测试的时间

你只是先写了测试,然后去开发

只是调了顺序,不是让你额外的去编写了什么东西

所以不要觉得 TDD 让你写了额外的代码,这些都是必须的

当然,这样也能给出一个比较合理的解决方案

单元测试代码在 2024 年可以用大模型生成,你可以训练一个大模型,让他能够根据你要求的描述,给出单元测试代码

(单元测试代码不会特别长,单元测试的函数一般都需要解耦,所以大模型生成的代码可执行率是很高的)

所以比较方便,可以让你将测试阶段的重心更加注重设计测试,而不是编写测试代码

在你时间不够的时候,仍然能够很好的进行 TDD