
1.2 代码规范与单元测试
2017年,阿里巴巴发布编码规范,这是开源界的一件大事,也在知乎等平台上引发了广泛的讨论,其中有个别回复纠结于具体细节的商榷和建议,但大部分认同该规范的指导意义。本节拟从代码规范、单元测试、代码审查及审查清单谈谈笔者的一些体会。
1.2.1 编码规范
不以规矩,不能成方圆。为什么要有规范(规约)想必不用多说了。本书重在讲述程序员如何具备大局观,具备大局观所要涵盖的知识宽度和视野,因此对于具体编码规范的逐条解析不作为重点。
Google Java Style Guide包含的内容有源文件基础、源文件结构、格式、命名、编程实践和Javadoc,可以作为一个团队必须遵守的共识。一套好的规范应该搭配好的审查清单(CheckList)。《Java开发手册1.5.0》就是一个不错的融合规约和审查清单的案例,其中的单元测试一章有一条规范检查项,引用如下:
【强制】单元测试应该是全自动执行的,并且是非交互式的。测试框架通常是定期执行的,执行过程必须完全自动化才有意义。对输出结果需要人工检查的测试不是好的单元测试。在单元测试中不准使用System.out进行人肉验证,必须使用Assert进行验证。
该项可以作为一条独立的审查清单纳入CheckStyle或者PMD这样的工具来扫描静态代码。对于单元测试应该如何编写,会在下面的单元测试小节展开讨论。
1.2.2 单元测试
单元测试(Unit Testing, UT)说起来简单,在实际操作过程中却要注意它不是为了测试而测试的。如下所示是一段对Java中的字符串进行右对齐操作的代码:
/** * 扩展并右对齐字符串,用指定的字符串填充目标字符串的左边 * @param str * @param size * @param padStr * @return str */ public static String alignRight(String str, int size, String padStr) { if (str == null) { return null; } if ((padStr == null) || (padStr.length() == 0)) { padStr = " "; } int padLen = padStr.length(); int strLen = str.length(); int pads = size - strLen; if (pads <= 0) { return str; } if (pads == padLen) { return padStr.concat(str); } else if (pads < padLen) { return padStr.substring(0, pads).concat(str); } else { char[] padding = new char[pads]; char[] padChars = padStr.toCharArray(); for (int i = 0; i < pads; i++) { padding[i] = padChars[i % padLen]; } return new String(padding).concat(str); } }
测试代码如下:
@Test public void testAlignRight () { assertEquals(StringUtil.alignRight(null, 3, "a") , null); assertEquals(StringUtil.alignRight("", 3, "w") , "www"); assertEquals(StringUtil.alignRight("bat", 3, "yw") , "bat"); assertEquals(StringUtil.alignRight("bat", 5, "yw") , "ywbat"); assertEquals(StringUtil.alignRight("bat", 1, "yw") , "bat"); assertEquals(StringUtil.alignRight("bat", -1, "yw") , "bat"); }
如何采用测试驱动设计(Test-Driven Development, TDD)方式写这段业务代码呢?这里先看一下要满足的需求:按照指定的目标长度扩展并右对齐字符串,用指定的字符串填充目标字符串的左边。
所以,这个需求对应的测试用例如下。
◎ 如果源字符串为null,则无论指定目标长度为多少,结果都为null。
◎ 如果源字符串为"(" 空串),指定目标长度为3、填充字符串为"w",则结果为"www"。
◎ 如果源字符串为"bat",指定目标长度为3、填充字符串为"yw",则结果为"bat"。
◎ 如果源字符串为"bat",指定目标长度为5、填充字符串为"yw",则结果为"ywbat"。
◎ 如果源字符串为"bat",指定目标长度为1、填充字符串为"yw",则结果为"bat"。
◎ 如果源字符串为"bat",指定目标长度为-1、填充字符串为"yw",则结果为"bat"。
1.2.3 测试驱动设计
笔者在2010年做了一件现在看起来不靠谱的事情,就是把一个模块的代码测试覆盖率做到了80%,在笔者刚接手时其覆盖率是30%。为此,笔者让一位研发人员补测试补了一周,这可以算作为了覆盖率而做,但其效果如何,我们不敢抱太大希望,比如在下一次重构的时候,这些测试代码是否值得信赖。
有位咨询师提到,测试覆盖率低的病灶常常如下。
◎ 团队成员没有写测试的习惯,没有意识到写测试的重要性,不想写。
◎ 代码难于测试,不会写。
◎ 赶进度,没有时间写。
相对于提升测试的覆盖率,解决这些问题要复杂、棘手得多。笔者不确定上述问题在特定环境下的解决方法是什么,但很确定补测试不是良方,它往往会催生出没有Assert的畸形测试及大量针对getter、setter的无用测试。
这件事情给笔者一个教训,就是单元测试一定不能事后补。我们团队在项目实际操作过程中没有严格遵循测试驱动设计的步骤,但是遵循了同步编写开发代码和测试代码的思路。
测试驱动设计的基本思想就是在开发功能代码之前先编写测试代码,也就是说在明确要开发的需求之后,首先思考如何分析这个需求,并完成测试代码的编写,然后编写相关代码来满足这些测试用例,最后循环添加其他测试用例,直到该需求对应的测试用例都测试通过。
测试驱动设计有3个原则,如下所述。
◎ 原则1:无测试,不代码。
◎ 原则2:单元测试不在多,能够识别出问题即可。
◎ 原则3:代码不在多,让当前单元测试全部通过即可。
下面看看具体的操作步骤,以1.2.2节的需求为例。
第1步:金丝雀测试
“金丝雀测试”的概念来自早期的煤炭矿井行业:金丝雀对有毒气体比较敏感,在19世纪左右,英国的矿井工人在下矿井时常常会带一只金丝雀,如果矿井内的有毒气体超标,金丝雀就会立刻死亡,这会救矿井工人一命。
同样,在测试驱动设计实践中,在开发具体的测试用例之前,也需要先写一个dummy的测试用例,确保整个编译、运行和JUnit环境是正常运行的。
import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; /** * Created by brliu on 2018/5/7. */ public class StringUtilTest { @Test public void testAlignRight() throws Exception { assertTrue(false); } }
之后运行这个测试用例,结果符合预期,说明整个编译、执行和JUnit环境是好的。
第2步:编写第1个最简单的单元测试
对应的需求为:如果源字符串为null,则无论指定的目标长度为多少,结果都为null。编写测试代码如下:
import org.junit.Ignore; import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; /** * Created by brliu on 2018/5/7. */ public class StringUtilTest { @Ignore public void testAlignRight() throws Exception { assertTrue(false); } @Test public void testAlignRight_givenNull_returnsNull() throws Exception { assertEquals(MyStringUtil.alignRight(null, 3, "a"), null); } }
好,在第1个单元测试运行时出现红色,说明编译出现了问题。开始编写如下代码:
/** * Created by brliu on 2018/5/10. */ public class MyStringUtil { public static String alignRight(String src, int outputLength, String fillingString) { return null; } }
这样,第1个单元测试在运行时就是绿色的了。也许有人会问:“这段代码有用吗?”然而,从另一方面来说,这不就是实现当前需求的最简洁和最高效的实现吗?
第3步:编写第2个单元测试
对应的需求为:如果源字符串为""(空串),指定目标长度为2、填充字符串为"w",则结果为"ww"。编写单元测试,代码如下:
@Test public void testAlignRight_givenEmptyString_andFillingString_returnFillingString() throws Exception { assertEquals(MyStringUtil.alignRight("", 2, "w"), "ww"); }
测试时再次出现红色,将代码修改如下:
public class MyStringUtil { public static String alignRight(String src, int outputLength, String fillingString) { if (src == null) { return null; } String alignedStr = ""; if (src.length() == 0 && fillingString.length() == 1) { StringBuilder strBuilder = new StringBuilder(); for (int i = 0; i < outputLength; i++) { strBuilder.insert(0, fillingString); } alignedStr = strBuilder.toString(); } return alignedStr; } }
这段代码非常具体(Specific),没有通用性(Generic),但是不妨碍非常高效地满足了当前的需求。
第4步:继续加需求
对应的需求为:如果源字符串为""(空串),指定目标长度为3、填充字符串为"w",则结果为"ww"。
这个需求其实是和上一个需求等价的,所以可以把这个单元测试和上一个单元测试合并:
@Test public void testAlignRight_givenEmptyString_andFillingString_returnFillingString() throws Exception { assertEquals(MyStringUtil.alignRight("", 2, "w"), "ww"); assertEquals(MyStringUtil.alignRight("", 3, "w"), "www"); }
再运行一次,结果怎样呢?运行结果正确!
第5步:接着加需求
对应的需求为:如果源字符串为"abc",指定目标长度为3、填充字符串为"w",则结果为"abc"。
增加测试用例,代码如下:
@Test public void testAlignRight_givenSrcString_andFillingString_returnSrcStringFirst() throws Exception { assertEquals(MyStringUtil.alignRight("abc", 3, "w"), "abc"); }
运行一次单元测试,发现运行失败。看来我们要继续写代码了,再加入一个if块:
public class MyStringUtil { public static String alignRight(String src, int outputLength, String fillingString) { if (src == null) { return null; } String alignedStr = ""; if (src.length() == 0 && fillingString.length() == 1) { StringBuilder strBuilder = new StringBuilder(); for (int i = 0; i < outputLength; i++) { strBuilder.insert(0, fillingString); } alignedStr = strBuilder.toString(); } if (src.length() >= outputLength) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.insert(0, src); alignedStr = stringBuilder.toString(); } return alignedStr; } }
第6步:再次加需求
对应的需求为:如果源字符串为"abc",指定目标长度为5、填充字符串为"wxy",则结果为"wxabc"。相关测试代码如下:
@Test public void testAlignRight_givenSrcString_andFillingString_returnSrcStringFillingStrCombined () throws Exception { assertEquals(MyStringUtil.alignRight("abc", 5, "wxy"), "wxabc"); }
实现代码如下:
public class MyStringUtil { public static String alignRight(String src, int outputLength, String fillingString) { if (src == null) { return null; } String alignedStr = ""; if (src.length() == 0 && fillingString.length() == 1) { StringBuilder strBuilder = new StringBuilder(); for (int i = 0; i < outputLength; i++) { strBuilder.insert(0, fillingString); } alignedStr = strBuilder.toString(); } else if (src.length() >= outputLength) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.insert(0, src); alignedStr = stringBuilder.toString(); } else if (src.length() < outputLength) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.insert(0, src); stringBuilder.insert(0, fillingString.substring(0, outputLength - src.length())); alignedStr = stringBuilder.toString(); } return alignedStr; } }
第7步:重构,是为了更好地前行
到目前为止,我们的代码已经“生长”到30行了,现在选择重构当前代码,主要关注两方面:让代码更整洁;让应用“从特殊到一般”来泛化代码,使“算法”更清晰。
首先,为了提取“pattern”,我们发现第1个if块和第3个if块有些类似。为了让它们呈现一样的“pattern”,我们在第1个if块中加入一条dummy语句:
public class MyStringUtil { public static String alignRight(String src, int outputLength, String fillingString) { if (src == null) { return null; } String alignedStr = ""; if (src.length() == 0 && fillingString.length() == 1) { StringBuilder strBuilder = new StringBuilder(); // 加入的dummy语句 strBuilder.insert(0, src); for (int i = 0; i < outputLength; i++) { strBuilder.insert(0, fillingString); } alignedStr = strBuilder.toString(); } else if (src.length() >= outputLength) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.insert(0, src); alignedStr = stringBuilder.toString(); } else if (src.length() < outputLength) {
然后,为了发掘pattern,对下面的代码块进行重构:
if (src.length() == 0 && fillingString.length() == 1) { StringBuilder strBuilder = new StringBuilder(); // 加入的dummy语句 strBuilder.insert(0, src); for (int i = 0; i < outputLength; i++) { strBuilder.insert(0, fillingString); } alignedStr = strBuilder.toString(); }
这段代码的逻辑是,当输入的源字符串长度为零,而填充字符的长度为1时,重复利用填充字符进行填充。我们可以重构“重复利用填充字符进行填充”这段代码:
// 将重复利用填充字符进行填充的准备工作提取出来 if (fillingString.length() == 1) { StringBuilder strBuilder = new StringBuilder(); for (int i = 0; i < outputLength; i++) { strBuilder.insert(0, fillingString); } processedFillingStr = strBuilder.toString(); } else { processedFillingStr = fillingString; }
这样的话,就可以把这个if块重构为和其他if块相似的pattern:
if (src.length() == 0 && fillingString.length() == 1) { //现在这个if块就和下面第3个if块有一样的pattern,可以进行合并了 StringBuilder strBuilder = new StringBuilder(); strBuilder.insert(0, src); strBuilder.insert(0, processedFillingStr.substring(0, outputLength - src.length())); alignedStr = strBuilder.toString(); }
这样一来,就可以通过合并第1个if块和第3个if块进行重构:
if (src.length() == 0 && fillingString.length() == 1) { StringBuilder strBuilder = new StringBuilder(); strBuilder.insert(0, src); strBuilder.insert(0, processedFillingStr.substring(0, outputLength - src.length())); alignedStr = strBuilder.toString(); } else if (src.length() >= outputLength) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.insert(0, src); alignedStr = stringBuilder.toString(); } else if (src.length() < outputLength) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.insert(0, src); stringBuilder.insert(0, processedFillingStr.substring(0, outputLength - src.length())); alignedStr = stringBuilder.toString(); }
重构的结果如下:
if (src.length() >= outputLength) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.insert(0, src); alignedStr = stringBuilder.toString(); } else { StringBuilder strBuilder = new StringBuilder(); strBuilder.insert(0, src); strBuilder.insert(0, processedFillingStr.substring(0, outputLength - src.length())); alignedStr = strBuilder.toString(); }
第8步:继续加入需求
对应的需求为:如果源字符串为"abc",指定目标长度为1、填充字符串为"wxy",则结果为"abc"。相关测试代码如下:
@Test public void testAlignRight_givenSrcString_andOutputLengthLessThanSrcString_returnSrcString() throws Exception { assertEquals(MyStringUtil.alignRight("abc", 1, "wxy"), "abc"); }
运行单元测试,结果居然是通过!
第9步:继续加入需求
对应的需求为:如果源字符串为"abc",指定目标长度为-1、填充字符串为"wxy",则结果为"abc",也就是说不进行处理。相关测试代码如下:
@Test public void testAlignRight_givenSrcString_andOutputLengthLessThanSrcString_returnSrcString() throws Exception { assertEquals(MyStringUtil.alignRight("abc", 1, "wxy"), "abc"); assertEquals(MyStringUtil.alignRight("abc", -1, "wxy"), "abc"); }
运行单元测试,结果仍然是通过。
也就是说,在前面的重构中,在使用了“从特殊到一般”对代码进行泛化重构之后,代码的使用范围更广了,或者说隐藏在背后的算法显现了,这也说明测试驱动设计是可以演化出算法的。