测试驱动开发总结

Posted by Kriz on 2017-12-06

简述

测试驱动开发(TDD,Test Driven Development),即通过测试来推动开发的进行,基本思想是在开发功能代码之前,先编写测试代码。也就是说在明确要开发的功能之后,先思考如何对这个功能进行测试并完成测试代码的编写,然后编写相关的代码满足这些测试用例,再迭代添加其他功能,直到完成全部功能的开发。

TDD很重要的一个功能是保证代码正确并贴合需求。由于TDD测试集较强的针对性,开发者能够迅速发现、定位bug。同时,测试驱动开发过程中产生的测试用例代码就是功能代码最好的说明文档。

根据调查,TDD会少量增加前期成本、减缓开发速度,但产品缺陷密度会大大降低。

TDD的目标:代码整洁、可用

基本过程

一个经典的TDD过程模型如下:

  1. 明确当前要完成的功能,可以整理成列表。
  2. 完成针对此功能的测试用例编写
  3. 测试代码编译不通过。
  4. 快速编写对应的功能代码
  5. 测试通过。
  6. 对代码进行重构,并保证测试通过。
  7. 迭代完成所有功能的开发。

这个过程也可以直接以这样三个阶段来阐述:

  1. 不可运行:写出不能工作的测试程序
  2. 可运行:开发功能,直到所有的测试通过,为此可以在程序中使用一些不合情理的方法
  3. 重构:优化代码结构

像这样:

因此这三个阶段也被称作红/绿/重构

具体可参考以下原文:

Follow these steps (slight variations exist among TDD practitioners):

  1. Understand the requirements of the story, work item, or feature that you are working on.
  2. Red: Create a test and make it fail.
    1. Imagine how the new code should be called and write the test as if the code already existed. You will not get IntelliSense because the new method does not yet exist.
    2. Create the new production code stub. Write just enough code so that it compiles.
    3. Run the test. It should fail. This is a calibration measure to ensure that your test is calling the correct code and that the code is not working by accident. This is a meaningful failure, and you expect it to fail.
  3. Green: Make the test pass by any means necessary.
    1. Write the production code to make the test pass. Keep it simple.
    2. Some advocate the hard-coding of the expected return value first to verify that the test correctly detects success. This varies from practitioner to practitioner.
    3. If you’ve written the code so that the test passes as intended, you are finished. You do not have to write more code speculatively. The test is the objective definition of “done.” The phrase “You Ain’t Gonna Need It” (YAGNI) is often used to veto unnecessary work. If new functionality is still needed, then another test is needed. Make this one test pass and continue.
    4. When the test passes, you might want to run all tests up to this point to build confidence that everything else is still working.
  4. Refactor: Change the code to remove duplication in your project and to improve the design while ensuring that all tests still pass.
    1. Remove duplication caused by the addition of the new functionality.
    2. Make design changes to improve the overall solution.
    3. After each refactoring, rerun all the tests to ensure that they all still pass.
  5. Repeat the cycle. Each cycle should be very short, and a typical hour should contain many Red/Green/Refactor cycles.

基本原则

三大原则:

  • 除非为了通过一个失败的单元测试,否则不允许编写任何产品代码
  • 在一个单元测试中,只允许编写刚好能够导致失败的内容(不能让一个单元测试覆盖太多)
  • 只允许编写刚好能够使一个失败的单元测试通过的产品代码(实现和重构也不要写太多)

这样的原则倡议无论是测试用例的设计,还是产品代码的实现,都要采用小步快跑的方式,不要一下子将太多的变化带入即有的代码。

