聊聊单元测试

遇到问题多思考、多查阅、多验证,方能有所得,再勤快点乐于分享,才能写出好文章。

一、单元测试

1. 定义与特点

单元测试(unit testing):是指对软件中的最小可测试单元进行检查和验证。

这个定义有点抽象,这里举几个单元测试的特性,大家感受一下:一般是一个函数配几个单元测试、单元测试不应该依赖外部系统、单元测试运行速度很快、单元测试不应该造成测试环境的脏数据、单元测试可以重复运行。

2. 优点

单元测试使得我们可以放心修改、重构业务代码,而不用担心修改某处代码后带来的副作用。

单元测试可以帮助我们反思模块划分的合理性,如果一个单元测试写得逻辑非常复杂、或者说一个函数复杂到无法写单测,那就说明模块的抽象有问题。

单元测试使得系统具备更好的可维护性、具备更好的可读性;对于团队的新人来说,阅读系统代码可以从单元测试入手,一点点开始后熟悉系统的逻辑。

3. 本文要解决的痛点

  1. 单测何时写? 如果你的团队在坚持TDD的风格,那就是在编码之前写;如果没有,也不建议在全部业务代码编写完成之后再开始补单元测试。单元测试比较(最)合适的时机是:一块业务逻辑写完后,跟着写几个单元测试验证下。
  2. 单测怎么写? 分层单测:数据库操作层、中间件依赖层、业务逻辑层,各自的单元测试各自写,互相不要有依赖。
  3. 单测运行太慢?
  • dao层测试,使用H2进行测试,做独立的BaseH2Test、独立的test-h2-applicationContext.xml,只对dao的测试
  • service层测试,依赖mockito框架,使用@RunWith(MockitoJUnitRunner.class)注解,就无需加载其他spring bean,具体用法
  • 对于依赖外部的中间件(例如redis、diamond、mq),在处理单测的时候要注意分开加载和测试,尤其是与dao的测试分开

二、Spring项目中的单元测试实践

我们基于unit-test-demo这个项目进行单元测试的实践。

1. dao层单元测试

最开始写单测的时候,要连着DEV的数据库,这时候会有两个烦恼:网络有问题的时候单测运行不通过、数据库里造成脏数据的时候会导致应用程序异常。这里我们选择H2进行DAO层的单元测试。有如下几个步骤:

  • 在resources下新建目录h2,存放schema.sql和data-prepare-user.sql文件,前者用于保存建表语句,后者用于准备初始数据
  • test-data-source.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:tx="http://www.springframework.org/schema/tx"
       xmlns:jdbc="http://www.springframework.org/schema/jdbc"   xsi:schemaLocation="http://www.springframework.org/schema/beans
       http://www.springframework.org/schema/beans/spring-beans.xsd
       http://www.springframework.org/schema/jdbc
       http://www.springframework.org/schema/jdbc/spring-jdbc.xsd">


    <!-- 初始化数据表结构 -->
    <jdbc:initialize-database data-source="dataSource">
        <jdbc:script location="classpath:h2/schema.sql" encoding="UTF-8"/>
        <jdbc:script location="classpath:h2/data-prepare-*.sql" encoding="UTF-8"/>
    </jdbc:initialize-database>

    <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"
          init-method="init" destroy-method="close">
        <property name="url" value="${user.jdbc.url}"/>
        <property name="username" value="${user.jdbc.username}"/>
        <property name="password" value="${user.jdbc.password}"/>
        <!-- 连接池初始连接数 -->
        <property name="initialSize" value="3"/>
        <!-- 允许的最大同时使用中(在被业务线程持有,还没有归还给druid) 的连接数 -->
        <property name="maxActive" value="30"/>
        <!-- 允许的最小空闲连接数,空闲连接超时踢除过程会最少保留的连接数 -->
        <property name="minIdle" value="3"/>
        <!-- 从连接池获取连接的最大等待时间 5 秒-->
        <property name="maxWait" value="5000"/>

        <!-- 强行关闭从连接池获取而长时间未归还给druid的连接(认为异常连接)-->
        <property name="removeAbandoned" value="true"/>
        <!-- 异常连接判断条件,超过180 秒 则认为是异常的,需要强行关闭 -->
        <property name="removeAbandonedTimeout" value="180"/>

        <!-- 从连接池获取到连接后,如果超过被空闲剔除周期,是否做一次连接有效性检查 -->
        <property name="testWhileIdle" value="true"/>
        <!-- 从连接池获取连接后,是否马上执行一次检查 -->
        <property name="testOnBorrow" value="false"/>
        <!-- 归还连接到连接池时是否马上做一次检查 -->
        <property name="testOnReturn" value="false"/>
        <!-- 连接有效性检查的SQL -->
        <property name="validationQuery" value="SELECT 1"/>
        <!-- 连接有效性检查的超时时间 1 秒 -->
        <property name="validationQueryTimeout" value="1"/>

        <!-- 周期性剔除长时间呆在池子里未被使用的空闲连接, 10秒一次-->
        <property name="timeBetweenEvictionRunsMillis" value="10000"/>
        <!-- 空闲多久可以认为是空闲太长而需要剔除 30 秒-->
        <property name="minEvictableIdleTimeMillis" value="30000"/>

        <!-- 是否缓存prepareStatement,也就是PSCache,MySQL建议关闭 -->
        <property name="poolPreparedStatements" value="false"/>
        <property name="maxOpenPreparedStatements" value="-1"/>

        <!-- 是否设置自动提交,相当于每个语句一个事务 -->
        <property name="defaultAutoCommit" value="true"/>
        <!-- 记录被判定为异常的连接 -->
        <property name="logAbandoned" value="true"/>
        <!-- 网络读取超时,网络连接超时 -->
        <property name="connectionProperties" value="connectTimeout=1000;socketTimeout=3000"/>
    </bean>

    <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
        <property name="dataSource" ref="dataSource"/>
        <property name="mapperLocations" value="classpath:mybatis/mapper/*Mapper.xml"/>
        <property name="typeAliasesPackage" value="org.learnjava.dq.core.dal.bean"/>
    </bean>

    <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
        <property name="basePackage" value="org.learnjava.dq.core.dal.dao"/>
        <property name="sqlSessionFactoryBeanName" value="sqlSessionFactory"/>
    </bean>
