1.引言
据我了解,我身边的大部分程序员对持续重构还是认同的,但因为担心重构(尤其是重构其他人开发的代码时)之后出现问题,如引入bug,所以,很少有人会主动去重构代码。如何保证重构不出错呢?我们不仅需要熟练掌握经典的**设计原则和设计模式**,还需要对业务和代码有足够的了解。另外,**单元测试(unit testing)**是保证重构不出错的有效手段。当重构完成之后,如果新代码仍然能够通过单元测试,就说明代码原有逻辑的正确性未被破坏,原有对外部的可见行为未变,符合重构的定义。
2.什么是单元测试
单元测试由开发工程师而非测试工程师编写,用来测试代码的正确性。相比集成测试(integration testing),单元测试的粒度更小。集成测试是一种端到端(end to send,从请求到返回所涉及的代码执行的整个路径)的测试。集成测试的测试对象是整个系统或某个功能模块,如测试用户的注册、登录功能是否正常。而单元测试是代码层级的测试,其测试对象是类或函数,用来测试类或函数是否能够按照预期执行。下面结合代码示例介绍单元测试。
public class Text{
private string content;
public Text(String content){
this.content=content;
}
public Integer toNumber(){
if(content = null || content.isEmpty()){
return null;
}
...
return null;
}
}
如果我们需要测试Text类中的 toNumber0函数,那么如何编写单元测试代吗?实际上,编写单元测试代码并不需要高深的技术,要程序员思考缜密,设计尽量覆盖所有正常情况和异常情况的测试用例,以保证代码在任何预期或非预期的情况下都能正确运行为了保证测试的全面性,针对toNumber0函数,我们需要设计如下测试用例。
1)如果字符串中只包含数字“123”,那么 toNumber() 函数输出对应的整数 123。
2)如果字符串为空或 null,那么 toNumber() 函数返回 null。
3)如果字符串中包含首尾空格:“123”“123”或“123”,那么 toNumber()返回对应的整数 123。
4)如果字符串中包含多个首尾空格:“123”“123”或“ 123”,那么toNumber()返回对应的整数 123。
5)如果字符串中包含非数字字符:“123a4”或“1234”,那么 toNumber() 返回 null.当测试用例设计好之后,接下来就是将其“翻译”成代码,具体的代码实现如下。注意下列单元测试代码没有使用任何测试框架。
public class Assert{
public static void assertEquals(Integer expectedValue, Integer actualValue){
if(actualValue !=expectedValue){
String message =String.format("Testfailed,expected:%d,actual:%d.",expectedValue,actualValue);
System.out.println(message);
}else {
System.out.println("Test succeeded.");
}
}
public static boolean assertNull(Integer actualValue){
boolean isNull= actualValue==null;
if(isNull){
System.out.println("Test succeeded.");
}else{
System.out.println("rest failed, the value is not null:" + actualvalue);
}
return isNull;
}
public class TestCaseRunner{
public static void main(String[] args){
System.out.println("Run testToNumber() ");
new TextTest().testToNumber();
System.out.println("Run testToNumber_nullorEmpty()");
new TextTest().testToNumber_nullorEmpty();
System.out.println("Run testToNumber_containsLeadingAndTrailingSpaces()");
new TextTest().testToNumber_containsLeadingAndTrailingSpaces();
System.out.printin("nun testToNumber_containsMultiLeadingAndTrailingSpaces()");
new TextTest().testToNumber_containsMultiLeadingAndTrailingSpaces();
System.out.println("Run testToNumber_containsInvalidCharaters()");
new TextTest().testToNumber_containsInvalidCharaters();
}
}
public class TextTest{
public void testToNumber(){
Text text = new Text("123");
Assert.assertEquals(123,text.toNumber());
}
public void testToNumber_nullorEmpty(){
Text textl = new Text(null);
Assert.assertNull (textl.toNumber ());
Text text2 = new Text("");
Assert.assertNull(text2.toNumber());
}
pvblic void testToNumber_containsLeadingAndTrailingSpaces(){
Text textl =new Text(” 123");
Assert.assertEquals(123,text1.toNumber());
Text text2 = new Text("123 ");
Assert.assertEquals(123, text2.toNumber());
Text text3new Text(" 123");
Assert.assertEquals(123, text3.toNumber());
}
public void testToNumber_containsMultiLeadingAndTrailingSpaces(){
Text textl=newText(”123");
Assert.assertEquals(123,text1.toNumber());
Text text2=new Text("123 ");
Assert.assertEquals(123, text2.toNumber());
Text text3=newText(”123");
Assert.assertEquals(123,text3.toNumber());
}
public void testToNumber_containsInvalidCharaters(){
Text textl = new Text("123a4");
Assert.assertNull(textl.toNumber());
Text text2 =new Text("123 4");
Assert assertNull(text2.toNumber());
}
}
3.为什么要编写单元测试代码
编写单元测试代码是提高代码质量的有效手段。在Google工作期间,作者编写了大量单元测试代码,因此,作者结合过往的开发经验,总结了单元测试的6个好处。
(1)单元测试能够帮助程序员发现代码中的 bug
编写bug free(无缺陷)的代码,是衡量程序员编码能力的重要标准,也是很多企业(尤是Google、Facebook等)面试时考察的重点。
在作者多年的工作过程中,作者坚持为自己提交的每一份代码设计完善的单元测试,得益于此,作者编写的代码几乎是bug free的。这为作者节省了很多修复低级bug 的时间,使作者能够腾出更多时间来做其他更有意义的事情。
(2)单元测试能够帮助程序员发现代码设计上的问题
在前面我们提到,代码的可测试性是评判代码质量的重要标准。如果我们在为一段代码设计单元测试时感觉吃力,需要依靠单元测试框架中的高级特性,那么往往意味着这段**代码的设计不合理,如没有使用依赖注入,大量使用静态函数和全局变量,以及代码高度耦合等**,因此,通过设计单元测试,我们可以及时发现代码设计上的问题。
(3)单元测试是对集成测试的有力补充
程序运行时出现的bug往往是在一些边界条件和异常情况下产生的,如除数未判断是否为零、网络超时等。大部分异常情况是在很难在测试环境中模拟。单元测试正好弥补了测试环境在这方面的不足,其利用**Mock方式**,控制Mock对象的返回值。模拟异常情况,以此测试代码在异常情况下的表现。
对于一些复杂系统,集成测试无法做到覆盖全面,因为复杂系统中往往有很多模块,每个模块都有各种输入、输出,以及可能出现的异常情况,如果我们将它们相互组合,那么整个统中需要模拟的测试场景会非常多,针对所有可能出现的情况设计测试用例并测试是不现实的。单元测试是对集成测试的有力补充。尽管单元测试无法完全替代集成测试,但是,如果我们能够保证每个类和函数都能按照预期执行,那么整个系统出问题的概率就会下降。
(4)编写单元测试代码的过程就是代码重构的过程
在前面提到,要把持续重构作为开发的一部分。实际上,编写单元测试代码就是一个落地执行持续重构的有效途径。在编写代码时,我们很难把所有情况都考虑清楚,编写单元测试代码就相当于我们自己对代码进行一次 **Code Review**,我们可以从中发现代码设计上的问题(如代码的可测试性不高)和代码编写方面的问题(如边界条件处理不当)等,然后有针对性地进行重构。
(5)单元测试能够帮助程序员快速熟悉代码
我们在阅读代码前,应该先了解业务背景和代码设计思路,这样阅读代码就会变得很轻松。一些程序员不喜欢编写文档和添加注释,而其编写的代码又很难做到“易读”和“易懂”在这种情况下,单元测试可以发挥文档和注释的作用。实际上,单元测试用例就是用户用例,它反映了代码的功能和使用方式。借助单元测试,我们不需要深入阅读代码,便能够知道代码实现的功能,以及我们需要考虑的特殊情况和需要处理的边界条件。
(6)单元测试是 TDD的改进方案
测试驱动开发(Test-Driven Development,TDD)是一个经常被人提及但很少被执行的开发模式。它的核心思想是测试用例先于代码编写。不过,目前想要让程序员接受和习惯这种开发模式,还是有一定难度的,因为一些程序员连单元测试代码都不愿意编写,更不用提在编写代码之前先设计测试用例了。
实际上,单元测试是TDD的改进方案:首先编写代码,然后设计单元测试,最后根据单元测试反馈的问题重构代码。这种开发流程更容易被程序员接受和落地执行。
4.如何设计单元测试
在2节介绍什么是单元测试时,我们提供了一个给toNumber()函数编写单元测试代码的例子。根据那个例子,我们可以得到一个结论:编写单元测试代码就是针对代码设计覆盖各种输入、异常和边界条件的测试用例,并将测试用例“翻译”成代码的过程。
在将测试用例“翻译”成代码时,我们可以利用单元测试框架,简化单元测试代码的编写
针对Java的单元测试框架有JUnit、TestNG和Spring Testing等。这些单元测试框架提供了通用的执行流程(如执行测试用例的TestCaseRunner)和工具类库(如各种Assert函数)等。借助它们,在编写测试代码时,我们只需要关注测试用例本身的设计。对于如何使用单元测试框架,读者可以参考单元测试框架的官方文档。
我们利用JUnit重新实现针对toNumber()函数的测试用例,重新实现之后的代码如下所示:
import org.junit.Assert;
import org.junit.Test;
public class TextTest{
public void testToNumber(){
Text text = new Text("123");
Assert.assertEquals(new Integer(123),text.toNumber());
}
public void testToNumber_nullorEmpty(){
Text textl = new Text(null);
Assert.assertNull (textl.toNumber ());
Text text2 = new Text("");
Assert.assertNull(text2.toNumber());
}
pvblic void testToNumber_containsLeadingAndTrailingSpaces(){
Text textl =new Text(” 123");
Assert.assertEquals(new Integer(123),text1.toNumber());
Text text2 = new Text("123 ");
Assert.assertEquals(new Integer(123), text2.toNumber());
Text text3new Text(" 123");
Assert.assertEquals(new Integer(123), text3.toNumber());
}
public void testToNumber_containsMultiLeadingAndTrailingSpaces(){
Text textl=newText(”123");
Assert.assertEquals(new Integer(123),text1.toNumber());
Text text2=new Text("123 ");
Assert.assertEquals(new Integer(123), text2.toNumber());
Text text3=newText(”123");
Assert.assertEquals(new Integer(123),text3.toNumber());
}
public void testToNumber_containsInvalidCharaters(){
Text textl = new Text("123a4");
Assert.assertNull(textl.toNumber());
Text text2 =new Text("123 4");
Assert assertNull(text2.toNumber());
}
};
接下来,我们探讨一下单元测试设计方面的5个问题。
1) 设计单元测试是一件耗时的事情吗?
虽然单元测试的代码量很大,有时甚至超过被测代码本身,但单元测试代码的编写并不会太耗时,因为单元测试代码的实现简单,我们不需要考虑太多代码设计上的问题,不同测试用例实现起来的差别可能不是很大,因此,我们可以在编写新的单元测试代码时,复用之前已经编写好的单元测试代码。
2) 对于单元测试代码的质量,有什么要求吗?
由于单元测试代码不在生产环境上运行,而且每个类的单元测试代码独立,不互相依赖,因此,相比业务代码,我们可以适当放低对单元测试代码的质量要求。命名稍微有些不规范,代码稍微有些重复,也都可以接受。只要单元测试能够自动化运行,不需要人工干预(如准备数据等),不会因为运行环境的变化而失败,都是合格的。
3) 单元测试只要覆盖率高就足够了吗?
单元测试覆盖是一个容易量化的指标,我们经常使用它来衡量单元测试的质量。单元测试覆盖率的统计工具有很多,如JaCoCo、Cobertura、EMMA和 Clover等。覆盖率的计算方式也有很多种,如简单的语句覆盖,以及复杂一些的条件覆盖、判定覆盖和路径覆盖等。
无论覆盖率的的计算方式多么复杂,作者认为,将盖率作为衡量单元测试质量的唯一是不合理的。实际上,我们更应该关注的是测试用例是否覆盖了所有可能的情况,特别些特殊情况。例如,针对下面这段代码,只需要一个测试用例,如cal(10.0,2.0),就可以100%的测试覆盖率,但这并不表示测试全面,因为我们还需要测试,在除数为0的情代码的执行是否符合预期。
public double cal(double a, double b){
if(b!= 0){
return a/b;
}
}
实际上,过度关注单元测试覆盖率会导致开发人员为了提高覆盖率编写很多没有必要的测试代码。例如 getter、setter方法,因为它们的逻辑简单,一般只包含赋值操作,所以没有必要为它们设计单元测试。一般来讲,项目的单元测试覆盖率达到60%~70%,即可上线。如果我们对代码质量的要求较高,那么可以适当提高对项目的单元测试覆盖率的要求。
4) 编写单元测试代码时需要了解代码的实现逻辑吗?
单元测试不需要依赖被测试函数的具体实现逻辑,它只关注被测试函数实现了什么功能。我们切不可为了追求高覆盖率,而逐行阅读代码,然后针对实现逻辑设计单元测试。否则,一旦对代码进行重构,在外部的可见行为不变的情况下,对代码的实现逻辑进行了修改,那么原本的单元测试都会运行失败,也就失去了为重构“保驾护航”的作用。
5) 如何选择单元测试框架?
编写单元测试代码并不需要使用复杂的技术,大部分单元测试框架都能满足需求。我们要在公司内部或团队内部统一单元测试框架。如果我们编写的代码无法使用已经选定的单元测试框架进行测试,那么多半是代码写得不够好。这个时候,我们要重构自己的代码,让其更容易被测试,而不是去找另一个更高级的单元测试框架。
5.为什么单元测试落地困难
虽然越来越多的人意识到单元测试的重要性,但目前真正付诸实践的并不多。据作者了解,大部分公司的项目都没有单元测试。即使一些项目有单元测试,但单元测试也不完善。
落地单元测试是一件“知易行难”的事情。
编写单元测试代码是一件考验耐心的事情。很多人往往因为单元测试代码编写起来比较繁琐且没有太多技术含量,而不愿意去做。还有很对团队刚开始推行编写单元测试时,还比较认真,执行得比较好。但当开发任务变得紧张之后,团队就开始放低对单元测试的要求,一旦出现破窗效应,大家慢慢地就跟着不写单元测试代码了。
还有的团队是因为历史原因,原来的代码都没有编写单元测试,代码已经堆砌了十几万行,不可能再逐一去补充单元测试。对于这种情况,首先,我们要保证新写的代码都要有单元测试,其次,当修改到某个类时,顺便为其补充单元测试。不过,这要求团队成员有足够强的主人翁意识,毕竟光依靠领导督促,很多事情是很难执行到位的。
除此之外,还有人觉得,有了测试团队,编写单元测试纯粹是浪费时间,没有必要。IT这一行业本该是智力密集型的,但现在,很多公司把它搞成劳动密集型的,包括一些大公司这开发的过程中,既不编写单元测试代码,又没有Code Review流程。即便有,做的也很不到位,写完代码直接提交,然后丢给黑盒测试团队去测试,测出的问题反馈给开发团队再修改,测不出的问题就留在线上出了问题再修复。
在这样的开发模式下,团队往往会觉得没必要编写单元测试,但换一个思考方式,如果我们把单元测试写好、Code Review 做好,重视起代码质量,其实可以很大程度上减少黑盒测试的时间。我在工作时,很多项目几乎没有测试团队参与,代码的正确性完全靠开发团队来保证。在这种开发模式下,线上bug反倒会很少。
只有使程序员真正感受到单元测试带来的好处,他们才会认可并使用它。
6.思考题
读者可尝试设计一个二分查找的变体算法:查找递增数组中第一个大于或等于某个给定值的元素,然后为这个算法设计单元测试用例。
本文转载自: https://blog.csdn.net/haokan123456789/article/details/139104930
版权归原作者 流星雨爱编程 所有, 如有侵权,请联系我们删除。
版权归原作者 流星雨爱编程 所有, 如有侵权,请联系我们删除。