一些其他的单元测试通用原则,仅作参考:

  • 先写断言:测试代码编写时,应该首先编写对功能代码的判断用的断言语句,然后编写相应的辅助语句。
  • 一顶帽子:开发人员开发过程中要做不同的工作,比如:编写测试代码、开发功能代码、对代码重构等。做不同的事,承担不同的角色。开发人员完成对应的工作时应该保持注意力集中在当前工作上,而不要过多的考虑其他方面的细节,保证头上只有一顶帽子。避免考虑无关细节过多,无谓地增加复杂度。
  • 测试隔离:不同代码的测试应该相互隔离。对一块代码的测试只考虑此代码的测试,不要考虑其实现细节(比如它使用了其他类的边界条件)。
  • 起好名字:不需要注释也能够被看懂的名字是必需的,它需要清楚表达测试的内容。可以在逻辑完成后再最终决定测试名。

示例

一个简单的开始

示例:计算器

1
public class Calculator {}

采用JUnit来做单元测试,按照TDD的思想来完成开发。

需求:完成两个整数的加法

测试1:两个正数相加(10,20)应该得到正确的结果(30)

测试2:两个负数相加(-10,-20)应该得到正确的结果(-30)

首先在计算器应用中添加class

1
2
3
4
5
package source;
public class Calculator {
}

随后完成测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package AppTests;
import org.junit.Assert;
import org.junit.Test;
import source.Calculator;
public class AddingNumbersTests {
private Calculator myCalculator = new Calculator();
@Test
public void addTwoPositiveNumbers() {
int expectedResult = 30;
int actualResult = myCalculator.Add(10, 20);
Assert.assertEquals("The sum of two positive numbers is correct" , expectedResult, actualResult);
}
@Test
public void addTwoNegativeNumbers() {
int expectedResult = -30;
int actualResult = myCalculator.Add(-10, -20);
Assert.assertEquals("The sum of two negative numbers is correct" , expectedResult, actualResult);
}
}

执行测试,输出

java.lang.Error: Unresolved compilation problem:
The method Add(int, int) is undefined for the type Calculator

接下来移步到功能代码部分,先return 0来保证测试代码在正常执行:

1
2
3
4
5
6
7
8
package source;
public class Calculator {
public int Add(int number1, int number2)
{
return 0;
}
}

输出:

java.lang.AssertionError: The the sum of two positive numbers is incorrect expected:\<30\> but was:\<0\>

这是我们所期望的error。接下来快速编写功能代码:

1
2
3
4
5
6
7
8
package source;
public class Calculator {
public int Add(int number1, int number2)
{
return (number1 + number2);
}
}

执行测试,通过。

最后进行重构,主要检查是否有Magic Number(无说明的数字或字符串)、Copy Code、冗余或效率低下的代码结构等。这里没有必要。

功能扩展示例

需求1:Add方法只可以接收0-2个使用逗号分隔的数字组成的字符串

完成测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test(expected = RuntimeException.class)
public void addMoreThanTwoNumbers() {
myCalculator.add("10,20,30");
}
@Test
public void addProperNumbers() {
int expectedResult = 30;
int actualResult = myCalculator.Add("10,20");
Assert.assertEquals("The sum of two negative numbers is correct" , expectedResult, actualResult);
}
@Test(expected = RuntimeException.class)
public void addNaN() {
myCalculator.add("10,X");
}

运行不通过,完成功能代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Calculator {
public int Add(final String numbers) {
int returnValue = 0;
String[] numbersArray = numbers.split(",");
if (numbersArray.length > 2) {
throw new RunTimeException("Up to 2 numbers seperated by comma are allowed");
} else {
for (String number : numberArray) {
returnValue += Integer.parseInt(number); // 参数类型无法转换则parseInt()会报错
}
}
return returnValue;
}
}

注意:根据上文提到的原则,此处的代码编写只需要刚好覆盖测试即可。

测试通过,做一点点重构:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Calculator {
public int Add(final String numbers) {
int returnValue = 0;
String[] numbersArray = numbers.split(",");
if (numbersArray.length > 2) {
throw new RunTimeException("Up to 2 numbers seperated by comma are allowed");
}
for (String number : numberArray) {
returnValue += Integer.parseInt(number); // 参数类型无法转换则parseInt()会报错
}
return returnValue;
}
}

