单元测试+JUnit

基础的背景知识

Posted by Xiaoran on February 17, 2018

内容参考自 HollisChuang’s Blog

第一弹 第二弹 第三弹

关于单元测试的重要性,本文不再赘述了。相信很多人都知道单测的重要性。但是在日常工作中写单测的人很少。很多项目的单测覆盖率和通过率一般都很低,尤其是web项目。

本文从软件开发的生命周期开始谈起,让我们站在一个全局的角度来看一下单元测试到底扮演着怎样的角色。

1、软件开发生命周期

一个软件或者系统,从开发到上线基本要经过以下三个步骤:持续集成、持续交付、持续部署。在The Product Managers’ Guide to Continuous Delivery and DevOps 一文中详细介绍了这三个阶段。

持续集成

持续集成(Continuous Integration)是指在软件开发过程中,频繁地将代码集成到主干上,然后进行自动化测试。

我们集团内部有持续集成的平台,可以直接在上面运行自动化测试。其实如果没有这样的平台,开发人员也可以做持续集成。

其实持续集成就是git commit + mvn clean install。注意,这里的maven命令不要加-Dtest.skip

持续交付

持续交付(Continuous Delivery)是指在持续集成的基础上,将集成后的代码部署到更贴近真实运行环境的“类生产环境”(production-like environments)中。比如,我们完成单元测试后,可以把代码部署到连接数据库的 Staging 环境中进行更多的测试。如果代码没有问题,可以继续手动部署到生产环境中。

这一步是在上线前的最后一次测试了,是在一个和生成环境一样的环境中进行测试。

持续部署

持续部署(Continuous Deploy)是在持续交付的基础上,把部署到生产环境的过程自动化。持续部署和持续交付的区别就是最终部署到生产环境是自动化的。


上面这三个阶段,都提到了一个词,那就是自动化测试。

自动化指软件测试的自动化,软件测试就是在预设条件下运行系统或应用程序,评估运行结果,预先条件应包括正常条件和异常条件。

2、软件测试

现在我们知道,一个软件的从无到有或者每一次迭代都需要通过持续集成、持续交付、持续部署三个阶段。而每一个阶段都伴随着自动化测试,这足以看出测试的重要性。既然提到自动化的软件测试,那么我就要搞清楚到底什么是软件测试。

软件测试的经典定义是:在规定的条件下对程序进行操作,以发现程序错误,衡量软件质量,并对其是否能满足设计要求进行评估的过程。

目前,被普遍认可的软件测试包括以下几个阶段:

  • 单元测试
    • 单元测试是对软件组成单元进行测试,其目的是检验软件基本组成单位的正确性,测试的对象是软件设计的最小单位:函数。
  • 集成测试
    • 集成测试也称综合测试、组装测试、联合测试,将程序模块采用适当的集成策略组装起来,对系统的接口及集成后的功能进行正确性检测的测试工作。其主要目的是检查软件单位之间的接口是否正确,集成测试的对象是已经经过单元测试的模块。
  • 系统测试
    • 系统测试主要包括功能测试、界面测试、可靠性测试、易用性测试、性能测试。 功能测试主要针对包括功能可用性、功能实现程度(功能流程&业务流程、数据处理&业务数据处理)方面测试。
  • 回归测试
    • 回归测试指在软件维护阶段,为了检测代码修改而引入的错误所进行的测试活动。回归测试是软件维护阶段的重要工作,有研究表明,回归测试带来的耗费占软件生命周期的1/3总费用以上。

在整个软件测试的生命周期中,以单元测试开始,然后依次是集成测试、系统测试、回归测试。软件测试的四个阶段,无论是复杂性还是成本,都是依次递增的。

3、单元测试

至此,我们知道,整个软件的上线过程中,都要进行自动化的软件测试。而软件测试主要有四个阶段:单元测试、集成测试、系统测试、回归测试。

那么,单元测试作为测试的第一个阶段,是发现和解决问题需要付出的成本最小的。那么,到底什么是单元测试?

