首页 » 编写高质量代码:改善Java程序的151个建议 » 编写高质量代码:改善Java程序的151个建议全文在线阅读

《编写高质量代码:改善Java程序的151个建议》建议145:不要完全依靠单元测试来发现问题

关灯直达底部

单元测试的目的是保证各个独立分割的程序单元的正确性,虽然它能够发现程序中存在的问题(或缺陷,或错误),但是单元测试只是排查程序错误的一种方式,不能保证代码中的所有错误都能被单元测试挖掘出来,原因有以下四点。

(1)单元测试不可能测试所有的场景(路径)

单元测试必须测试的三种数据场景是:正常场景、边界场景、异常场景。一般情况下,如果这三种测试场景都能出现预期的结果,则认为代码正确,但问题是代码是人类思维的直观表达,要想完整地测试它就必须写出比生产代码多得多的测试代码,例如有这样一个类:


public class Foo{

//除法计算

public int pid(int a, int b){

return a/b;

}

}


就这一个简单的除法计算,如果我们要进行完整的测试就必须建立三个不同的测试场景:正常数据场景,用来测试代码的主逻辑;边界数据场景,用来测试代码(或数据)在边界的情况下逻辑是否正确;异常数据场景,用来测试出现异常非故障时能否按照预期运行,测试类如下:


public class FooTest{

//构建测试对象

private Foo foo=new Foo();

//正常测试场景

@Test

public void testDividNormal(){

//断言100除以10的结果为10

assertEquals(10,foo.pid(100,10));

}

//边界测试场景

@Test

public void testDividBroader(){

//断言最大值除以最小值结果为0

assertEquals(0,foo.pid(Integer.MAX_VALUE, Integer.MIN_VALUE));

//断言最小值除以最大值结果为-1

assertEquals(-1,foo.pid(Integer.MIN_VALUE, Integer.MAX_VALUE));

}

//异常测试场景

@Test(expected=ArithmeticException.class)

public void testDividException(){

//断言除数为0时抛出ArithmeticException

foo.pid(100,0);

//断言不会执行到这里

fail();

}

}


诸位可以看到这么简单的一个除法计算就需要如此多的测试代码,如果在生产代码中再加入就if、switch等判断语句,那它所需要的测试场景就会更加复杂了。只要有一个判断条件,就必须有两个测试场景(条件为真的场景和条件为假的场景),这也是在项目中的测试覆盖率不能达到100%的一个主要原因:单元测试的代码量远大于生产代码。通常在项目中,单元测试覆盖率很难达到60%,因为不能100%覆盖,这就导致了代码测试的不完整性,隐藏的缺陷也就必然存在了。

(2)代码整合错误是不可避免的

单元测试只是保证了分割的独立单元的正确性,它不能保证一个功能的完整性。单元测试首先会假设所有的依赖条件都满足,但真实情况并不是这样的,我们经常会发现虽然所有的单元测试都通过了,但在进行整合测试时仍然会产生大量的业务错误——很多情况下,此种错误是因为对代码的职责不清晰而引起的,这属于认知范畴,不能通过单元测试避免。

(3)部分代码无法(或很难)测试

如果把如下代码放置在一个多线程的环境下,思考一下该如何测试呢?代码如下:


class Apple{

//苹果颜色

private int color;

public int getColor(){

return color;

}

public void setColor(int color){

this.color=color;

}

}


这是一个简单的JavaBean,也是我们项目中经常出现的,对于此类BO(Business Object),通常情况下是不会进行单元测试的,想必你也会想这不用测试吧,很简单嘛,就一个getter/setter方法,出错的可能性不大。但这只是我们一厢情愿的想法,如果该Apple是在多线程环境下,你还认为不会出现线程不安全的情况吗?事实上,因为没有采用资源保护措施(synchronized或Lock),多个线程共同访问该对象时就会出现不安全的情况。现在问题来了:为什么在通常情况下不做此类对象的单元测试呢?

比如一个JEE应用,一般情况下都是多线程环境,但是我们很少对代码进行多线程测试,原因很简单,测试很复杂,很难进行全面的多线程测试。而且如果要保证在多线程下测试通过,就必须对代码增加大量的修饰,这必然会导致代码的可读性和可维护性降低,这也是我们一般都抛弃多线程测试的原因。

在Spring中,默认情况下每个注入的对象都是Singleton的,也就是单例的,每个类在内存中只有一个对象实例,这也是偶尔出现数据资源不一致现象的元凶:在多线程环境下数据未进行资源保护,特别是在系统压力较大、响应能力较低的情况下,数据资源出现不一致情况的可能性更大。

这只是一种单元测试很难覆盖的情景,还有一种情景是根本不能实施单元测试,比如不确定性算法(Nondeterministic Algorithm),什么叫不确定算法?像我们经常接触的函数f(x),给定一个确定的x值,就有确定的结果f(x),在任何时候输入x,都能获得固定的f(x),这就是确定性算法,也是我们经常接触的,但还有一种算法,比如要计算出明天通过某一个大桥的车辆数量,必须根据专家经验、天气、交通情况、是否是节庆日、是否有大型体育比赛、并行道路通行的情况等来进行计算,这些条件很多都是非确定的依据,所推导出的也是一个非确定结论的数据——明天通过大桥的车辆数量,想想看,这怎么进行单元测试,不确定算法只能无限接近而不能达到,单元测试只能对确定算法进行假设,不能对不确定算法进行验证。

(4)单元测试验证的是编码人员的假设

我们都知道单元测试是白箱测试,一般情况下测试代码是编码人员自行编写的,我们可以这样理解,编码人员根据胸中的蓝图,迅速地实行了一个算法,然后通过断言确定算法是否与预期相匹配。简单地说,我们左手画了一个圆,右手拿着一个圆规进行测试,检验这是否是一个标准的圆,但问题是是谁要求我们画一个圆的呢?谁又能确定是一个直径为2厘米的圆而不是2.1厘米的圆?——代码的意图只是反映了编码者的理解,而单元测试只是验证了这种假设。想想看,如果编码者从一开始就误解了需求意图,此时的单元测试就充当了帮凶:验证了一个错误的假设。

指出单元测试的缺陷,并不是为了说明单元测试不必要,相反,单元测试在项目中是必须的,而且是非常重要的,特别敏捷开发中提倡的TDD(Test-Driven Development)测试驱动开发:单元测试先行,而后才会编写生产代码,这可以大幅度地提升代码质量,加快项目开发的进度。