基于Spring Boot和Junit4的单元测试进阶教程

一、教程目标

  • 学会基于AssertJ的断言技术;
  • 学会基于AssertJ-DB的数据库断言技术;
  • 学会基于JMockit的mock技术;
  • 学会内存和数据库的造数;
  • 学会集成Maven进行单元测试、集成测试的执行;
  • 学会查看测试覆盖率;

二、断言技术

断言库包含很多,比如junit自带的、hamcrest等,这里推荐使用AssertJ,看它的官网就知道了,宣称fluent assertions java library

2.1 核心库断言

AssertJ的断言采用assertThat(result)的形式,等同于then(result),这两种方式使用上没有区别;我们需要在pom中引入如下依赖:

        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.15.0</version>
            <scope>test</scope>
        </dependency>

下面列出常见的断言用法,其它的用法可以参考依赖库学习使用。

  • 对文本的断言;

    assertThat(result).isEqualTo("apple");
    assertThat(result).isEqualToIgnoringCase("apple");
    assertThat(result).contains("apple");
    assertThat(result).containsIgnoringCase("apple");
    assertThat(result).startsWith("apple");
    assertThat(result).matches("^[A-Za-z0-9]{8}$");
    assertThat(result).hasSize(10);
    assertThat(result).containsSequence("a", "p", "l");
    ...
    
  • 对数字的断言;

    assertThat(result).isGreaterThanOrEqualTo(100);
    assertThat(result).isCloseTo(100.0, Offset.offset(0.000001));
    assertThat(result).isBetween(90.0, 91.0),
    assertThat(result).isNaN();
    ...
    
  • 对日期的断言;

    assertThat(result).isAfter(startDate);
    assertThat(result).isBefore("2020-01-01");
    assertThat(result).isInSameMonthAs("2019-12-01");
    ...
    
  • 对集合的断言;

    assertThat(result).hasSize(3);
    assertThat(result).contains("apple", "orange");
    assertThat(result).doesNotcontain("apple", "orange");
    assertThat(result).containsExactly("apple", "orange");
    assertThat(result).startsWith("apple");
    assertThat(result).endsWith("orange");
    assertThat(result).doesNotContainNull();
    assertThat(result).doesNotHaveDuplicates();
    assertThat(result).isNotEmpty();
    assertThat(result).isNullOrEmpty();
    ...
    assertThat(result).hasSize(2);
    assertThat(result).containsEntry("apple", "12");
    assertThat(result).containsKeys("apple", "orange");
    assertThat(result).containsOnlyKeys("apple", "orange");
    assertThat(result).containsValues("apple", "orange");
    ...
    
  • 对对象的断言;

    assertThat(result).isEqualToComparingOnlyGivenFields(obj1, "name", "weight");
    assertThat(result).isEqualToIgnoringGivenFields(obj1,"name", "weight");
    assertThat(result).isEqualToIgnoringNullFields(obj1);
    ...
    

2.2 数据库断言

AssertJ-Core只适合为单元测试使用,如果要进行集成测试,或者只测试DAO层的SQL执行结果,就无能为力了,这是就需要用到AssertJ-DB,首先我们需要在pom中引入如下的依赖:

        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-db</artifactId>
            <version>1.3.0</version>
            <scope>test</scope>
        </dependency>