</beans>
  • test-h2-applicationContext.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:aop="http://www.springframework.org/schema/aop"
       xmlns:context="http://www.springframework.org/schema/context"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd">

    <!-- 激活自动代理功能 -->
    <aop:aspectj-autoproxy proxy-target-class="true"/>
    <!-- spring容器启动时,静态配置替换 -->
    <context:property-placeholder location="classpath*:*.properties" ignore-unresolvable="true"/>

    <context:component-scan base-package="org.learnjava.dq.core.dal.dao"/>

    <import resource="test-data-sources.xml"/>
</beans>
  • UserInfoDAOTest 这个文件是DAO层单元测试的主要内容,我只写了一个,读者朋友可以下载代码自己练习,把剩余的几个写了。

PS:这里我们只有一个DAO,所以spring容器加载就放在这个文件里了,如果DAO多的话,建议抽出一个BaseH2Test文件,这样所有的DAO单元测试只需要加载一次spring容器。

package org.learnjava.dq.core.dal.dao;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.learnjava.dq.core.dal.bean.UserInfoBean;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

import java.util.Date;
import javax.annotation.Resource;
import static org.junit.Assert.*;

/**
 * 作用:
 * User: duqi
 * Date: 2017/6/24
 * Time: 09:33
 */
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration("classpath:test-h2-applicationContext.xml")
public class UserInfoDAOTest {

    @Resource
    private UserInfoDAO userInfoDAO;

    @Test
    public void saveUserInfoBean() throws Exception {
        UserInfoBean userInfoBean = new UserInfoBean();
        userInfoBean.setUserId(1003L);
        userInfoBean.setNickname("wangwu");
        userInfoBean.setMobile("18890987675");
        userInfoBean.setSex(1);
        userInfoBean.setUpdateTime(new Date());
        userInfoBean.setCreateTime(new Date());

        int rows = userInfoDAO.saveUserInfoBean(userInfoBean);

        assertEquals(1, rows);
    }

    @Test
    public void updateUserInfoBean() throws Exception {
    }

    @Test
    public void getUserInfoBeanByUserId() throws Exception {
    }

    @Test
    public void getUserInfoBeanByMobile() throws Exception {
    }

    @Test
    public void listUserInfoBeanByUserIds() throws Exception {
    }

    @Test
    public void removeUserInfoBeanByUserId() throws Exception {
    }

}

2. service层单元测试

  • Mockito Mocktio是一个非常易用的mock框架。开发者可以依靠Mockito提供的简洁的API写出漂亮的单元测试。

Mockito is a mocking framework that tastes really good. It lets you write beautiful tests with a clean & simple API. Mockito doesn’t give you hangover because the tests are very readable and they produce clean verification errors.

  • UserInfoManagerImplTest 单元测试,不应该依赖于DAO层的执行逻辑是否正确【否则就是集成测试】,需要假设DAO的行为是什么样子,然后再看本层的逻辑是否正确。 这里使用@RunWith(MockitoJUnitRunner.class)修饰当前的单元测试类,如果有多个单元测试类的话,可以考虑抽出一个基础的BaseBizTest类。
package org.learnjava.dq.biz.manager.impl;


import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.learnjava.dq.biz.domain.UserInfo;
import org.learnjava.dq.biz.manager.UserInfoManager;
import org.learnjava.dq.core.dal.bean.UserInfoBean;
import org.learnjava.dq.core.dal.dao.UserInfoDAO;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.runners.MockitoJUnitRunner;

import static org.junit.Assert.*;

import static org.mockito.Mockito.*;

/**
 * 作用:
 * User: duqi
 * Date: 2017/6/24
 * Time: 09:55
 */
@RunWith(MockitoJUnitRunner.class)
public class UserInfoManagerImplTest {

    @Mock //用于定义被Mock的组件
    private UserInfoDAO userInfoDAO;

    @InjectMocks //用于定义待测试的组件
    private UserInfoManager userInfoManager = new UserInfoManagerImpl();