单元测试(英语:Unit Testing)又称为模块测试, 是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。

其实,可以很简单的说,就是我们在写代码的时候,对我们写出来的每一个重要的方法进行测试。

4、黑盒测试与白盒测试

软件测试,从测试方法上来区分可以分为黑盒测试、白盒测试和灰盒测试。

黑盒测试

黑盒测试,也称为功能测试。测试者不了解程序的内部情况,不需具备应用程序的代码、内部结构和编程语言的专门知识。只知道程序的输入、输出和系统的功能,这是从用户的角度针对软件界面、功能及外部结构进行测试,而不考虑程序内部逻辑结构。测试案例是依应用系统应该做的功能,照规范、规格或要求等设计。测试者选择有效输入和无效输入来验证是否正确的输出。

此测试方法可适合大部分的软件测试,如集成测试以及系统测试

黑盒测试主要是为了发现以下几类错误:

是否有不正确或遗漏的功能? 在接口上,输入是否能正确的接受?能否输出正确的结果? 是否有数据结构错误或外部信息(例如数据文件)访问错误? 性能上是否能够满足要求? 是否有初始化或终止性错误?

白盒测试

白盒测试又称透明盒测试、结构测试等。测试应用程序的内部结构或运作,而不是测试应用程序的功能(即黑盒测试)。在白盒测试时,以编程语言的角度来设计测试案例。测试者输入数据验证数据流在程序中的流动路径,并确定适当的输出,类似测试电路中的节点。测试者了解待测试程序的内部结构、算法等信息,这是从程序设计者的角度对程序进行的测试。

白盒测试可以应用于单元测试集成测试和系统的软件测试流程。

白盒测试主要是想对程序模块进行如下检查:

对程序模块的所有独立的执行路径至少测试一遍。 对所有的逻辑判定,取“真”与取“假”的两种情况都能至少测一遍。 在循环的边界和运行的界限内执行循环体。 测试内部数据结构的有效性,等等。

灰盒测试

灰盒测试,是介于白盒测试与黑盒测试之间的一种测试,灰盒测试多用于集成测试阶段,不仅关注输出、输入的正确性,同时也关注程序内部的情况。灰盒测试不像白盒那样详细、完整,但又比黑盒测试更关注程序的内部逻辑,常常是通过一些表征性的现象、事件、标志来判断内部的运行状态。

5、对代码做白盒测试

上面介绍了软件测试中的黑盒、白盒和灰盒测试。白盒测试被广泛的使用在单元测试阶段。

这里我们先来分析下,我们要进行单元测试,需要做哪些事情?因为单元测试的主要手段是白盒测试,白盒测试的测试方法是:测试者输入数据验证数据流在程序中的流动路径,并确定适当的输出。那么整个测试流程大概需要包含以下几个步骤:

  • 初始化测试环境、准备测试数据。
  • 调用需要被测试的单元。
  • 收集结果,并与期望值比较。
  • 测试数据清理。

以上四个步骤在每个单元在被测试的时候都需要被执行。举个例子,我们有一个除法运算的方法,我们要对他做单元测试。

public class Calculator{
    public float divide(float divisorfloat dividend){
        return divisor/dividend;
    }
}

我们要在程序中验证上面这个方法的正确性,一般会写以下代码来测试他:

public class CalculatorTest{
    public static void main(String [] args){
        Calculator calculator = new Calculator();
        float result = calculator. divide(10.0,2.0);
        if(result == 5.0){
            System.out.println("divide test ok");
        }else{
            System.out.println("divide test failed");
        }
    }
}

这只是对该方法测试的第一个测试,如果我想测试这个方法在被除数是0的情况下会怎么样,那么我就要再写一个CalculatorTest2,然后重写写一个main方法,再重新定义一个Calculator对象,然后在调用divide方法的时候把第二个参数的值传为0。

