本书第二部分中主要介绍了可读性/可维护性/可靠性三个方面的code smell或者说反模式(anti-pattern)。
我们首先看可读性的code smell. 再次解释一下所谓可读性,就是代码的意图和行为能通过阅读代码就能得到准确而清晰的表达。
- 原始的断言 (Primitive assertions)
意思是assert所做的判断抽象层级过于低,是在测试代码的实现。如书中下面的例子:
@Test
public void outputHasLineNumbers() {
String content = "1st match on #1\nand\n2nd match on #3";
String out = grep.grep("match", "test.txt", content);
assertTrue(out.indexOf("test.txt:1 1st match") != -1);
assertTrue(out.indexOf("test.txt:3 2nd match") != -1);
}
其中的indexOf 和 magic number -1 都过于和java的String类的实现相关了。
修改后的版本用到了org.junit.JUnitMatchers#containsString()方法,对比之前,是不是可读性好了很多呢?
@Test
public void outputHasLineNumbers() {
String content = "1st match on #1\nand\n2nd match on #3";
String out = grep.grep("match", "test.txt", content);
assertThat(out, containsString("test.txt:1 1st match"));
assertThat(out, containsString("test.txt:3 2nd match"));
}
我们应该追求测试代码的本质,也就是正确的行为,而不是去测试代码的实现细节。
超断言(Hyperassertions)
这是指断言的内容过于具体和琐碎,或者过于脆弱,非本质的改变就会影响测试结果。或者说我们所测试的输出过于庞大,并且采用过于精细的比较去判断。
文中举得一个例子是测试一段Log代码输出,简单的判断log要和预定的一套String完全一样。这样的问题在于如果稍微改一下log的格式,比如时间显示格式(假设测试的不是时间格式对不对),测试就会通不过,而且不看输出细节还不知道到底是哪里输出错了(理想情况是希望通过方法名直接知道什么错误而不需要看output)。
不过我个人觉得,这种超断言在有些特殊情况下也是可以用的,比如一些UI测试直接用了截屏比较图片的方式,可以认为是很脆弱的超断言,因为简单的CSS改动就会导致测试通不过,但如果每个UI对象去测试的维护成本更高或者根本难以写出这种测试的话,图片比较方式也是可以接受的甚至是唯一现实可行的方案。
这在我们系统中有个例子,就是Order Interface的测试,为了简单起见,直接拿接口的xml输出去和一个认为正确的文本比较是否完全一致,虽然写起来简单,也导致了测试的脆弱性,其中利弊需要权衡。逐比特位断言 (Bitwise assertions)
这个其实也不用单独写出来,是一种特殊的原始断言,给个例子看看就明白了。
public class PlatformTest {
@Test
public void platformBitLength() {
assertTrue(Platform.IS_32_BIT ^ Platform.IS_64_BIT);
}
}
应该改成下面这样
public class PlatformTest {
@Test
public void platformBitLength() {
assertTrue("Not 32 or 64-bit platform?",
Platform.IS_32_BIT || Platform.IS_64_BIT);
assertFalse("Can’t be 32 and 64-bit at the same time.",
Platform.IS_32_BIT && Platform.IS_64_BIT);
}
}
附加的细节(Incidental details)
这个是说单段的测试代码太长了,所有东西写在一个方法里,抽象层次混杂,无关的细节太多, 让人一眼看不出代码到底想要干啥,从而可读性不好。解决办法是抽方法出来,让测试方法保持在同一个抽象级别上。具体就不说了。分裂的人格 (Split personality)
简单说来就是同一个测试方法里断言了几个不太相关的东西,可以说是不同的人格(personality)或者不同的兴趣点(interest)。当然,如何划分所谓不想关的事情需要具体情况具体分析,也可能不同人理解不同。根据不同情况,我们采取的措施可以是简单拆成不同的测试方法。如果拆方法还不够,那我们自然还可以拆成不同的测试类(相同部分可以提取抽象测试基类)。分裂的逻辑 (Split logic)
这说的是测试的代码过于长导致看了后面的忘了前面的,或者逻辑被分隔在不同的文件中,看了这个文件忘了那个文件。
书中的例子如下
public class TestRuby {
private Ruby runtime;
@Before
public void setUp() throws Exception {
runtime = Ruby.newInstance();
}
@Test
public void testVarAndMet() throws Exception {
runtime.getLoadService().init(new ArrayList());
eval("load 'test/testVariableAndMethod.rb'");
assertEquals("Hello World", eval("puts($a)"));
assertEquals("dlroW olleH", eval("puts $b"));
assertEquals("Hello World",
eval("puts $d.reverse, $c, $e.reverse"));
assertEquals("135 20 3",
eval("puts $f, \" \", $g, \" \", $h"));
}
}
上面这段代码的最大问题就在于 eval("load 'test/testVariableAndMethod.rb'"); 这句话。这个测试从外部加载了另一个ruby文件,读者不两个文件对比着看,根本不知道在测试什么。
解决办法是,如果testVariableAndMethod.rb这个文件内容不多的话,直接把文件内容插在测试代码里(inline)。如果一定要分开放的话,要和测试放在同一个目录下面,要能通过相对路径来访问。
- 魔法数(Magic number)
这个很好理解,一般来说解决方法是抽取常量。不过书中还写了一些特殊方式来解决,比如用独特的方法名来表明入参的含义。见下面这个例子
public class BowlingGameTest {
@Test
public void perfectGame() throws Exception {
roll(pins(10), times(12));
assertThat(game.score(), is(equalTo(300)));
}
private int pins(int n) { return n; }
private int times(int n) { return n; }
}
过长的初始化 (Setup sermon)
就是说写了一大堆的代码用于初始化测试所需的fixture,是一种特殊情况的Incidental details。解决方法无非抽方法,变量取好名字,保持同一方法中的抽象层级一致性。过保护的测试(Overprotective tests)
测试一些非核心的并不是你测试代码需要测试的内容。
@Test
public void count() {
Data data = project.getData();
assertNotNull(data);
assertEquals(4, data.count());
}
上面代码中的第一个null断言不需要写,因为这并不是这个测试方法所要测试的逻辑。虽然说隐式的假设data不为空不是完全正确,但是显式去判断这种无关紧要的东西会影响可读性。另外就算不写这句话,如果data为null了测试同样会抛错导致测试失败,所以也不会影响测试结果。
可读性的code smell到此介绍结束,下一章将介绍可维护性。