Java SE基础巩固(十二):单元测试

548 阅读16分钟

1 概述

总所周知,测试是软件开发中一个非常重要的环节,用来验证程序运行是否符合预期(这个预期包括了程序的正确性、性能质量等),如果不符合预期,就根据测试的结果报告定位问题,修复问题,然后再次测试,这个过程往往需要重复多次,直到程序的运行状况符合预期才可以尝试发布、上线,否则就是对产品,软件不负责。

根据分类方式不同,测试可以分成不同的类型,一般最常见也是最重要的是根据开发阶段划分,可以划分出4个主要的测试类型:

  • 单元测试
  • 集成测试
  • 系统测试
  • 验收测试

本文主要介绍的就是第一个:单元测试。作为开发人员,其他三个可以不那么熟悉,但单元测试必须要非常熟悉。

下面是从维基百科上摘取的单元测试的定义:

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

可以说单元测试的目的就是检验程序的正确性,对于性能、可用性等没有要求,所以也常常说单元测试是最基本的测试,如果单元测试都无法通过,后面的测试完全没必要进行。

Java社区中有很多第三方优秀的开源测试框架,例如JUnit,Mockito,TestNG等,下面我将介绍Junit和Mockito的使用。

本文不涉及软件测试的理论知识,仅会谈到测试工具的使用。

2 JUnit

JUnit是一款非常出名的开源测试框架,甚至很多非Java开发者都或多或少听说过。Junit现在(2018-10-15)已经发布了Junit5,多了一些特性,而且最低支持的Java版本的是Java8,但本文不打算使用Junit5,而是采用JUnit4。关于JUnit5的变化,建议到官网查看。

2.1 下载安装

官网中提供了JUnit的jar包的下载地址,导入jar包即可使用。如果项目是Maven项目的话,也可以往pom.xml文件里加入junit依赖,如下所示:

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

2.2 初尝JUnit

从JUnit4开始,我们可以在需要测试的方法上加上@Test注解来表示该方法是一个待测试的方法。在JUnit3的时候,要想测试一个方法,只能使用“命名模式”将待测试方法的方法名设置成testXXX的形式,命名模式有很多缺点和不足,所以推荐大家尽量使用JUnit4之后的版本。下面是一个JUnit4的简单使用案例:

public class ApplicationTest {

    private int calculateSum(int a, int b) {
        return a + b;
    }

    @Test
    //这里的方法名只是一种习惯用法,JUnit4并不强制要求必须是testXXX
    public void testCalculate() {
        Assert.assertEquals(10, calculateSum(5, 5));       //通过
        Assert.assertEquals(10, calculateSum(20, -10));    //通过
        Assert.assertEquals(10, calculateSum(0,0));        //不通过,一般不会这样写,这里只是为了演示
        Assert.assertNotEquals(10, calculateSum(10, 10));  //通过
    }
}

有@Test注解方法是待测试方法,当程序启动的时候,会依次调用所有的待测试方法,如果在方法里抛出异常,那么该方法就算是测试失败了。Assert是org.junit包下的一个类,提供了丰富的断言API供我们使用,例如assertEquals用来断言期待值和实际值相等,assertNull用来断言参数是一个null值。在案例代码中,只有一个待测试方法,该方法的测试目标是calculateSum方法,其中的4个断言都是为了验证calculateSum方法的返回值是否符合预期,启动程序,控制台输出内容大致如下所示:


java.lang.AssertionError: 
Expected :10
Actual   :0
 <Click to see difference>


	at org.junit.Assert.fail(Assert.java:88)
	at org.junit.Assert.failNotEquals(Assert.java:834)
	at org.junit.Assert.assertEquals(Assert.java:645)
	at org.junit.Assert.assertEquals(Assert.java:631)
	at top.yeonon.ApplicationTest.testCalculate(ApplicationTest.java:21)
	.......

可以看到方法抛出了一个AssertionError异常,并打印了异常堆栈,用于定位问题所在,除此之外,JUnit还给出了一个简单的测试报告,即:

