一、写本文背景
说到 Junit,很多人都知道非常强大的代码逻辑检测框架,但在平时项目中,我发现两个问题:
开发人员并不喜欢写UT,其中有包括很多资深工程师和架构师等;
写UT仅是为了验证即时代码的正确性,因此让 UT 变成了一次性的,且只为了本次代码的覆盖而写;
二、简单栗子热热身
假设我们要测试个除法运算,如div(a,b) 那么就要针对c=a/b做分别的假设和预期结果
public class MathService {
/**
* c = a/b
*/
public int div(int a, int b) {
return a / b;
}
}
假设a=10,b=5,c应为2(ab正常情况)
假设a=10,b=0,应该会抛出异常 (除数为0情况)
假设a=0,b=10,结果应是0 (被除数为0情况)
假设a=17(质数),b=8,那么是2(被除数为质数情况,主要是验证不能整除的情况)…(当然还有其它的假设和预期结果)
@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,更不用说喜欢了,还有很多工程师没有写过,甚至是资深工程师,加之在平时的业务代码中逻辑的复杂性,各种外部环境,多方依赖等各种情况更让人不知怎么写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,开始我也比较排斥,毕竟已经很忙了,还要花时间UT,但多次讨论和分析下来决定试一试,然后定了几个有几个是强制的要求,
上线分支必须达到:
所有UT方法100%成功
- UT的代码覆盖率>=80%
自从保证了这两个点,我们组的bug几乎没有了,而且功能性bug几乎一个都没有,因此确实是奇效,后来整个公司要求,当然业务组相应要求低一点,但核心链路必须写UT,效果确实非常明显。
我从中得到经验是什么呢?
1.开始很痛苦,但熟能生巧
也许开始写UT感觉到痛苦,费时,但在写UT习惯之后,我们写代码时就会自然考虑到很多Case,因此代码的复杂度我们会非常注意;
非常注意代码的规范性和可读性,几乎不可能再写嵌套复杂的代码,还会使用工具来检查,如sonar,阿里p3c等,对代码的规范和复杂度都非常有指导和约束作用;
每个类和方法都不会太长,且非常注意重用性,反过来说,重用的代码UT不用写,且促进我们去抽象,去改善代码结构和质量。
2.能提升重构水平
当代码到达一定的覆盖率时,覆盖不到或很难覆盖到的代码会强制我们重构,因此可以大大改善代码结构;这点特别针对try…然后后边一堆catch的代码改善非常明显;
如上边的try…后边的一堆catch,一般业务逻辑的代码针对这么多的异常也不可能一一处理,其实很多异常是可以合并处理的,如果不需要特殊处理的异常,可以统一起这些异常;try{
httpClient.get("http://xxxx/getUser");
//...
}catch (NoSuchMethodException|IllegalAccessException|HttpURLConnection|IOException e){
//...
}
3.多使用Mock测试减少状态规避外部依赖
针对外部环境的依赖,正常流程肯定是没办法测试的,但现在有针对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功能的好处在于:
简单易用,速度快;
不用依赖外部环境;
可重复测试,无副作用,不像DB或外部可能会产生持久性状态;
4. 使用DB的不持久化方案测试
简单来说这种方案其实就是配置事务,执行完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"?>
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">
效果:在执行UT期间会在当前事务生成一条记录,当前UT验证可通过,且数据最后会自动回滚不落库;
这种方式相对于mock的优缺点:
优点:
一定程度上可以验证DB层是否OK,当然如果是soa或是联调别人的接口就比较麻烦了
- 有时不用像mock一样造那么多数据,直接通过DB查询即可
缺点:
依赖DB环境,也需要维护DB环境(甚至还有数据)
- 加载速度较慢,往往需要加载整个配置文件才能执行UT,优化UT启动配置成本并不低;
小结
UT的一般步骤
提出假设的输入
执行测试方法
- 验证预期结果(assert)
所有的方法都验证通过
代码的覆盖率最好是100%
应达到可重复执行,可回归验证
最后个人经验
UT可以大大提升工程师的代码质量,可大大减少逻辑性bug;
写UT习惯反过来可以大提升对代码重构水平;
UT的回归测试可以及时反馈被改错的代码,这一点非常有用;可以考虑集成在cicd,上线需要UT没达到一定的代码覆盖率等
- 无状态的Mock测试往往就是最佳选择,但如果有需要,其实多种测试都可以一起使用;
近期好文推荐:
专访中行软件中心张新 | DevOps 新实践,女性如何在 IT 工作中撑起半边天?
遇见里斯本,走进 DevOps World & Jenkins World 葡萄牙站!
面试字节跳动,我被面试官狂怼全过程!
相爱相杀的运维之殤:苏宁消费金融超大规模 IT 系统 DevOps 实践
“DevOps时代”公众号诚邀广大技术人员投稿。
投稿邮箱:jiachen@greatops.net 或 添加联系人微信:135 2116 9787(同微信)。
点击,访问大会官网
你点的每个赞,我都认真当成了喜欢