ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
## [测试驱动开发](https://lingcoder.gitee.io/onjava8/#/book/16-Validating-Your-Code?id=%e6%b5%8b%e8%af%95%e9%a9%b1%e5%8a%a8%e5%bc%80%e5%8f%91) 之所以可以有测试驱动开发(TDD)这种开发方式,是因为如果你在设计和编写代码时考虑到了测试,那么你不仅可以写出可测试性更好的代码,而且还可以得到更好的代码设计。 一般情况下这个说法都是正确的。 一旦我想到“我将如何测试我的代码?”,这个想法将使我的代码产生变化,并且往往是从“可测试”转变为“可用”。 纯粹的 TDD 主义者会在实现新功能之前就为其编写测试,这称为测试优先的开发。 我们采用一个简易的示例程序来进行说明,它的功能是反转**String**中字符的大小写。 让我们随意添加一些约束:**String**必须小于或等于30个字符,并且必须只包含字母,空格,逗号和句号(英文)。 此示例与标准 TDD 不同,因为它的作用在于接收**StringInverter**的不同实现,以便在我们逐步满足测试的过程中来体现类的演变。 为了满足这个要求,将**StringInverter**作为接口: ~~~ // validating/StringInverter.java package validating; interface StringInverter { String invert(String str); } ~~~ 现在我们通过可以编写测试来表述我们的要求。 以下所述通常不是你编写测试的方式,但由于我们在此处有一个特殊的约束:我们要对 \*\*StringInverter \*\*多个版本的实现进行测试,为此,我们利用了 JUnit5 中最复杂的新功能之一:动态测试生成。 顾名思义,通过它你可以使你所编写的代码在运行时生成测试,而不需要你对每个测试显式编码。 这带来了许多新的可能性,特别是在明确地需要编写一整套测试而令人望而却步的情况下。 JUnit5 提供了几种动态生成测试的方法,但这里使用的方法可能是最复杂的。 \*\*DynamicTest.stream() \*\*方法采用了: * 对象集合上的迭代器 (versions) ,这个迭代器在不同组的测试中是不同的。 迭代器生成的对象可以是任何类型,但是只能有一种对象生成,因此对于存在多个不同的对象类型时,必须人为地将它们打包成单个类型。 * **Function**,它从迭代器获取对象并生成描述测试的**String**。 * **Consumer**,它从迭代器获取对象并包含基于该对象的测试代码。 在此示例中,所有代码将在**testVersions()**中进行组合以防止代码重复。 迭代器生成的对象是对**DynamicTest**的不同实现,这些对象体现了对接口不同版本的实现: ~~~ // validating/tests/DynamicStringInverterTests.java package validating; import java.util.*; import java.util.function.*; import java.util.stream.*; import org.junit.jupiter.api.*; import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.DynamicTest.*; class DynamicStringInverterTests { // Combine operations to prevent code duplication: Stream<DynamicTest> testVersions(String id, Function<StringInverter, String> test) { List<StringInverter> versions = Arrays.asList( new Inverter1(), new Inverter2(), new Inverter3(), new Inverter4()); return DynamicTest.stream( versions.iterator(), inverter -> inverter.getClass().getSimpleName(), inverter -> { System.out.println( inverter.getClass().getSimpleName() + ": " + id); try { if(test.apply(inverter) != "fail") System.out.println("Success"); } catch(Exception | Error e) { System.out.println( "Exception: " + e.getMessage()); } } ); } String isEqual(String lval, String rval) { if(lval.equals(rval)) return "success"; System.out.println("FAIL: " + lval + " != " + rval); return "fail"; } @BeforeAll static void startMsg() { System.out.println( ">>> Starting DynamicStringInverterTests <<<"); } @AfterAll static void endMsg() { System.out.println( ">>> Finished DynamicStringInverterTests <<<"); } @TestFactory Stream<DynamicTest> basicInversion1() { String in = "Exit, Pursued by a Bear."; String out = "eXIT, pURSUED BY A bEAR."; return testVersions( "Basic inversion (should succeed)", inverter -> isEqual(inverter.invert(in), out) ); } @TestFactory Stream<DynamicTest> basicInversion2() { return testVersions( "Basic inversion (should fail)", inverter -> isEqual(inverter.invert("X"), "X")); } @TestFactory Stream<DynamicTest> disallowedCharacters() { String disallowed = ";-_()*&^%$#@!~`0123456789"; return testVersions( "Disallowed characters", inverter -> { String result = disallowed.chars() .mapToObj(c -> { String cc = Character.toString((char)c); try { inverter.invert(cc); return ""; } catch(RuntimeException e) { return cc; } }).collect(Collectors.joining("")); if(result.length() == 0) return "success"; System.out.println("Bad characters: " + result); return "fail"; } ); } @TestFactory Stream<DynamicTest> allowedCharacters() { String lowcase = "abcdefghijklmnopqrstuvwxyz ,."; String upcase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ ,."; return testVersions( "Allowed characters (should succeed)", inverter -> { assertEquals(inverter.invert(lowcase), upcase); assertEquals(inverter.invert(upcase), lowcase); return "success"; } ); } @TestFactory Stream<DynamicTest> lengthNoGreaterThan30() { String str = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; assertTrue(str.length() > 30); return testVersions( "Length must be less than 31 (throws exception)", inverter -> inverter.invert(str) ); } @TestFactory Stream<DynamicTest> lengthLessThan31() { String str = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; assertTrue(str.length() < 31); return testVersions( "Length must be less than 31 (should succeed)", inverter -> inverter.invert(str) ); } } ~~~ 在一般的测试中,你可能认为在进行一个结果为失败的测试时应该停止代码构建。 但是在这里,我们只希望系统报告问题,但仍然继续运行,以便你可以看到不同版本的**StringInverter**的效果。 每个使用**@TestFactory**注释的方法都会生成一个**DynamicTest**对象的**Stream**(通过**testVersions()**),每个 JUnit 都像常规的**@Test**方法一样执行。 现在测试都已经准备好了,我们就可以开始实现 \*\*StringInverter \*\*了。 我们从一个仅返回其参数的假的实现类开始: ~~~ // validating/Inverter1.java package validating; public class Inverter1 implements StringInverter { public String invert(String str) { return str; } } ~~~ 接下来我们实现反转操作: ~~~ // validating/Inverter2.java package validating; import static java.lang.Character.*; public class Inverter2 implements StringInverter { public String invert(String str) { String result = ""; for(int i = 0; i < str.length(); i++) { char c = str.charAt(i); result += isUpperCase(c) ? toLowerCase(c) : toUpperCase(c); } return result; } } ~~~ 现在添加代码以确保输入不超过30个字符: ~~~ // validating/Inverter3.java package validating; import static java.lang.Character.*; public class Inverter3 implements StringInverter { public String invert(String str) { if(str.length() > 30) throw new RuntimeException("argument too long!"); String result = ""; for(int i = 0; i < str.length(); i++) { char c = str.charAt(i); result += isUpperCase(c) ? toLowerCase(c) : toUpperCase(c); } return result; } } ~~~ 最后,我们排除了不允许的字符: ~~~ // validating/Inverter4.java package validating; import static java.lang.Character.*; public class Inverter4 implements StringInverter { static final String ALLOWED = "abcdefghijklmnopqrstuvwxyz ,." + "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; public String invert(String str) { if(str.length() > 30) throw new RuntimeException("argument too long!"); String result = ""; for(int i = 0; i < str.length(); i++) { char c = str.charAt(i); if(ALLOWED.indexOf(c) == -1) throw new RuntimeException(c + " Not allowed"); result += isUpperCase(c) ? toLowerCase(c) : toUpperCase(c); } return result; } } ~~~ 你将从测试输出中看到,每个版本的**Inverter**都几乎能通过所有测试。 当你在进行测试优先的开发时会有相同的体验。 **DynamicStringInverterTests.java**仅是为了显示 TDD 过程中不同**StringInverter**实现的开发。 通常,你只需编写一组如下所示的测试,并修改单个**StringInverter**类直到它满足所有测试: ~~~ // validating/tests/StringInverterTests.java package validating; import java.util.*; import java.util.stream.*; import org.junit.jupiter.api.*; import static org.junit.jupiter.api.Assertions.*; public class StringInverterTests { StringInverter inverter = new Inverter4(); @BeforeAll static void startMsg() { System.out.println(">>> StringInverterTests <<<"); } @Test void basicInversion1() { String in = "Exit, Pursued by a Bear."; String out = "eXIT, pURSUED BY A bEAR."; assertEquals(inverter.invert(in), out); } @Test void basicInversion2() { expectThrows(Error.class, () -> { assertEquals(inverter.invert("X"), "X"); }); } @Test void disallowedCharacters() { String disallowed = ";-_()*&^%$#@!~`0123456789"; String result = disallowed.chars() .mapToObj(c -> { String cc = Character.toString((char)c); try { inverter.invert(cc); return ""; } catch(RuntimeException e) { return cc; } }).collect(Collectors.joining("")); assertEquals(result, disallowed); } @Test void allowedCharacters() { String lowcase = "abcdefghijklmnopqrstuvwxyz ,."; String upcase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ ,."; assertEquals(inverter.invert(lowcase), upcase); assertEquals(inverter.invert(upcase), lowcase); } @Test void lengthNoGreaterThan30() { String str = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; assertTrue(str.length() > 30); expectThrows(RuntimeException.class, () -> { inverter.invert(str); }); } @Test void lengthLessThan31() { String str = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; assertTrue(str.length() < 31); inverter.invert(str); } } ~~~ 你可以通过这种方式进行开发:一开始在测试中建立你期望程序应有的所有特性,然后你就能在实现中一步步添加功能,直到所有测试通过。 完成后,你还可以在将来通过这些测试来得知(或让其他任何人得知)当修复错误或添加功能时,代码是否被破坏了。 TDD的目标是产生更好,更周全的测试,因为在完全实现之后尝试实现完整的测试覆盖通常会产生匆忙或无意义的测试。