java.lang.AssertionError: 
Expected :10
Actual   :0

Expected即期待值,使我们在程序中自定义的,Actual是calculateSum的返回值,JUnit想要告诉我们的是:你期待的值是10,但实际值却是0,即不符合预期,应该尝试修复问题。

下面是一个相对比较复杂的例子(只是和上面的例子比较,实际开发中不会那么简单):

public class AppTest {

    @Test
    public void testAssertEqualAndNotEqual() {
        String name = "yeonon";
        Assert.assertEquals("yeonon", name);
        Assert.assertNotEquals("weiyanyu", name);
    }

    @Test
    public void testArrayEqual() {
        byte[] expected = "trial".getBytes();
        byte[] actual = "trial".getBytes();
        Assert.assertArrayEquals("failure - byte arrays not same", expected, actual);
    }

    @Test
    public void testBoolean() {
        Assert.assertTrue(true);
        Assert.assertFalse(false);
    }

    @Test
    public void testNull() {
        Assert.assertNull(null);
        Assert.assertNotNull(new Object());

    }

    @Test
    public void testThatHashItems() {
        Assert.assertThat(Arrays.asList("one","two","three"), CoreMatchers.hasItems("one","two"));
    }

    @Test
    public void testThatBoth() {
        Assert.assertThat("yeonon",
                CoreMatchers.both(
                        CoreMatchers.containsString("e"))
                        .and(CoreMatchers.containsString("o")));

    }
}

其实就是试试Assert的各种API,不多说了,看看方法名字大概就知道功能了。

顺便说一下,如果觉得太多的Assert和CoreMatchers看着烦,可以使用静态导入包的方式导入包,例如:

import static org.junit.Assert.*;
import static org.hamcrest.CoreMatchers.*;

JUnit的使用就是那么简单粗暴直接,这也是为什么JUnit如此火爆的原因之一。当然,JUnit不仅仅只有那么点功能,关于JUnit更高级的功能,建议到JUnit官网查看官方文档,它的文档写的还是不错的。

3 Mockito

Mockito是一款非常强大的测试框架,其最大的特点就是“Mock”,即模拟。单元测试的一个很重要的关键点就是尽量在不涉及依赖关系的情况下测试代码,尽量的模拟真实的环境去做测试。Mockito可以做到这一点,他会将用到的类包装成一个Mock对象,该Mock对象是可配置的,即可以将其行为配置成我们想要的样子。

例如在通常的Web开发中,后端会分为3层,即MVC,负责控制层的同学可能已经把控制层写好了,但负责模型层的同学还没写好,这时候控制层的同学想要对控制层的功能做测试,就可以使用Mock模拟出一个模型层(假设接口以及定义好了,只是功能还没实现),然后进行测试,这样就不需要等待负责模型层的同学写完了。

3.1 下载和安装

和JUnit一样,可以下载jar包并导入项目,如果项目是Maven项目的话,可以在pom.xml文件里加入如下依赖:

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>2.21.0</version>
    <scope>test</scope>
</dependency>

在实际用的时候,还需要加入JUnit的依赖。(但不是说mockito依赖JUnit,仅仅是项目依赖了JUnit)

3.2 简单使用

下面仅介绍一个简单例子,如下所示:

public class ApplicationTest {

    //有返回值方法
    public int calcSum(int a, int b) {
        return 1;
    }

    //无返回值方法
    public void noReturn() {

    }

    @Test
    //设置单个返回值
    public void testOneReturn() {
        ApplicationTest test = mock(ApplicationTest.class);
        when(test.calcSum(10, 10)).thenReturn(10);
        assertEquals(10,test.calcSum(10, 10));
    }

    @Test
    //设置多个返回值,按顺序校验
    public void testMultiReturn() {
        ApplicationTest test = mock(ApplicationTest.class);
        when(test.calcSum(10, 10)).thenReturn(10).thenReturn(20);
        assertEquals(10, test.calcSum(10, 10));
        assertEquals(20, test.calcSum(10, 10));
    }

