
1.3 单元测试的FIRST原则
通过1.1节和1.2节的讨论,我们已经了解到了单元测试的重要性,可是如何才能高效正确地编写单元测试,而不是使其成为开发人员的负担呢?(可能有些开发人员认为单元测试加重了自己的工作量,称之为一种“负担”。实际上在项目初期,单从源代码开发的角度来看,单元测试的编写确实增加了工作量,但是随着项目进度的深入,进行集成测试、问题重现与修复时,单元测试的优势就会凸显出来,我们将这种特性称为“早期的增负未来的减负”。)在编写单元测试代码时,应尽可能地遵循F(Fast)、I(Independent)、R(Repeatable)、S(Self-validating)、T(Thorough)原则(简称FIRST原则),该原则可以提高开发人员编写单元测试的效率,以及开发有价值的、正确的单元测试程序。
(1)快(Fast)
“快”是指单元测试的执行速度应该很快,否则就会降低编译、打包和部署的效率。通常情况下,影响单元测试执行速度的主要因素是对一些外部组件资源的依赖,比如,源代码程序依赖于数据库、网络资源、本地文件读写和中间件调用等。因此,在对须调用外部资源的源代码进行测试时,需要使用mock技术模拟真实资源的行为(本书的第二部分会讲解mock技术的使用),而不是真正地发起对外部资源的读写访问,进而提高单元测试的执行速度。
(2)独立、无依赖(Independent)
单元测试之间应该彼此独立、互不干扰,坚决不能出现互相依赖的情况,比如,某单元测试方法F2依赖于单元测试方法F1的执行结果。同时,每个单元测试在执行前后,其环境应该完全一致。比如,某单元测试方法执行后会在某个路径下生成数据文件,这就违背了单元测试执行前后一致性的原则,因为该单元测试方法在运行之前原本并没有这样的数据文件。除此之外,后来的单元测试方法由于能够看到前一个单元测试方法生成的数据文件,进而导致这两个单元测试方法拥有不一样的执行环境(JUnit的设计哲学完全遵从这样的原则,比如单元测试方法之间彼此独立,在每个单元测试方法执行前后都有对应的套件方法进行资源初始化(setUp)和测试后的环境恢复(tearDown);而测试框架testNG则允许测试方法之间互相依赖)。
程序代码1-1和程序代码1-2分别演示了JUnit 3.x和JUnit 4.x版本下的套件方法,相信大家对此已经非常熟悉了,因此这里就不做过多的解释和说明了。
程序代码1-1 JUnit 3.x套件方法
import junit.framework.TestCase; //在JUnit 3.x版本中,套件方法需要继承自TestCase基类。 public class SimpleTestSuite3 extends TestCase { //资源初始化的套件方法。每个单元测试方法在执行之前,都会调用一次该方法。 @Override protected void setUp() throws Exception { //resource initialize } //单元测试方法。在JUnit 3.x中,方法名必须以test开头,且是受public修饰的。 public void testFun() { //unit test code. } //该方法不是单元测试方法。 public void funTest() { //this method is not the unit test function } //测试后的环境恢复套件方法。每个单元测试方法在执行之后,都会调用一次该方法。 @Override protected void tearDown() throws Exception { //resource release/destroy } }
JUnit 3.x版本比较老,只有在一些较早以前的开源项目中才能见到该方法,现在几乎没有人会基于JUnit 3.x版本编写单元测试代码了。JUnit 3.x需要继承TestCase基类才能成为单元测试类,相较于这种比较烦琐的方式,JUnit 4.x则要简单得多,只需要在相应的方法上标记注解(annotation)即可,程序代码1-2演示了JUnit 4.x的套件方法。
程序代码1-2 JUnit 4.x套件方法
import org.junit.After; import org.junit.Before; import org.junit.Test; public class SimpleTestSuite { @Before public void setUp() { //resource initialize } @Test public void simpleTest() { //unit test code. } @After public void tearDown() { //resource release/destroy } }
JUnit 4.x除了提供在每个单元测试前后都会执行的套件方法@Before和@After之外,还提供了针对单元测试类的套件方法@BeforeClass和@AfterClass,但是这两个注解所标注的方法必须是类方法。单元测试类在执行所有的单元测试方法之前,首先会调用@BeforeClass标注的类方法,同样,执行完所有的单元测试方法之后,就会调用@Afterclass标注的类方法。
(3)可重复(repeatable)
单元测试的可重复性是指,每次执行单元测试时所产生的结果应该相同,为了保证测试结果的可重复性,单元测试与外部资源应尽可能地隔离开来(mock外部资源而不是直接操作和访问外部资源)。
大家可能会有这样的疑问,既然单元测试提供了初始化和资源回收的套件方法,那么是否可以在初始化访问中就执行资源的初始化操作,在测试方法中对外部资源进行操作,在资源回收方法中对测试方法产生的副作用数据进行还原,以达到单元测试方法执行前后环境一致的目的?答案是不能,虽然可以在套件方法中对外部资源进行初始化和还原,但是我们在运行单元测试的同时,很有可能其他同事也在执行单元测试,这样就会导致双方在执行单元测试的过程中相互影响。
某些项目很难避免对外部资源的依赖,比如使用了数据库持久层解决方案(比如,Hibernate、JOOQ、MyBatis)的项目,mock技术很难大规模地模拟持久层API,或者说模拟持久层API的方法的成本非常高,有点得不偿失,那么对于这种场景又该如何处理呢?这种情况通常需要使用当前单元测试执行环境的私有沙箱技术(如图1-2所示),可以利用内存数据库解决方案(比如,H2、Derby、HSQLDB 等)替代具体的外部数据库,使这些持久层解决方案能够不被mock也能正确执行(10.1.3节将有具体演示)。

图1-2 针对数据库持久层的私有沙箱技术示意图
(4)自我验证(self-validating)
每个单元测试都应该对期望的测试结果自动进行自我验证,以验证实际值与期望值是否相等,JUnit会通过一些断言语句进行自我验证,比如assertEqual(expectValue,actualValue)或assertTrue()。不过,第2章将介绍Hamcrest这样一个Matcher类库,配合assertThat()方法,可以更加灵活优雅地对单元测试中的数据进行自我验证。
(5)周密、细致、全面(thorough)
每个单元测试都应该尽可能周密、细致而又全面地覆盖源代码方法中的每一个分支,比如,单元测试需要涵盖if、else和else if,switch的所有case和default,以及每个异常的try语句块、catch语句块、finally语句块等,因为它们在不同的条件下对应着不同的逻辑处理方式。