其实上面的测试是存在很大问题的,因为在内存中并无法精确的存储浮点数,当我们把两个浮点数相除的时候结果并不一定可以精确的存储下来,而我们的逾期结果却是一个精确值,这样的比较可能会不相等的。但是这样的情况需要多个case才有可能被发现。

所以,我们在测试一个类中的一个方法的时候,可能要定义大量的类,然后需要分别执行,并且通过看控制台的输出才能确认结果。

这里,请先记住这些问题,因为,接下来我们要介绍的测试框架会帮我们解决这些问题的。

6、单元测试框架

通常,在没有特定框架支持下,我们在对一个方法进行单元测试的时候,无外乎是使用分支判断、异常处理、流程控制等来控制代码的执行,通过程序输出来表示方法的执行成功和失败。这样存在的最大问题就是我们每执行完一个单测之后,都要去控制台看输出才知道单元测试有没有成功,这明显是不合理的,因为单元测试是需要自动化执行的,程序没办法帮我们检查输出是否正确的。

单元测试框架就解决了这个问题,一旦使用了框架,加入单元测试相对来说会简单许多。通常,Java中常用的单元测试框架一般包含三个功能:测试工具、测试套件、测试运行器。

  • 测试工具
    • 测试工具是一整套固定的工具用于基线测试。测试工具的目的是为了确保测试能够在共享且固定的环境中运行,因此保证测试结果的可重复性。一般负责初始化测试环境、准备测试数据和测试数据清理。
  • 测试套件
    • 测试套件意味捆绑几个测试案例并且同时运行。
  • 测试运行器
    • 用于执行测试案例。一般负责调用需要被测试的单元、收集结果、并与期望值比较。

除了以上这些功能之外,针对不同的功能,一般还会提供很多API和语法支持。

7、JUnit

JUnit是一个目前比较流行的单测框架。

JUnit是由 Erich Gamma 和 Kent Beck 编写的一个回归测试框架(regression testing framework)。Junit测试是程序员测试,即所谓白盒测试,因为程序员知道被测试的软件如何(How)完成功能和完成什么样(What)的功能。

现在很多IDE中都已经集成了JUnit,当我们在创建maven项目的时候,一般在pom文件中也会自动增加junit的依赖。

<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <scope>test</scope>
    <version>4.4</version>
</dependency>

8、上手JUnit

要测试,要先有被测代码(当然,基于测试编程可以先有测试代码)。先来看我们想要测试的代码:

public class CaculateService {
    public float divide(float divisor, float dividend) {
        return divisor / dividend;
    }
}

我们想要测试这个类,那么如何使用Junit进行测试呢?先来写一个测试类。如果你使用的是IntelliJ+Mac,那么可以在类名上使用快捷键option+enter直接生成测试类,这样IDE会帮忙生成一个对应的测试类。(其他操作系统和IDE也有同样的功能)

生成后的测试代码和被测代码所处路径如下:

可以看到,一般的maven项目中,会在src/main下面有两个目录,javatestjava目录中放的是源码,test目录中放的是测试代码。测试代码和测试代码的包名保持一致即可。

测试代码如下:

public class CaculateServiceTest {
    CaculateService caculateService = new CaculateService();
    @Test
    public void testDivide() throws Exception {
       Assert.assertEquals(caculateService.divide(2, 1), 2.0);
    }
}

然后执行该方法就可以了,先不管Assert.assertEquals的用法及结果,这里总结下使用JUnit写测试代码的简单步骤:

  • 创建一个名为 CaculateServiceTest.java 的测试类
  • 向测试类中添加名为 testDivide() 的方法
  • 向方法中添加 Annotaion @Test
  • 执行测试条件并且应用 Junit 的 assertEquals API 来检查

9、JUnit中的Assert

public class Assert extends java.lang.Object

