程序员的三门课:技术精进、架构修炼、管理探秘
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

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");
          }

运行单元测试,结果仍然是通过。

也就是说,在前面的重构中,在使用了“从特殊到一般”对代码进行泛化重构之后,代码的使用范围更广了,或者说隐藏在背后的算法显现了,这也说明测试驱动设计是可以演化出算法的。