    @Test
    //根据输入参数不同来定义不同的返回值
    public void testMethodParam() {
        ApplicationTest test = mock(ApplicationTest.class);
        when(test.calcSum(0,0)).thenReturn(1);
        when(test.calcSum(1,1)).thenReturn(0);
        assertEquals(1, test.calcSum(0, 0));
        assertEquals(0, test.calcSum(1, 1));
    }

    @Test
    //返回值不依赖输入
    public void testNotMethodParam() {
        ApplicationTest test = mock(ApplicationTest.class);
        when(test.calcSum(anyInt(),anyInt())).thenReturn(-1);
        assertEquals(-1, test.calcSum(10, 10));
        assertEquals(-1, test.calcSum(100, -100));
    }

    @Test
    //根据返回值的类型来决定输出
    public void testReturnTypeOfMethodParam() {
        ApplicationTest test = mock(ApplicationTest.class);
        when(test.calcSum(isA(Integer.class), isA(Integer.class))).thenReturn(-100);
        assertEquals(-100, test.calcSum(100, 100));
        assertEquals(-100, test.calcSum(111,111));
    }

    @Test
    //行为验证,主要用于验证方法是否被调用
    public void testBehavior() {
        ApplicationTest test = mock(ApplicationTest.class);
        test.calcSum(10, 10);
        test.calcSum(10, 10);
        //times(2)表示被调用两次
        verify(test, times(2)).calcSum(10, 10);
    }
}

首先,我们在每个方法里都构造了一个Mock对象,即

ApplicationTest test = mock(ApplicationTest.class);

构造完毕之后,就可以做一些配置了,拿testOneReturn方法来说,使用了when(...).thenReturn(...)的方式来对mock对象进行配置,when的参数是一个方法调用,例如test.calcSum(10, 10),threnReturn的参数就是设置该方法调用的返回值。所以when(test.calcSum(10, 10)).thenReturn(10);这行代码的意思就是“当调用test.calcSum(10,10)的时候,应该返回10”,然后调用assertEquals(10,test.calcSum(10, 10));来验证是否正确。

这里你可能会有点奇怪,代码中的calcSum无论如何都应该返回-1才对啊,那这行代码是否能通过测试呢?答案是能!因为我们使用when(...).thenReturn(...)就是在对这个方法调用做设置,即这里定义的返回值是我们自定义的,无论calcSum是如何实现的,只要我们按照when里规定的调用形式(例子中是test.calcSum(10, 10)),那么就一定会返回配对的thenReturn()里设置的值。

其他方法就不多说了,和testOneReturn()差不多,而且也做了注释,应该不难理解。

4 SpringBoot Test

4.1 简单演示

SpringBoot Test模块包含了JUnit、Mockito等依赖,在对Spring Boot项目进行测试的时候,只需要添加一个Spring Boot Test的依赖即可,如下所示:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <version>根据官网发布的版本进行选择,记得避免版本冲突</version>
    <scope>test</scope>
</dependency>

标准的Spring Boot的MVC三层代码,我就省略了,非常简单,直接来看测试类。

package top.yeonon.springtest;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.RequestBuilder;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import top.yeonon.springtest.controller.UserController;
import top.yeonon.springtest.repository.UserRepository;
import top.yeonon.springtest.service.UserService;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
 * @Author yeonon
 * @date 2018/10/15 0015 18:21
 **/
@RunWith(SpringRunner.class)
@SpringBootTest
public class UserServiceTest {

    private MockMvc mockMvc;

    @Autowired
    private UserController userController;

    @Autowired
    private UserService userService;

    @Autowired
    private UserRepository userRepository;

    @Before
    public void setUp() {
        //构造mockMvc
        mockMvc = MockMvcBuilders.standaloneSetup(userController, userService, userRepository).build();
    }