也有很多情况下部分重构是结合下一个需求完成的(例如下文需求5的情况),不一定要在这个时候进行。

需求2:如果传入空字符串,则直接返回0

完成测试代码:

1
2
3
4
@Test
public final void parameterStringIsEmpty() {
Assert.assertEquals(0, myCalculator.add(""));
}

测试不通过,完善功能代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Calculator {
public int Add(final String numbers) {
int returnValue = 0;
String[] numbersArray = numbers.split(",");
if (numbersArray.length > 2) {
throw new RunTimeException("Up to 2 numbers seperated by comma are allowed");
}
for (String number : numberArray) {
if (!number.trim().isEmpty()) {
returnValue += Integer.parseInt(number);
}
}
return returnValue;
}
}

需求3:允许任意个加数

这个需求与之前的内容冲突,所以要满足这个需求并使所有测试不发生错误,需要删除测试addMoreThanTwoNumbers()

1
2
3
4
5
6
7
8
9
10
11
// @Test(expected = RuntimeException.class)
// public void addMoreThanTwoNumbers() {
// myCalculator.add("10, 20, 30");
// }
@Test
public void addMoreThanTwoNumbers() {
int expectedResult = 10 + 20 + 5 + 15;
int actualResult = myCalculator.Add("10,20,5,15");
Assert.assertEquals(expectedResult, actualResult);
}

测试不通过,完成实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Calculator {
public int Add(final String numbers) {
int returnValue = 0;
String[] numbersArray = numbers.split(",");
// if (numbersArray.length > 2) {
// throw new RunTimeException("Up to 2 numbers seperated by comma are allowed");
// }
for (String number : numberArray) {
if (!number.trim().isEmpty()) {
returnValue += Integer.parseInt(number);
}
}
return returnValue;
}
}

测试通过

需求4:响应换行

1
2
3
4
@Test
public final void responseNewLine() {
Assert.assertEquals(3 + 6 + 15, myCalculator.Add("3,6\n15"));
}
1
2
3
4
5
6
7
8
9
10
11
12
public class Calculator {
public int Add(final String numbers) {
int returnValue = 0;
String[] numbersArray = numbers.split(",|\n");
for (String number : numberArray) {
if (!number.trim().isEmpty()) {
returnValue += Integer.parseInt(number.trim());
}
}
return returnValue;
}
}

需求5:支持格式为“//[delimiter]\n[numbers…]”的一位自定义分隔符

编写测试代码:

1
2
3
4
@Test
public final void customizedDelimeter() {
Assert.assertEquals(3 + 6 + 15, myCalculator.Add("//;n3;6;15"));
}

测试不通过。要完成这个需求需要做较大程度的重构,为了保证之前的测试不会因为新引入代码而变得不稳定,此例采用调用重载的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public int Add(final String numbers) {
String delimiter = ",|\n";
String numbersWithoutDelimiter = numbers;
if (numbers.startsWith("//")) {
int delimiterIndex = numbers.indexOf("//") + 2;
delimiter = numbers.substring(delimiterIndex, delimiterIndex + 1);
numbersWithoutDelimiter = numbers.substring(numbers.indexOf("\n") + 1);
}
return Add(numbersWithoutDelimiter, delimiter);
}
public int Add(final String numbers, final String delimeter) {
int returnValue = 0;
String[] numbersArray = numbers.split(delimiter);
for (String number : numberArray) {
if (!number.trim().isEmpty()) {
returnValue += Integer.parseInt(number.trim());
}
}
return returnValue;
}

测试通过,并以此类推,继续完成之后的需求。

另外一个可参考的TDD实例:

总结

基本思想:在开发功能代码之前,先编写测试代码

每个迭代的步骤:

  • 明确需求
  • 编写不能工作的测试
  • 快速编写对应的功能代码
  • 重构功能代码

基本原则:

  • 除非为了通过测试,否则不能编写产品代码
  • 一个单元测试不能覆盖太多
  • 只允许编写刚好能通过测试的代码

参考资料