测试驱动开发 (TDD) 是一种编写整洁代码的"规程"或"方法论",而不仅仅是测试技术。
JaCoCo 在运行测试后生成详细的覆盖率报告的工具, maven 引用。
测试驱动开发
测试驱动开发(TDD)是什么?
TDD 不是说写完代码再写测试,而是先写测试,再写代码。它是一种开发流程,一个不断循环的"节奏":
- 红灯 (Red): 写一个针对某个新功能的自动化测试 。运行这个测试,它应该失败,因为你还没写对应的功能代码。这个失败告诉你,"我想要的功能还不存在"。
- 绿灯 (Green): 写最少量 的程序代码,让刚才失败的测试通过。你的目标只是让测试变绿,代码可能写得不好看、效率不高都没关系。然后运行所有的测试(包括之前写过的),确保没有破坏已有的功能。
- 重构 (Refactor): 现在所有测试都通过了,功能是正确的。这时,你可以放心地改进和优化 你的程序代码和测试代码,让它们更整洁、更高效、结构更好。重构过程中,要持续运行所有测试,确保改进没有引入新的 Bug。
这个 红 -> 绿 -> 重构 的循环非常短,可能只需要几分钟到十几分钟。你不断地重复这个循环,逐步完善你的功能。
TDD 的三定律
- 在你编写一个失败的测试之前,不能编写任何生产代码。
- 在一个失败的测试中,你不能编写多于恰好能够暴露失败的测试代码。
- 在你编写一个失败的测试之外,你不能编写多于恰好能够使得当前失败测试通过的生产代码。
举例说明:构建一个简单的字符串计算器
假设我们要写一个函数,能够接收一个包含数字和逗号的字符串,并计算所有数字的和。例如 "1,2,3" 应该返回 6。
我们使用 TDD 的流程来开发这个功能。
我们要测试的函数: int add(String numbers)
步骤 1:红灯 (Red) - 测试空字符串
- 需求: 空字符串应该返回 0。
写测试:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
// 我们要测试的类,也就是生产类
class StringCalculator {
// 我们还没写这个 add 方法
// public int add(String numbers) { ... }
}
class StringCalculatorTest {
@Test
void shouldReturnZeroForEmptyString() {
StringCalculator calculator = new StringCalculator();
// 断言:调用 add("") 应该返回 0
assertEquals(0, calculator.add(""));
}
}
- 运行测试: 测试会编译失败(因为
StringCalculator
类可能不存在,或者add
方法不存在),或者运行时失败(比如add
方法抛异常)。测试失败了,红灯!
步骤 2:绿灯 (Green) - 让测试通过
- 目标: 让
shouldReturnZeroForEmptyString
测试通过。
写最少量代码:
class StringCalculator {
public int add(String numbers) {
// 最简单让上面测试通过的代码就是判断是不是空字符串
if (numbers == null || numbers.isEmpty()) {
return 0;
}
// 现在还不能处理非空字符串,先随便返回个东西或抛异常 (这里暂不处理非空)
// 实际上,为了让测试尽快通过,我们可能直接返回 0
return 0; // !!! 让 shouldReturnZeroForEmptyString 通过的最少量代码 !!!
}
}
- 运行测试:
shouldReturnZeroForEmptyString
测试通过了。如果之前有其他测试,也运行一下。所有测试都通过了,绿灯!
步骤 3:重构 (Refactor) - 改进代码
- 代码量很少,暂时不需要大的重构。可能把
if
判断写得更清晰一点,或者给类和方法加注释。这里跳过大的重构。
步骤 4:红灯 (Red) - 测试单个数字
- 需求: 输入 "1" 应该返回 1。
写测试:
class StringCalculatorTest {
// ... shouldReturnZeroForEmptyString 测试 ...
@Test
void shouldReturnNumberForSingleNumberString() {
StringCalculator calculator = new StringCalculator();
// 断言:调用 add("1") 应该返回 1
assertEquals(1, calculator.add("1"));
// 断言:调用 add("5") 应该返回 5
assertEquals(5, calculator.add("5"));
}
}
- 运行测试:
shouldReturnNumberForSingleNumberString
测试会失败(因为add("1")
仍然返回 0)。shouldReturnZeroForEmptyString
应该仍然通过。测试失败,红灯!
步骤 5:绿灯 (Green) - 让测试通过
- 目标: 让
shouldReturnNumberForSingleNumberString
通过。
写最少量代码:
class StringCalculator {
public int add(String numbers) {
if (numbers == null || numbers.isEmpty()) {
return 0;
}
// !!! 添加处理单个数字的代码 !!!
// 尝试将字符串转换为整数
return Integer.parseInt(numbers); // !!! 让测试通过的最少量代码 !!!
}
}
- 运行测试:
shouldReturnZeroForEmptyString
和shouldReturnNumberForSingleNumberString
都通过了。所有测试都通过了,绿灯!
步骤 6:重构 (Refactor) - 改进代码
Integer.parseInt
可能会抛出NumberFormatException
,虽然当前测试没有覆盖到无效数字字符串,但为了健壮性,可以在这里考虑异常处理(或者等写了相关测试后再处理)。这里暂时不展开。
步骤 7:红灯 (Red) - 测试两个数字
- 需求: 输入 "1,2" 应该返回 3。
写测试:
class StringCalculatorTest {
// ... shouldReturnZeroForEmptyString 测试 ...
// ... shouldReturnNumberForSingleNumberString 测试 ...
@Test
void shouldReturnSumForTwoNumbersSeparatedByComma() {
StringCalculator calculator = new StringCalculator();
// 断言:调用 add("1,2") 应该返回 3
assertEquals(3, calculator.add("1,2"));
// 断言:调用 add("5,7") 应该返回 12
assertEquals(12, calculator.add("5,7"));
}
}
- 运行测试:
shouldReturnSumForTwoNumbersSeparatedByComma
测试会失败(因为add("1,2")
会因为无法直接解析 "1,2" 而抛出NumberFormatException
)。测试失败,红灯!
步骤 8:绿灯 (Green) - 让测试通过
- 目标: 让
shouldReturnSumForTwoNumbersSeparatedByComma
通过。
写最少量代码:
class StringCalculator {
public int add(String numbers) {
if (numbers == null || numbers.isEmpty()) {
return 0;
}
// !!! 添加处理逗号分隔的代码 !!!
String[] numberArray = numbers.split(","); // 按逗号分割
if (numberArray.length == 1) {
// 如果分割后只有一个元素 (处理单个数字的情况)
return Integer.parseInt(numberArray[0]);
} else {
// 如果分割后有两个元素 (处理两个数字的情况)
int num1 = Integer.parseInt(numberArray[0]);
int num2 = Integer.parseInt(numberArray[1]);
return num1 + num2; // 求和
}
// 注意:这段代码现在还不能处理三个或更多数字,甚至无效数字字符串
// 但它让当前的测试通过了
}
}
- 运行测试: 所有三个测试都应该通过。所有测试都通过了,绿灯!
步骤 9:重构 (Refactor) - 改进代码
现在的代码有点简陋,只能处理空字符串、一个数字或两个数字。我们可以重构它,让它能处理任意数量的数字(注意这里违反了第三条定律):
(需求就是实现测试,目前已经完成了,这里是重构,不过重构不建议加新功能哈,这里不加新功能没啥好重构的了hhhh)
class StringCalculator {
public int add(String numbers) {
if (numbers == null || numbers.isEmpty()) {
return 0;
}
// 重构:处理任意数量的数字
String[] numberArray = numbers.split(","); // 按逗号分割
int sum = 0;
for (String numberStr : numberArray) {
// 这里应该加上 NumberFormatException 的处理,但为了例子简洁暂不加
sum += Integer.parseInt(numberStr); // 累加每个数字
}
return sum;
}
}
- 运行测试: 再次运行所有测试,确保重构没有破坏功能。它们都应该通过。
这个过程会一直进行下去,每次只添加一点点功能(比如处理换行符分隔、处理负数、忽略大于 1000 的数字等等),为每个新功能写一个测试,让测试通过,然后重构。
这就是 TDD 的基本流程。它通过小步快跑、频繁测试和重构,确保你构建的功能是正确的,并且代码保持整洁。
测试的整洁
整洁测试三要素:可读性、可读性和可读性。
核心思想: 好的测试和好的生产代码一样重要,它们必须是整洁且易于维护的。
整洁测试的五大原则 (F.I.R.S.T.):
F - Fast (快速):
- 什么意思: 你的测试应该运行得非常快。
- 为什么重要: 如果测试运行得慢,开发者就不会频繁地运行它们(比如在每次修改代码后)。不频繁运行测试,测试的价值就大打折扣,无法及时发现问题。快速的测试才能融入到小步快跑的 TDD 循环中。
I - Independent (独立):
- 什么意思: 每个测试用例都应该是独立的,它们不应该相互依赖。一个测试的通过或失败不应该影响到其他测试的运行结果。
- 为什么重要: 如果测试相互依赖,当一个测试失败时,可能会导致一系列其他测试也跟着失败(级联失败),让你很难判断是哪个测试真正发现了问题,调试会非常困难。独立性也意味着你可以随意调整测试的运行顺序,或者只运行某个特定的测试,而不用担心遗漏依赖项。
R - Repeatable (可重复):
- 什么意思: 在任何环境(你的开发机、测试服务器、CI/CD 环境)下,无论何时运行,测试都应该给出相同的结果。
- 为什么重要: 如果测试的结果不可重复(有时通过,有时失败),你就无法信任你的测试套件。它可能是因为外部因素(如网络、时间、文件状态)或测试本身的设计问题导致的不稳定(Flaky Test)。不可重复的测试是最大的障碍,会让人失去对测试的信心。
S - Self-validating (自我验证): 就是用断言,控制台通过或报错,而不是看控制台输出
- 什么意思: 测试的输出必须是明确的"通过"或"失败"。它应该通过自动化断言(Assert)来判断结果,而不是需要人工去查看日志、比较文件或观察程序行为来判断是否正确。
- 为什么重要: 自动化测试的目的就是减少人工干预。测试运行完毕后,你只需要看一个简单的报告(比如绿条或红条)就知道代码是否工作正常,不需要花费时间去分析结果。
T - Timely (及时):
- 什么意思: 测试应该在正确的时间 编写。在 TDD 中,正确的时间就是恰好在需要实现对应功能之前。
- 为什么重要: 及时编写测试(先于代码)是 TDD 方法论的核心,它驱动你思考代码如何使用,促进更好的设计,并确保不会遗漏测试。