下面是一些常用的功能:

  • 数据源

    如果我们想使用SpringBoot项目中配置的数据源,比如在application.properties中的数据库配置项:

    spring.datasource.url=jdbc:postgresql://localhost:5432/mydb?useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&allowMultiQueries=true
    spring.datasource.username=postgre
    spring.datasource.password=postgre
    spring.datasource.driver-class-name=org.postgresql.Driver
    

    那么我们就需要在运行该单元测试的时候启动整个Spring Boot工程,首先需要先建立一个测试基类:

    @RunWith(SpringJUnit4ClassRunner.class)
    @SpringBootTest(classes = DailyWorkServerApplication.class, webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
    @Transactional
    @Rollback
    public class BaseTest {
        // 集成测试基类
          // 如果使用maven运行测试用例,需要在maven-surefire-plugin插件中将本基类排除执行,否则会报错,因为没有测试用例
    }
    

    然后,我们的测试基类继承该测试基类:

    public class SystemInfoDaoTest extends BaseTest {
        // 获取系统数据源
          @Autowired
        private DataSource dataSource;
        @Autowired
        private PersonDao personDao;
    
        @Test
        public void getPersonCount() {
              // 构造一个连接到数据源的Request,此处可以先略过,后面会有详细介绍
            Request request = new Request(dataSource, "select count(1) from person where name = ?", "zhangsan");
              // assertj-db执行如上Request中的SQL,对获取的数据进行断言
            assertThat(request).row(0).column().value().isEqualTo(1);
        }
    }
    

    如果你不想使用SpringBoot的数据源,需要自定义数据源,那么可以在测试类中这么写:

    public class SystemInfoDaoTest extends BaseTest {
        private Source dataSource;
        @Autowired
        private PersonDao personDao;
    
        private static final String DB_URL = "jdbc:postgresql://localhost:5432/mydb?useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&allowMultiQueries=true";
        private static final String DB_USER_NAME = "postgre";
        private static final String DB_PASSWORD = "postgre";
    
        @Before
        public void before(){
            this.dataSource = new Source(DB_URL,DB_USER_NAME,DB_PASSWORD);
        }
    
        @Test
        public void getUmCount() {
           // 构造一个连接到数据源的Request,此处可以先略过,后面会有详细介绍
            Request request = new Request(dataSource, "select count(1) from person where name = ?", "zhangsan");
              // assertj-db执行如上Request中的SQL,对获取的数据进行断言
            assertThat(request).row(0).column().value().isEqualTo(1);
        }
    }
    

    当然,还可以使用其它类型的DataSource,详细信息可以参考文末关于AssertJ-DB的官网内容。

  • Table

    当数据源连接上之后,我们可以使用如下的语句来代表某一张具体的表:

    Table table = new Table(dateSource, "person");
    
  • Request

    一个Request可以代表一个即将要执行的SQL请求:

    Request request = new Request(dataSource, "select count(1) from person where name = ?", "zhangsan");
    
  • Row

    Row是基于上面table和request的结果的某一行数据:

    // 取当前表的第二行数据
    table.row(1);
    // 取当前请求的第4行数据,然后再跳到第11行数据
    request.row(3).row(10);
    
  • Column

    Column是基于上面table和request的结果的某一列数据:

    // 取当前表的第二列数据
    table.column(1);
    // 取当前请求的第4列数据,然后再跳到第11列数据
    request.column(3).column(10);
    // 取当前请求的第2行数据,然后取当前行的第4列单元格
    request.row(1).column(3);
    
  • Value

    Value是基于Row或者Column的某一单元格中的值:

    // 取当前请求的第2行数据,然后取当前行的第4列单元格的值
    request.row(1).column(3).value();
    

    总结下来,只有DAO层的对数据库的增、删、改操作才需要使用AssertJ-DB,而查询操作是不需要的,因为查询已经将数据加载到内存中,只要使用AssertJ-Core做断言比较即可。

    关于这些常用功能的详细案例,可以参考文末的Assertj-DB文档。

PS:

实验表明,对于事务回滚控制的测试用例,assertJ-DB似乎并不能得到我们想要的结果。

如下案例中,测试用例是事务回滚的,但是使用JdbcTemplate可以得到正确的结果,但是使用assertJ-DB就不行了。只能针对非事务回滚的测试用例,assertJ-DB才能得到正确的结果。这个目前还不知道怎么解决,暂时只能用JdbcTemplate替代。

    @Test
    public void addSystemInfoTest(){
        SystemUpdateDTO systemUpdateDTO = new SystemUpdateDTO();
        systemUpdateDTO.setSysNameCN("测试-商品管理系统");
        systemUpdateDTO.setSysNameEN("test-GMS");

        // 测试DAO逻辑-插入一条数据
        systemInfoDao.addSystemInfo(systemUpdateDTO);

        String querySql = "select count(1) from dw_sys_info dsi";
        JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
        Integer rows = jdbcTemplate.queryForObject(querySql,Integer.class);
        // 1
        System.out.println("总共有" + rows);
        Request request = new Request(dataSource, querySql);
        // 0
        System.out.println("总共有" + request.getRow(0).getColumnValue(0).getValue());
    }

三、Mock技术

Mock框架有很多,古老的JMock、社区活跃的Mockito、还有我们今天要介绍的主角JMockit。

Mock技术是为了隔离被测试方法依赖的外部变量,从而可以使得测试方法的表现只受被测试方法本身的逻辑影响。举个例子:

@Service("personService")
public class PersonServiceImpl implements PersonService{
  @Autowired
  private InvokeService invokeService;
  
  @Override
  public Integer getPersonCountBySchool(String school){
    if(StringUtils.isEmpty(school)){
      return 0;
    }
    // 调用关联方获取数据的数量
    return invokeService.getPersonBySchool(school).size();
  }
  ...
}

我们如果想测试getPersonCountBySchool能否正常返回数据的数量,我们不必真的去执行invokeService.getPersonBySchool(school)调用关联方,只要使用Mock技术,让其返回我们设定的值即可:

public class PersonServiceImplTest extends BaseTest {
    @Tested
    @Autowired
    private PersonService personService;

    @Test
    public void testGetPersonCountBySchool(@Injectable InvokeService invokeService) {
        // 准备数据
        List<Person> personList = new ArrayList<>();
        Person peter = new Person("东方高中");
        Person jack = new Person("东方高中");
        personList.add(peter);
        personList.add(jack);
      
        // 模拟录制
        new Expectations(){
            {
                // 模拟调用关联方获取数据列表,无论入参是什么字符串,都返回上面准备好的列表
                invokeService.getPersonBySchool(anyString);
                result = personList;
            }
        };
        
        // 重放
        Integer personCount = personService.getPersonCountBySchool("华夏高中");
      
        // 验证
        assertThat(personCount).isEqualTo(2);
    }
}

在这里,最重要的两个注解就是@Tested和@Injectable,前者代表需要测试的类,后者代表需要mock的对象。

JMockit支持mock一个类、mock一个对象实例、mock一个对象中的某个具体的方法,甚至还可以对传入的参数进行检查,更多细节请参考文末列举的JMockit的官方文档。

四、造数技术

4.1 内存中造数

我们在运行单元测试的时候,为了满足调用参数的要求,不得不为参数对象设置值。比如,当参数对象为一个Person类的时候,倘若它的属性值不多,我们可以像上面的例子中一样,使用手动造数;但是,如果属性值很多,甚至中间还嵌套了其它对象怎么办?手动造数太繁琐了。

  • java-faker,可以对生活中常用的事物进行造数,使用简单,但无法满足复杂对象的造数;
  • easy-random,可以对复杂对象进行造数,而且可以自定义造数的值类型和范围;
  • jmockdata,可以对复杂对象进行造数,而且可以自定义造数的值类型和范围;

这些工具库的使用都非常简单,参考文末列出的官方文档看下即可。

4.2 数据库造数

我们在测试DAO层关于SQL的增删查改前,要先提供一批专供测试使用的假数据,一般有以下方式:

  • 使用内存数据库

    如果不希望测试用例的执行污染测试数据库,那么可以建立一个专为测试用例执行使用的内存数据库,缺点是,需要维护所有的建表语句。

  • 使用数据库造数工具

    可以使用DBFactory之类的造数工具,往测试数据库中提前准备数据,但是测试完成后删除数据是个问题。

  • 测试用例使用事务回滚

    好处是不会对测试数据库造成数据污染,但是需要在测试用例逻辑执行前,手动准备数据;

五、Maven集成

我们在如上的学习过程中,都是写完单元测试后直接运行了。倘若我们在提交代码前,要运行所有的单元测试该怎么操作呢?总不可能一个个地打开所有地测试类,都点击运行一遍吧。

这里介绍使用Maven的插件进行单元测试运行的集成操作。

5.1 默认配置

首先,在pom文件中引入maven-surefire-plugin,版本选择最新版:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>2.19</version>
  </plugin>

其实,这就可以了,我们使用了默认配置,执行mvn生命周期的test,就可以运行src/test/java目录下的所有单元测试和集成测试了。

5.2 跳过执行测试用例

有时候我们编译工程并不像运行测试用例,那么可以增加如下配置:

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.19</version>
                <configuration>
                    <!--跳过执行测试用例-->
                    <skipTests>true</skipTests>
                </configuration>
            </plugin>

5.3 选定运行测试用例

有些场景下,我们只想运行某一个/一类/一路径的测试用例,我们可以使用<include>来配置:

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.19</version>
                <configuration>
                    <!--只运行包含的测试-->
                    <includes>
                        <include>unit/**/*.java</include>
                    </includes>
                </configuration>
            </plugin>

如上配置表示,只运行src/test/java/unit路径下所有java结尾的测试类中的测试用例;

5.4 排除运行测试用例

有时候,我们需要排除运行一些测试用例。比如,在编译阶段,我们只想快速地运行单元测试,而不要运行集成测试,那么就可以将集成测试所在的文件路径做如下的配置:

           <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.19</version>
                <configuration>
                    <!--需要排除的测试-->
                    <excludes>
                        <!--当不需要运行集成测试时添加如下文件内容-->
                        <exclude>integration/**/*.java</exclude>
                    </excludes>

                    <!--设置并发执行测试,节约时间-->
                    <parallel>methods</parallel>
                    <threadCount>10</threadCount>
                </configuration>
            </plugin>

前提是,我们所有的集成测试类都必须放在src/test/java/integration目录下。

5.5 多线程运行测试用例

有时候,项目中的单元测试和集成测试非常多,一次执行会耗时比较久,那么可以设置多线程来执行:

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.19</version>
                <configuration>
                    <!--设置并发执行测试,节约时间-->
                    <parallel>methods</parallel>
                    <threadCount>10</threadCount>
                </configuration>

需要注意的时,要确保各测试用例之间没有调用依赖,否则便不可使用多线程的方式。

5.6 测试报告及覆盖率的查看

如果仅靠上面引入的maven-surefire-plugin插件,那么你只能在控制台看到运行的测试报告,如果要跟别人分享,十分不方便。可以通过引入maven-surefire-report-plugin插件:

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-report-plugin</artifactId>
                <version>2.12.2</version>
                <configuration>
                    <!--报告中是否显示成功率为100%的项目-->
                    <showSuccess>false</showSuccess>
                </configuration>
            </plugin>

执行其中的surefire-report:report命令,就可以重新运行所有测试用例,并在target/site目录下生成一个html测试报告。

如果想查看测试覆盖率,那么就需要引入另外一个插件cobertura-maven-plugin

            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>cobertura-maven-plugin</artifactId>
                <version>2.5.1</version>
            </plugin>

执行其中的cobertura:cobertura命令,就可以重新运行所有测试用例,并在target/site/cobertura目录下生成一个静态站点文件,找到其中的index.html,打开就可以看到各个测试覆盖率数据了。

5.7 其它配置

关于maven-surefire-plugin插件还有很多其它配置内容,可以参考文末引用自行阅读尝试。

六、经验总结

  • 测试用例的名称一定要突显被测试代码的意图,名称不一定要以“Test”结尾,可以很长,单词之间用下划线连接;
  • 要注重测试用例代码的可读性,让人一眼就能看出测试意图;
  • 测试用例中应该避免使用分支和循环,可以拆成多个测试用例;
  • 每个测试用例使用prepare-action-verify三段式结构;
  • 不要在测试用例中捕获异常,应该抛出异常或者期待异常@Test(expected=SomeException),当然还可以使用ExpectedException;
  • 测试用例不能依赖数据库中的已有数据,应该在测试用例中自己准备数据;
  • 测试完成后应该回滚数据,避免造成数据库污染,保证测试用例可以反复执行;
  • 通常不使用单元测试来测JavaBean和Controller,所以这两者中尽量也不要有业务逻辑;

七、参考文献

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容