    @Test
    public void testUserService() throws Exception {
        RequestBuilder request = null;

        //1. 注册用户

        request = post("/users")
                .param("username", "yeonon")
                .param("password", "admin")
                .contentType(MediaType.APPLICATION_JSON_UTF8);

        mockMvc.perform(request)
                .andExpect(status().is(200))
                .andExpect(content().string("注册成功"));  //在业务代码中,如果成功就会返回“注册成功”;

        //2. 根据id获取用户
        request = get("/users/1");
        mockMvc.perform(request)
                .andExpect(status().is(200))
                .andExpect(content().string("{\"id\":1,\"username\":\"yeonon\",\"password\":\"admin\"}"));

        //3. 修改用户信息
        request = put("/users")
                .param("username", "weiyanyu")
                .param("password", "aaa")
                .param("id", "1");
        mockMvc.perform(request)
                .andExpect(status().is(200))
                .andExpect(content().string("更新成功"));

        //4. 再次获取信息
        request = get("/users/1");
        mockMvc.perform(request)
                .andExpect(status().is(200))
                .andExpect(content().string("{\"id\":1,\"username\":\"weiyanyu\",\"password\":\"aaa\"}"));

        //5. 删除用户
        request = delete("/users/1");
        mockMvc.perform(request)
                .andExpect(status().is(200))
                .andExpect(content().string("删除成功"));

    }
}

mockMvc是Spring封装的一个类,从名字可以看出来是针对MVC的一个模拟,实际上也确实如此。整个测试过程可以分为以下几个步骤:

  1. 构造mockMvc对象,可以通过MockMvcBuilders并传入相应的Bean(如果传入不完整,可能会导致Bean注入失败并报出空指针异常)。
  2. 获取一个RequestBuilder对象,可以通过MockMvcRequestBuilders.get(),MockMvcRequestBuilders.post()等方法获取。
  3. 将RequestBuilder对象传入mockMvc.perform()方法中,该方法会返回一个ResultActions对象,表示某种行为。
  4. 通过返回的ResultActions对象提供的API来对结果做验证,例如andExpect,andDo,andReturn等。其中andExpect接受的参数是一个ResultMatcher类型的对象,在MockMvcResultMatchers中有很多使用的方法可以供我们使用,例如status,content等。

这就完成了一次web测试。这里顺便说一下编码问题,在这个测试环境下,默认的编码方式不是UTF-8(好像是ISO-xxx,具体忘了),所以如果controller返回的有中文且不做特殊处理的话,可能会出错。一个解决方案是,修改controller中的@RequestMapping上的produces属性,如下所示:

@DeleteMapping(value = "{id}",produces = "application/json;charset=UTF-8")
public String deleteUser(@PathVariable("id") Long id) {
    return userService.deleteUser(id);
}

4.2 h2内存数据库

该小测试项目中,其实用到了h2数据库。h2是一款用Java语言开发的数据库,可直接嵌入到应用程序中,与应用程序打包发布,不受平台限制,它还支持内存模式,所以非常适合用于测试环境。一般为了方便,在测试环境使用的时候,会将项目的.sql文件载入到h2中,然后使用内存模式进行测试,在内存模式下,所有的操作都在内存中进行,不会进行持久化,所以无需担心会弄脏生产环境的数据库。

spring boot对h2也有支持,我们只需要在项目中加入h2的相关依赖并做少量配置即可使用,如下所示:

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>1.4.196</version>
</dependency>

配置如下所示:

spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:mem:h2test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
spring.datasource.username=admin
spring.datasource.password=admin

spring.jpa.database=h2
spring.jpa.hibernate.ddl-auto=update

spring.h2.console.enabled=true
spring.h2.console.path=/console

  1. datasource的常规四个配置就不多说了,不难理解。
  2. spring.jpa.database。因为项目中使用了jpa,所以这里配置一下jpa的目标数据库类型是h2。
  3. spring.h2.console.enabled。是否启用h2控制台,h2提供了一个web控制台,方便用户增删改查数据。
  4. spring.h2.console.path。控制台的路径,例如上面配置的是/console,在使用的时候就可以在浏览器地址栏输入http://localhost:port/console进入控制台。

