背景
软件系统刚开发完成时几乎不会出现Bug。为什么呢?因为刚开发第一版软件系统时,需求并不复杂,场景也不是很多,因此实现起来比较简单,再加上测试小哥哥/小姐姐保驾护航,基本不会出现比较严重的bug。但是随着时间的推移,系统功能越加越多,需求越来越复杂,既要兼容原来的功能完好无损、又要保证新增的功能正常使用,再加上项目工期的不断逼近,导致开发小哥压力山大,于是心理历程逐渐转变为:代码和人只要有一个能跑就行的诡异心理。如果产品需求再模糊不清、频繁修改,估计开发小哥想死的心都有了 。与此同时,测试小哥也同样不轻松,因为他发现每次发布新功能竟然有可能会影响到另一个毫不相关的功能,为了保证每次发布新功能时不影响原有功能,于是不得不将原有功能进行回归测试,这无疑给测试小哥增加了成倍的工作量,久而久之,这个系统被越来越多的人厌烦,最后当大家都不愿意再维护这个系统时,这个系统也就走到了终点。即使最后想要重构,也会感觉无从下手,因为你无法预估代码变更所带来的的风险。
测试金字塔
针对上述问题,业界有一套公认的指导方案——测试金字塔。它将测试步骤分为多个层次,每个层次关注不同的测试内容,对于层次的划分,网上有很多种方式,但无一例外,它们最底层都是单元测试,由此可见,编写单元测试是多么的重要。随着对单元测试的不断了解,相关问题也随之而来:应该怎样编写单元测试?哪些代码需要编写单元测试?怎样评判单元测试的好坏?怎样规范的编写单元测试?单元测试的能够带来的好处有哪些?下面让我们一起来了解一下单元测试的爱恨情仇。
文章末尾会将我学习期间产生的一些关键问题一一列出,并附上我的个人观点供大家参考与借鉴(也欢迎大家来前来找我讨论。)
单元测试Demo
首先大致介绍一下该项目的背景,我们公司最近正在开发一个很小的功能,因为某些原因不得不拆分为一个独立项目进行开发,而我就是这个项目的开发人员,由于领导强烈要求80%的单元测试覆盖率以满足SonarQube的标准,所以我不得不花点时间去研究它。由于这个项目的比较小,所以我就直接拿来当案例使用了(删除了一些敏感信息)。
项目技术栈:SpringBoot、JUnit4、mysql、Redis、mybatis-plus、Mockito
JUnit4的基础用法
JUnit是一个Java语言的单元测试框架,应用之广泛应该能够与Spring相媲美了吧。据我了解JUnit有两个广泛流传的版本,分别是JUnit4与Junit5,这两个版本的用法存在着很多差异,因此不建议混合使用,SpringBoot框架中已经默认支持了JUnit作为测试框架。因为我最先接触的是JUnit4版本,因此下文以JUnit4进行示例。
示例代码
public class DesensitizationUtil {
public static String len11mobile(String mobile){
String first = mobile.substring(0, 2);
String last = mobile.substring(mobile.length()-4);
return first+"****"+last;
}
}
代码分析
这是一个非常简单的工具类,其功能是做手机号的脱敏处理,现在需要编写这个方法的单元测试,首先让我们分析一下单元测试的目的有哪些?
- 我们希望单元测试可以验证这个方法的功能是否正常。
- 我们希望单元测试可以将这个方法的所有情况全部验证,而不仅仅是某一个特定的条件
- 当我们需要更改这个方法的实现细节时,单元测试可以帮助我们验证这次变更是否正确。
针对以上几点,我编写了如下的单元测试
单元测试
public class DesensitizationUtilTest {
@Test
public void testLen11mobile() {
String mobile = "123456789";
Assert.assertEquals(DesensitizationUtil.len11mobile(mobile),"12****6789");
}
}
- 当len11mobile()方法发生变化而被破坏时,该测试用例可以检测出其返回结果与期望值不匹配,从而进行风险提示
- 上述例子只存在一个条件分支,因此只需要编写这一个测试用例就可以完全覆盖len11mobile()方法了。
- 当我们需要修改此方法的内部实现时,如果该测试用例通过,则说明本次变更没有更改此方法的行为,因此便不会导致其他功能受其影响。在系统重构时,这一点尤为重要
Mockito的基础用法
上述例子仅仅完成了一个及其普通的单元测试,但是我们大多数的业务场景往往不那么简单,我们可能需要查询数据库、可能需要调用三方接口、也可能需要依赖其他组件(redis、mq)等等。这个时候我们面临的第一个问题就出来了:如何在单元测试中屏蔽掉这些外来因素的影响?于是Mockito被引入进来,使用Mockito,我们可以模拟一些对象的行为使其返回特定的数据。再说白一点就是Mockito会在运行单元测试时生成指定对象的代理对象,从而跳过真实的业务逻辑并返回我们预先设定好的数据类型(如果不理解的话建议先动手写个Demo,相信你会有更深刻的理解)。
示例代码
@AllArgsConstructor
public class UserServiceImpl implements UserService {
private final UserMasterMapper userMasterMapper;
@Override
public boolean markMerchant(MarkMerchantModel model) {
// 查询用户信息
UserMasterEntity user = userMasterMapper.selectOne(
Wrappers.<UserMasterEntity>lambdaQuery().eq(UserMasterEntity::getUserId,model.getUserId()));
// 校验用户是否存在
ExceptionAssertEnum.USER_NOT_EXIST.notNull(user);
// 将其标记为商家类型
user.setIsShopMerchant(model.isMarkMerchant());
user.setShopMerchantDate(LocalDateTime.now());
// 更新数据库
int count = userMasterMapper.updateById(user);
// 检验是否更新成功
ExceptionAssertEnum.SYSTEM_EXCEPTION.isTrue(count == 1);
// 返回业务结果
return true;
}
}
代码分析
这是一个较为简单的业务方法,该方法的功能是将用户标记为商家类型,为了使大家看起来更方便一些,我将每行代码都加了注释,大家可以看到这个方法其实存在多种不同的行为:
- 当业务执行成功时返回true
- 当数据库查询不到用户信息时抛出:USER_NOT_EXIST异常
- 当数据库写入失败时抛出:SYSTEM_EXCEPTION异常
以上的几种行为便是单元测试所需要验证的内容,然而这些行为的验证都离不开DB的支持,因此我们需要通过Mock跳过DB操作,于是编写了如下的单元测试
单元测试
public abstract class BaseTest {
@Before
public void before() {
MockitoAnnotations.openMocks(this);
}
public void assertThrows(ThrowingRunnable runnable, Integer errorCode) {
BusinessException e = Assert.assertThrows(BusinessException.class, runnable);
Assert.assertEquals(errorCode,e.getCode());
}
}
public class UserServiceImplTest extends BaseTest {
@Mock
private UserMasterMapper userMasterMapper;
@InjectMocks
private UserServiceImpl service;
@Test
public void testMarkMerchant() {
MarkMerchantModel model = new MarkMerchantModel();
model.setUserId("RealUserId");
model.setMarkMerchant(true);
when(userMasterMapper.selectOne(any())).thenReturn(new UserMasterEntity());
when(userMasterMapper.updateById(any())).thenReturn(1);
Assert.assertTrue(service.markMerchant(model));
}
@Test
public void testMarkMerchantForUserNotExistException() {
MarkMerchantModel model = new MarkMerchantModel();
model.setUserId("testUserId");
assertThrows(() -> service.markMerchant(model), ExceptionAssertEnum.USER_NOT_EXIST.getCode());
}
@Test
public void testMarkMerchantUpdateException() {
MarkMerchantModel model = new MarkMerchantModel();
model.setUserId("RealUserId");
model.setMarkMerchant(true);
when(userMasterMapper.selectOne(any())).thenReturn(new UserMasterEntity());
when(userMasterMapper.updateById(any())).thenReturn(0);
assertThrows(() -> service.markMerchant(model), ExceptionAssertEnum.SYSTEM_EXCEPTION.getCode());
}
}
根据方法名称我想大家应该也可以猜得到这三个测试用例分别是对应以上三种行为。这里继承了BaseTest,因为我喜欢在父类中编写一些公共的方法。而@Before标注的方法会重复执行在每一个测试用例之前,MockitoAnnotations.openMocks(this)方法代表开启Mockito的注解功能,@Mock注解可以生成一个UserMasterMapper的代理对象,@InjectMocks注解可以将@Mock生成代理对象注入到serivce中,最后在具体的测试用例中通过when()设置不同的返回数据,从而完成UserMasterMapper对象的模拟,然后通过Assert验证该方法的行为是否符合预期,从而决定了单元测试的成功与否。
Mockito的用法其实还有很多,我没有一一叙述,因为相对于基础教学之类的文章,我更喜欢写一些能够传递我的思想观点的文章。
针对单元测试产生的疑问?
单元测试的目的?
代码变更时保证软件系统原有功能不被破坏。
单元测试的粒度?
我认为单元测试的粒度应该精确到类中的某个具体方法。
单元测试的覆盖率?
我们之所以编写单元测试,是为了保证业务代码的可靠运行。盲目追求100%的测试覆盖率并不会给我们带来质量上的提升,反而会加重我们的负担。所以不要为了测试覆盖率而编写单元测试。
单元测试的覆盖范围?
类覆盖、方法覆盖、行覆盖、条件覆盖。我认为条件覆盖是最为苛刻的一种,因为它需要输入不同的条件进行测试
哪些代码需要单元测试?
非常简单的方法(get、set、equals…)以及不对外暴露的方法(private…)无须编写单元测试
单元测试是否需要被测方法同步更新?
单元测试只关注被测方法的行为(参数、返回值),而不应该关注其实现细节。。
单元测试是否需要依赖Spring环境?
单元测试不需要依赖Spring环境,我更愿意将需要依赖Spring特性(Aop)的单元测试理解为一种狭义的集成测试。
单元测试是否需要依赖外部系统或中间件?
每一个开发人员都需要能够在本地反复的执行单元测试,所以单元测试不建议依赖任何的外部因素,这些因素都可能导致单元测试的失败,包括mysql、nacos、seate、redis、openFeign、三方接口等。这些因素需要在单元测试阶段进行模拟(Mock)或屏蔽(disable)。
单元测试带来的好处有哪些?
- 可以检测代码是否被破坏
- 当代码难以阅读时,阅读单元测试可以帮助我们了解其功能
- 当系统需要重构时,单元测试可以帮助我们验证被测方法的正确性
- 可以减少回归测试的时间成本
- 可以使开发人员对自己的代码更有信心
单元测试相关技术?
Junit4、Junit5:单元测试运行框架
Mockito、Wiremock:mock框架,用来模拟一些对象行为
SonarQube:代码静态扫描平台,可以通过静态扫描检查代码漏洞、代码规范、代码重复率、测试覆盖率等信息
Jacoco:用来分析测试覆盖率并生成可视化报告,SonarQube通过Jacoco生成的报告进行展示。
尾言
单元测试固然重要,但切记:技术没有银弹!