这个类提供了一系列的编写测试的有用的声明方法。只有失败的声明方法才会被记录。

  • void assertEquals(boolean expected, boolean actual)
    • 检查两个变量或者等式是否平衡
  • void assertFalse(boolean condition)
    • 检查条件是假的
  • void assertNotNull(Object object)
    • 检查对象不是空的
  • void assertNull(Object object)
    • 检查对象是空的
  • void assertTrue(boolean condition)
    • 检查条件为真
  • void fail()
    • 在没有报告的情况下使测试不通过

这些方法我就不一一介绍了,相信我的读者们都能看懂并在平时开发中用的到,还是比较容易理解的。

Assert可以用来判断方法的真是结果和预期结果是否一样。是我们在写单元测试中用到最多的一个api。

10、JUnit中的注解

  • @BeforeClass:针对所有测试,只执行一次,且必须为static void
  • @Before:初始化方法
  • @Test:测试方法,在这里可以测试期望异常和超时时间
  • @After:释放资源
  • @AfterClass:针对所有测试,只执行一次,且必须为static void
  • @Ignore:忽略的测试方法

一个单元测试类执行顺序为:

@BeforeClass –> @Before –> @Test –> @After –> @AfterClass

每一个测试方法的调用顺序为:

@Before –> @Test –> @After

11、时间测试

如果一个测试用例比起指定的毫秒数花费了更多的时间,那么 Junit 将自动将它标记为失败。timeout 参数和 @Test 注释一起使用。现在让我们看看活动中的 @test(timeout)。

@Test(timeout = 1000)
public void testTimeoutSuccess() {
    // do nothing
}

12、异常测试

你可以测试代码是否它抛出了想要得到的异常。expected 参数和 @Test 注释一起使用。现在让我们看看活动中的 @Test(expected)。

@Test(expected = NullPointerException.class)
public void testException() {
    throw new NullPointerException();
}

13、所有测试代码

package com.hollischuang.effective.unitest.service;

import org.junit.*;

/**
 * @author Hollis 17/1/7.
 */
public class JUnitTest {

    /**
     * 只执行一次,在整个类执行之前执行
     */
    @BeforeClass
    public static void beforeClass() {
        System.out.println("in before class");
    }

    /**
     * 只执行一次,在整个类执行之后执行
     */
    @AfterClass
    public static void afterClass() {
        System.out.println("in after class");
    }

    /**
     * 每个测试方法被执行前都被执行一次
     */
    @Before
    public void before() {
        System.out.println("in before");
    }

    /**
     * 每个测试方法被执行后都被执行一次
     */
    @After
    public void after() {
        System.out.println("in after");
    }

    // test case 1
    @Test
    public void testCase1() {
        System.out.println("in test case 1");
    }

    // test case 2
    @Test
    public void testCase2() {
        System.out.println("in test case 2");
    }

    /**
     * 测试assertEquals
     */
    @Test
    public void testEquals() {
        Assert.assertEquals(1 + 2, 3);
    }

    /**
     * 测试assertTrue
     */
    @Test
    public void testTrue() {
        Assert.assertTrue(1 + 2 == 3);
    }

    /**
     * 测试assertFalse
     */
    @Test
    public void testFals() {
        Assert.assertFalse(1 + 2 == 4);
    }

    /**
     * 测试assertNotNull
     */
    @Test
    public void assertNotNull() {
        Assert.assertNotNull("not null");
    }

    /**
     * 测试assertNull
     */
    @Test
    public void assertNull() {
        Assert.assertNull(null);
    }

    /**
     * 测试fail和Ignore
     */
    @Test
    @Ignore
    public void assertFail() {
        Assert.fail();
    }

    /**
     * 测试异常
     */
    @Test(expected = NullPointerException.class)
    public void testException() {
        throw new NullPointerException();
    }

    /**
     * 测试时间
     */
    @Test(timeout = 1000)
    public void testTimeoutSuccess() {
        // do nothing
    }

    /**
     * 测试时间
     */
    @Test(timeout = 1000)
    public void testTimeoutFailed() {
        while (true) {

        }
    }
}