启动项目之后,在浏览器里输入url进入h2控制台,如下所示:

iaBLm4.png

做好配置之后,输入用户名密码,点击Connect即可进入控制台界面,如下所示:

iaBO0J.png

在空白处可以输入符合SQL规范的语句对数据库进行操作,左侧边栏可以看到有一个T_USER数据库表,这是JPA帮我们创建的,在h2中,表的名字默认都是大写的,但是在写SQL语句的时候可以使用小写,h2会帮我们转换成大写形式。如下所示:

iaDC6O.png

关于h2数据库的介绍就先这样,因为h2的接口也符合JDBC规范,所以如果熟悉JDBC的话,不需要太关注h2的操作细节。

5 TDD

TDD即Test-Driven Development (测试驱动开发)。名字可能不那么好理解其意义,什么是测测试驱动开发?为什么要用测试来启动开发?测试如何驱动开发的?下面将围绕这三个问题简单介绍一下TDD。

5.1 什么是测试驱动开发

如果之前没有接触过类似的概念,大多数人对测试的认识应该是:先编写代码,完成之后再进行测试,测试的目的是检验程序的正确性、性能质量、可用性、可伸缩性等。而测试驱动开发则恰恰相反,TDD提倡的是先编写测试程序,然后编写代码满足测试成功,使得测试程序能通过,只要测试用例写的好,重构代码的时候需要考虑的事情就可以少很多,只需要让代码能通过测试即可。

5.2 为什么需要测试驱动开发

TDD和传统的先开发后测试的方式相比,至少有如下几个好处:

  • 降低开发者的负担,开发者只需要编写代码通过测试用例即可,不需要在各种乱七八糟的需求中纠结。
  • 对需求变化有很强的适应性,但需求发生变化的时候,只需要根据需求修改测试,然后再次编写或者修改代码来适应测试用例,避免“捡了芝麻,丢了西瓜”的情况发生。
  • 需求明确,提前编写测试可以督促我们理清需求,而不是写代码写到一半才发现需求不明确,导致“返工”。
  • 效率高,所谓磨刀不误砍柴工,虽然提前编写测试需要花费很长的时间和很多的精力,但这些消耗都是值得的。如果不提前编写测试,最终也需要自己进行手动测试,而手动测试又需要花时间去启动应用,在各个界面之间来回跳转,其实花费的时间比提前编写自动化测试多得多。

5.3 测试如何驱动开发

其实上面隐隐有提到过这点,但没有明确给出一个思路或者步骤,下面是TDD的基本流程:

  1. 编写一个测试用例
  2. 运行测试程序,此时应该会测试不通过
  3. 编写代码,目标是使代码能通过测试用例。
  4. 再次运行测试程序,此时如果还是测试不通过,回到步骤3,如此往复,直到测试通过。

这里有一个问题,步骤2显然是肯定会失败的(因为还没有编写具体的代码),为什么还要运行一次测试程序呢?因为失败的原因有很多,不一定就是因为还没有编写具体代码导致,也有可能是测试环境有问题导致的,所以先运行一次,查看错误报告,如果是测试环境有问题,那么就先尝试修复测试环境,否则如果在有问题的测试环境下进行开发,可能会导致无论怎么编写程序都不可能通过测试的情况(因为每次测试都会因为测试环境的问题导致测试失败)。

实际上,TDD远不止如此,还有很多很多好处,也有一些弊端,因为我本人对TDD的了解也不算多,平时因为比较懒,也没有养成先写测试的习惯,所以就不多说了,建议自行搜索相关资料进行学习,这里就当是“抛砖引玉”吧。

6 小结

本文介绍了单元测试的概念,顺带介绍了两个测试框架JUnit,Mockito的简单使用,随后还结合Spring Boot项目做了一次小实践,希望对读者有帮助。最后还简单介绍了TDD(测试驱动开发),TDD是敏捷开发中的一项核心技术,可以有效的提高开发效率和产品质量,TDD其实也算是一门学问,如果想要深入学习,推荐到这里看看。