说到 Junit,很多人都知道非常强大的代码逻辑检测框架,但在平时项目中,我发现两个问题:
假设我们要测试个除法运算,如div(a,b) 那么就要针对c=a/b做分别的假设和预期结果
public class MathService {
/**
* c = a/b
*/
public int div(int a, int b) {
return a / b;
}
}
@Test
public void addIfAandBareNormal() {
int c = mathService.div(10, 5);
Assert.assertEquals("验证div失败", 2, c);
}
//预期为异常的写法注意一下,最好写精确是什么异常,不要直接写Exception
@Test(expected = ArithmeticException.class)
public void addIfBIsZero() {
mathService.div(10, 0);
}
@Test
public void addIfAIsZero() {
int c = mathService.div(0, 10);
Assert.assertEquals("验证div失败", 0, c);
}
@Test
public void addIfAisPrime() {
int c = mathService.div(17, 8);
Assert.assertEquals("验证div失败", 2, c);
}
执行效果图:所有方法都是成功的
针对测试类或方法覆盖率
咋一看上边这个简单的除法,UT比本身的代码多了几倍,这里也是为了证明写UT的工作确实并不是件容易的工作,相反反而有点费劲,因此多数的开发并不喜欢写UT,虽然也知道UT的重要性和功能强大。但个人经验来说,这么多年工作的几个公司中,几乎没有工程师愿意写UT,更不用说喜欢了,还有很多工程师没有写过,甚至是资深工程师,加之在平时的业务代码中逻辑的复杂性,各种外部环境,多方依赖等各种情况更让人不知怎么写UT。
1.多种输入条件组合导致要写的case比较多,甚至比本身的代码要多得多,且针对多次变更的复杂度极高的老代码更让人望而却步;
二八原则用在这里极为恰当,正常逻辑可能只有1个case,异常逻辑要写5,6个case;
几乎没有看到写得好的业务代码,因为业务变更频繁和快速上线导致多次变更以后兼容代码会很多,甚至最后惨不忍睹;
2.依赖的外部条件导致Case不好写
a.依赖数据库,执行一次以后,第二次结果就不一样了,比如我测试一个save,update或delete等;
b.与多方联调,很多地方根本没有测试环境,只有生产环境,且根本没办法直接访问的,如与支付宝对接支付接口,涉及到下单,支付,回调等流程的UT,按正常流程根本无法写;
3.针对业务逻辑的异常处理等的代码覆盖很困难
有时写UT时发现有些代码是永远不可能覆盖到废代码,有些代码也根本不会抛出接口中声明的异常等
如以下这段,有些异常,我们正常去写CASE,这简直没办法通过输入来产生这些预期的异常,且有些异常永远不会抛出,如HttpURLConnection,不可能拔网线关网络来实现吧:)
try{
httpClient.get("http://xxxx/getUser");
//...
}catch (NoSuchMethodException e){
//...
}catch (IllegalAccessException e){
//...
}catch (HttpURLConnection e){
//...
}catch (IOException e){
//...
}
4.UT可用于代码回归验证,因此UT也需要维护 假设有一个业务突然变更,那原来代码逻辑更新,写好的UT回归测试必然过不了,那么UT也需要更变,因此 UT也需要跟着代码一起维护,维护成本也比较高;
说了这么多UT的难点,相信我们已知道写UT固然不是信手拈来的活,但为什么还要写,能为我们带来什么好处吗? 答案是肯定的;
先说一个自身的案例,当年在一互联网创业公司,刚好本人担任基础架构师在架构组一同推UT,开始我也比较排斥,毕竟已经很忙了,还要花时间UT,但多次讨论和分析下来决定试一试,然后定了几个有几个是强制的要求,
上线分支必须达到:
自从保证了这两个点,我们组的bug几乎没有了,而且功能性bug几乎一个都没有,因此确实是奇效,后来整个公司要求,当然业务组相应要求低一点,但核心链路必须写UT,效果确实非常明显。
我从中得到经验是什么呢?
也许开始写UT感觉到痛苦,费时,但在写UT习惯之后,我们写代码时就会自然考虑到很多Case,因此代码的复杂度我们会非常注意;
当代码到达一定的覆盖率时,覆盖不到或很难覆盖到的代码会强制我们重构,因此可以大大改善代码结构;
这点特别针对try…然后后边一堆catch的代码改善非常明显;
如上边的try…后边的一堆catch,一般业务逻辑的代码针对这么多的异常也不可能一一处理,其实很多异常是可以合并处理的,如果不需要特殊处理的异常,可以统一起这些异常;
try{
httpClient.get("http://xxxx/getUser");
//...
}catch (NoSuchMethodException|IllegalAccessException|HttpURLConnection|IOException e){
//...
}
针对外部环境的依赖,正常流程肯定是没办法测试的,但现在有针对UT的Mock框架,如与Junit结合使用的Powermock,可为我们排除外界干扰,db数据变了或联调的外界环境问题等都完全不是问题
假设我有一个登录功能LoginService,需要调用UserService来取用户,假设UserService是soa或访问了db,但我们并不关心UserService的逻辑是什么,我们只要验证LoginServiceImpl的正确性;
Mock版Demo代码如下:
@Service
public class LoginServiceImpl implements ILoginService {
@Autowired
private IUserService userService;
public boolean login(String name, String passwrd) {
UserDto userDto = userService.getUserByName(name);
if (userDto.getName().endsWith(name) && userDto.getPassword().equals(passwrd)) {
log.info("{}登录成功",userDto.getName());
return true;
}
log.info("{}登录失败,用户名或密码错",userDto.getName());
return false;
}
}
我们明确我们要测试的目标是LoginServiceImpl,因此我们要将依赖的外部接口Mock掉
public class LoginServiceImplMockTest{
/**
* 当前要测试的类,使用InjectMocks注解
*/
@InjectMocks
private LoginServiceImpl loginService = new LoginServiceImpl();
/**
* 由于LoginServiceImpl中使用了IUserService,假设IUserService是一个soa接口,本地可能没办法测试
* 我们只想测试LoginServiceImpl,因此把IUserService mock掉
*/
@Mock
private IUserService userService;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
}
/**
* 1. 造mock数据
* 2. 设置要mock的接口
Mockito.when(userService.getUserByName(Mockito.anyString())).thenReturn(userDto);
* 3. 调用本次要UT的接口:登录
* 4. 使用Assert:验证登录结果
*/
@Test
public void loginSuccess() {
UserDto userDto = new UserDto();
userDto.setName("admin");
userDto.setPassword("123456");
Mockito.when(userService.getUserByName(Mockito.anyString())).thenReturn(userDto);
boolean result = loginService.login(userDto.getName(), userDto.getPassword());
Assert.assertTrue("登录功能验证失败 "+userDto, result);
}
@Test
public void loginFailed() {
UserDto userDto = new UserDto();
userDto.setName("admin");
userDto.setPassword("123456");
Mockito.when(userService.getUserByName(Mockito.anyString())).thenReturn(userDto);
//此处故意将密码设置错
boolean result = loginService.login(userDto.getName(), "1234561");
Assert.assertFalse("登录功能验证失败", result);
}
mock功能的好处在于:
简单来说这种方案其实就是配置事务,执行完UT让事务回滚掉,不产生持久状态; Demo: 还是以上边的登录为例 BaseJunit,主要用于加载基础和通用注解: 这里有一个非常重要的注解 @Rollback 即表示如果有事务最后都回滚
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = {"classpath*:context/*.xml"})
@Rollback
public class BaseJunit {
}
LoginServiceImplTest继承了BaseJunit,实例化,使用@Autowired注解即可
public class LoginServiceImplTest extends BaseJunit {
@Autowired
private ILoginService loginService;
@Autowired
private IUserService userService;
/**
* 代码里写死了只有admin能登录成功,因此我们使用admin用户登录;
* 假设:使用admin预期结果成功
* 1. 准备数据
* 2. 使用userService插入一条数据
* 3. 调用登录接口
* 4. 使用Assert验证
*/
@Transactional
// 此处隐藏了一个Rollback注解因为在BaseJunit上统一了这个注解,
// 主要是为了让大家知道我们最后数据会回滚的,
// @Rollback//
@Test
public void loginSuccess() throws Exception {
//准备数据
UserDto userDto = new UserDto();
userDto.setName("admin");
userDto.setPassword("123456");
userDto.setAge(20);
userService.addUser(userDto);
//登录
boolean result = loginService.login(userDto.getName(), userDto.getPassword());
//验证
Assert.assertTrue("登录功能验证失败 " + userDto, result);
}
@Transactional
@Test
public void loginFailed() throws Exception {
//准备数据
UserDto userDto = new UserDto();
userDto.setName("admin");
userDto.setPassword("123456");
userDto.setAge(20);
userService.addUser(userDto);
boolean result = loginService.login(userDto.getName(), "1234561");
Assert.assertFalse("登录功能验证失败"+ userDto, result);
}
}
配置文件:context-all.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: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/tx http://www.springframework.org/schema/tx/spring-tx.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd">
<!-- 需要扫描实例化注解的包名 -->
<context:component-scan base-package="me.ele.ebu"/>
<bean id="testDataSource" class="com.alibaba.druid.pool.DruidDataSource" destroy-method="close">
<property name="driverClassName" value="com.mysql.jdbc.Driver"/>
<!-- 基本属性 url、user、password -->
<property name="url" value="jdbc:mysql://127.0.01:3306/test?useUnicode=true"/>
<property name="username" value="test"/>
<property name="password" value="test"/>
</bean>
<bean id="testSqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="testDataSource"></property>
<property name="mapperLocations" value="classpath*:/me/ele/ebu/junit/mapper/*Mapper.xml"/>
</bean>
<bean id="testMapperScannerConfigurer" class="org.mybatis.spring.mapper.MapperScannerConfigurer">
<property name="basePackage" value="me.ele.ebu.junit.mapper"/>
<property name="sqlSessionFactoryBeanName" value="testSqlSessionFactory"/>
</bean>
<!-- 配置事务管理器 -->
<tx:annotation-driven transaction-manager="testTxManager" />
<bean id="testTxManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="testDataSource"/>
</bean>
</beans>
效果:在执行UT期间会在当前事务生成一条记录,当前UT验证可通过,且数据最后会自动回滚不落库;
这种方式相对于mock的优缺点:
优点:
缺点:
小结
UT的一般步骤
UT的重要指标和作用
最后个人经验