    private UserInfo userInfoToSave;

    @Before
    public void setUp() throws Exception {
        //用于初始化@Mock注解修饰的组件
        MockitoAnnotations.initMocks(this);

        userInfoToSave = new UserInfo();
        userInfoToSave.setMobile("18978760099");
        userInfoToSave.setUserId(7777L);
        userInfoToSave.setSex(1);
    }

    @Test
    public void saveUserInfo_case1() throws Exception {
        //step1 准备数据和动作
        doReturn(1).when(userInfoDAO).saveUserInfoBean(any(UserInfoBean.class));

        //step2 运行待测试模块
        Boolean res = userInfoManager.saveUserInfo(userInfoToSave);

        //step3 验证测试结果
        assertTrue(res);
    }

    @Test
    public void saveUserInfo_case2() throws Exception {
        //step1 准备数据和动作
        doReturn(0).when(userInfoDAO).saveUserInfoBean(any(UserInfoBean.class));

        //step2 运行待测试模块
        Boolean res = userInfoManager.saveUserInfo(userInfoToSave);

        //step3 验证测试结果
        assertFalse(res);
    }

    @Test
    public void updateUserInfo() throws Exception {
    }

    @Test
    public void getUserInfoByUserId() throws Exception {
    }

    @Test
    public void getUserInfoByMobile() throws Exception {
    }

    @Test
    public void listUserInfoByUserIds() throws Exception {
    }

    @Test
    public void removeUserInfoByUserId() throws Exception {
    }

}
  • Mockito要点
    • MockitoJUnitRunner:用于提供单元测试运行的容器环境
    • Mock:用于模拟待测试模块中依赖的外部组件
    • InjectMock:用于标识待测试组件
    • org.mockito.Mockito.*:这个类里的方法可以用于指定Mock组件的预期行为,包括异常处理。

三、总结

  1. 单元测试的三个步骤
  • 准备数据、行为
  • 测试目标模块
  • 验证测试结果
  1. 除了本文中提到的Junit、Mockito、H2,还有很多其他的单元测试框架,例如TestNGspock等。
  2. 在Java Web项目中,controller层一般不写业务逻辑,也就没有必要写单元测试,但是如果要写,也有办法,可以参考我之前的文章:在Spring Boot项目中使用Spock框架
  3. 单元测试代码也是线上代码,要和业务代码一样认真对待,也需要注意代码和测试数据的复用。

参考资料

  1. 使用Mockito的Annotation简化测试 -- 使用Mockito和JUnit【二】
  2. 单元测试的艺术
  3. 阿里巴巴 Java编码规范

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏程序员宝库

给你一份详细的 Spring Boot 知识清单

在过去两三年的Spring生态圈,最让人兴奋的莫过于Spring Boot框架。或许从命名上就能看出这个框架的设计初衷:快速的启动Spring应用。因而Spri...

1145
来自专栏邹立巍的专栏

Linux进程间通信:共享内存 (下)

使用文件或管道进行进程间通信会有很多局限性,比如效率问题以及数据处理使用文件描述符而不如内存地址访问方便,于是多个进程以共享内存的方式进行通信就成了很自然要实现...

8460
来自专栏Java技术栈

给你一份超详细 Spring Boot 知识清单

在过去两三年的Spring生态圈,最让人兴奋的莫过于Spring Boot框架。或许从命名上就能看出这个框架的设计初衷:快速的启动Spring应用。因而Spri...

1182
来自专栏Android开发与分享

【Android】Retrofit 2.0 的使用一、概述二、原理二、使用

一、概述 Retrofit是Square公司开发的一个类型安全的Java和Android 的REST客户端库。来自官网的介绍: A type-safe HTT...

3695
来自专栏gnnngu

经典Java面试题收集(二)

答:Servlet与CGI的区别在于Servlet处于服务器进程中,它通过多线程方式运行其service()方法,一个实例可以服务于多个请求,并且其实例一般不会...

94320
来自专栏代码GG之家

封装之路(一) BaseApp

架构基于 Dagger2 + RxJava + Retrofit + Material Design + MVVM 目标:简化开发app难度,轻松容易的实...

2109
来自专栏安富莱嵌入式技术分享

【RL-TCPnet网络教程】第26章 RL-TCPnet之DHCP应用

本章节为大家讲解RL-TCPnet的DHCP应用,学习本章节前,务必要优先学习第25章的DHCP基础知识。有了这些基础知识之后,再搞本章节会有事半功倍的效果。

981
来自专栏JAVA同学会

Zookeeper应用之——栅栏(barrier)

barrier的作用是所有的线程等待,知道某一时刻,锁释放,所有的线程同时执行。举一个生动的例子,比如跑步比赛,所有 运动员都要在起跑线上等待,直到枪声响后,所...

811
来自专栏JAVA同学会

Zookeeper应用之——栅栏(barrier)

barrier的作用是所有的线程等待,知道某一时刻,锁释放,所有的线程同时执行。举一个生动的例子,比如跑步比赛,所有

983
来自专栏JAVA高级架构

Java面试分享(题目+答案)

2553

扫码关注云+社区

领取腾讯云代金券