遇到问题多思考、多查阅、多验证,方能有所得,再勤快点乐于分享,才能写出好文章。
单元测试 定义与特点单元测试(unit testing) :是指对软件中的最小可测试单元进行检查和验证。
这个定义有点抽象,这里举几个单元测试的特性,大家感受一下:一般是一个函数配几个单元测试、单元测试不应该依赖外部系统、单元测试运行速度很快、单元测试不应该造成测试环境的脏数据、单元测试可以重复运行。
优点单元测试使得我们可以放心修改、重构业务代码,而不用担心修改某处代码后带来的副作用。
单元测试可以帮助我们反思模块划分的合理性,如果一个单元测试写得逻辑非常复杂、或者说一个函数复杂到无法写单测,那就说明模块的抽象有问题。
单元测试使得系统具备更好的可维护性、具备更好的可读性;对于团队的新人来说,阅读系统代码可以从单元测试入手,一点点开始后熟悉系统的逻辑。
本文要解决的痛点单测何时写? 如果你的团队在坚持TDD的风格,那就是在编码之前写;如果没有,也不建议在全部业务代码编写完成之后再开始补单元测试。单元测试比较(最)合适的时机是:一块业务逻辑写完后,跟着写几个单元测试验证下。 单测怎么写? 分层单测:数据库操作层、中间件依赖层、业务逻辑层,各自的单元测试各自写,互相不要有依赖。 单测运行太慢? dao层测试,使用H2进行测试,做独立的BaseH2Test、独立的test-h2-applicationContext.xml,只对dao的测试 service层测试,依赖mockito框架,使用@RunWith(MockitoJUnitRunner.class)注解,就无需加载其他spring bean,具体用法 对于依赖外部的中间件(例如redis、diamond、mq),在处理单测的时候要注意分开加载和测试,尤其是与dao的测试分开 Spring项目中的单元测试实践我们基于unit-test-demo 这个项目进行单元测试的实践。
dao层单元测试最开始写单测的时候,要连着DEV的数据库,这时候会有两个烦恼:网络有问题的时候单测运行不通过、数据库里造成脏数据的时候会导致应用程序异常。这里我们选择H2进行DAO层的单元测试。有如下几个步骤:
在resources下新建目录h2,存放schema.sql和data-prepare-user.sql文件,前者用于保存建表语句,后者用于准备初始数据 test-data-source.xml 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 <? 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" /> <property name ="maxActive" value ="30" /> <property name ="minIdle" value ="3" /> <property name ="maxWait" value ="5000" /> <property name ="removeAbandoned" value ="true" /> <property name ="removeAbandonedTimeout" value ="180" /> <property name ="testWhileIdle" value ="true" /> <property name ="testOnBorrow" value ="false" /> <property name ="testOnReturn" value ="false" /> <property name ="validationQuery" value ="SELECT 1" /> <property name ="validationQueryTimeout" value ="1" /> <property name ="timeBetweenEvictionRunsMillis" value ="10000" /> <property name ="minEvictableIdleTimeMillis" value ="30000" /> <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 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <? 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" /> <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容器。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 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.*;@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 { } }
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类。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 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.*;@RunWith (MockitoJUnitRunner.class)public class UserInfoManagerImplTest { @Mock private UserInfoDAO userInfoDAO; @InjectMocks private UserInfoManager userInfoManager = new UserInfoManagerImpl(); private UserInfo userInfoToSave; @Before public void setUp () throws Exception { MockitoAnnotations.initMocks(this ); userInfoToSave = new UserInfo(); userInfoToSave.setMobile("18978760099" ); userInfoToSave.setUserId(7777L ); userInfoToSave.setSex(1 ); } @Test public void saveUserInfo_case1 () throws Exception { doReturn(1 ).when(userInfoDAO).saveUserInfoBean(any(UserInfoBean.class)); Boolean res = userInfoManager.saveUserInfo(userInfoToSave); assertTrue(res); } @Test public void saveUserInfo_case2 () throws Exception { doReturn(0 ).when(userInfoDAO).saveUserInfoBean(any(UserInfoBean.class)); Boolean res = userInfoManager.saveUserInfo(userInfoToSave); 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组件的预期行为,包括异常处理。 总结单元测试的三个步骤 除了本文中提到的Junit、Mockito、H2,还有很多其他的单元测试框架,例如TestNG 、spock 等。 在Java Web项目中,controller层一般不写业务逻辑,也就没有必要写单元测试,但是如果要写,也有办法,可以参考我之前的文章:在Spring Boot项目中使用Spock框架 。 单元测试代码也是线上代码,要和业务代码一样认真对待,也需要注意代码和测试数据的复用。 参考资料使用Mockito的Annotation简化测试 – 使用Mockito和JUnit【二】 单元测试的艺术 阿里巴巴 Java编码规范 Thoughts, stories and ideas.