单元测试最佳实践
背景
在近期的代码重构的过程中,遇到各式各样的问题,比如调整代码顺序导致的bug,方法重构参数校验逻辑的移除等。
在上线前需要花大量时间进行测试和灰度验证。最大的感受就是:一切没有单元测试覆盖情况下的代码重构,都是裸奔。
单元测试
what
一个单元测试是一段自动化的代码,这段代码调用被测试的工作单元。只要产品代码不发生变化,单元测试的结果是稳定的。
单元测试是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。合格的单元测试具有以下特征:
自动化检测
单元测试应该是无效开发手动进行验证的,运行单元测试应该自动运行结果并进行校验。
快速运行
单元测试应该是很快被执行的,一个项目所有的测试用例应该在10s内完成全部的验证。快速的单元测试才能在开发过程中检测,反例是依赖Spring IOC容器启动。
无依赖
单元测试不应该依赖任何外部资源,比如:数据库、RPC、文件系统,所有对外资源的调用应该采用Mock的方式。
持续维护
单元测试非一次性代码,需要桶业务代码的发展持续进行维护。不易维护、不可月度的单元测试是无意义的。
粒度小
单元测试检测的功能范围应该是粒度的。
单元测试反例
工作中经常会遇到一些无效的单元测试,通常是启动Spring容器,连接数据库,然后调用测试方法控制台查看数据结果。这并不是合格的单元测试,比如:
@RunWith(SpringRunner.class)
@SpringBootTest(classes = ApplicationLoader.class)
public class UserServiceTest {
@Autowired
private UserService userService;
@Test
public void testAddUser() {
AddUserRequest addUserRequest = new AddUserRequest("username", "password");
boolean isSuccess = userService.addUser(addUserRequest);
System.out.println(isSuccess);
}
}
why
工作中很多代码是没有单元测试的,这些项目也正常运行,那为什么要编写单元测试呢?
好的单元测试能提供我们代码交付质量的同时,还可以减少bug发现和修复的成本,进而提高工作效率。

提高工作效率
程序员大多时间都消耗在改bug的阶段,编码往往可能至占用一小部分,尤其突出是在修改或者重构已有代码,不得不考虑增量代码是否会对原有逻辑带来影响。所以长远来看,单元测试是能提高工作效率的。
提升代码质量
可测试通常与软件的设计良好程序相关,难以测试的代码一般设计上都存在问题。所以有效的单元测试会驱动开发者写出高质量的代码。
节省成本
单元测试能确保程序的底层逻辑的正确性,让问题能够在开发自测阶段暴露出来。Bug越早发现,修复成本往往越低,带来的影响也会更小。
如图红色曲线,标表示在不同阶段修复bug的成本差别是巨大的。

Who
代码的作者最了解代码的目的、特点和实现的局限性。单元测试的编写没有比代码作者更合适的人选了。
When
编写单元测试的时机,一般是越早越好,尽量不要将单元测试拖延到代码写完之后,这样带来的收益并不会特别多。遵循:the sonner the better。
TDD 测试驱动开发,以其倡导先写测试程序,然后编码实现。这是一种理想的状态,由于种种原因想要遵循TDD有一定难度,毕竟产品的需求往往是一直在变化的。
边开发边写单元测试,先写少量代码,紧接着写单元测试,重复过程知道功能代码开发完成。
开发后再补单元测试,这个效果往往收益不如前面收益高。
Which
哪些方面需要进行单元测试?并不是单元测试覆盖率越高越好。
接受不完美,对于历史代码全覆盖是不现实的,我们可以根据优先级、重要程度,针对性补全单元测试。
另外在JavaWeb项目代码中,Controller层、Dao层以及其他接口转发相关方法,往往是不需要覆盖单元测试的。
How
编写单元测试代码遵守BCDE原则,以保证被测试模块的交付质量。
- B:Border,边界值测试,包括循环边界、特殊取值、特殊时间点、数据顺序等。
边界值分析法就是对输入或输出的边界值进行测试的一种黑盒测试方法。
- 循环边界:指校验第0次、1次、n次是否有错误
- 特殊取值:指运算或判断中取最大值、最小值时是否有错误
- 特殊时间点:指对时间不同值敏感的函数,采用刚好等于、大于、小于的时间值进行校验
- 数据顺序:指数据流、控制流中刚好等于、大于、小于确定的比较值是否出现错误
- C:Correct,正确的输入,并得到预期的结果。
- D:Design,与设计文档相结合,来编写单元测试。
- E:Error,强制错误信息输入(如:非法数据、异常流程、业务允许外等),并得到预期的结果。
Mockito
工欲其善必先利器,选择一个合适Mock框架比手动实现Fake数据,往往能让我们的单元测试事半功倍。有很多Mock框架可以选择,比如:Mockito、PowerMock、Spock、EasyMock、JMock。
引入Mockito测试框架依赖:
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>4.8.1</version>
<scope>test</scope>
</dependency>
创建Mock对象
1.Mockito.mock(class)方式
通过静态方法mock来创建Mock对象,由于mock是静态方法所以同程会写成静态导入的方式。
List<String> mockList = Mockito.mock(ArrayList.class);
2.使用@Mock注解方式来创建Mock对象
@Mock
private List<String> mockList;
使用该方式创需要注意的是要在运行测试方法前使用 @RunWith 或者 @ExtendWith。
注意:如果您使用的是 Junit 版本 < 5,则必须使用 @RunWith(SpringRunner.class) 或 @RunWith(MockitoJUnitRunner.class) 等。
如果您使用的是 Junit 版本 = 5,那么您必须使用 @ExtendWith(SpringExtension.class) 或 @ExtendWith(MockitoExtension.class) 等
@Mock、@MockBean区别、@InjectMocks与@Spy的区别
@Mock: 创建一个Mock.
@MockBean:可用于向 Spring ApplicationContext 添加模拟的注释……如果在上下文中定义的任何现有的相同类型的单个 bean 将被模拟替换,如果没有定义现有的 bean,则将添加一个新的。
@InjectMocks: 创建一个实例,简单的说是这个Mock可以调用真实代码的方法,其余用@Mock(或@Spy)注解创建的mock将被注入到用该实例中。
@Spy:对函数的调用均执行真正部分。
@Spy对象@Value值注入(非SpringBootTest场景)
ReflectionTestUtils.setField(obj, "value", "some value");
单元测试实战
单元测试的编写一般包含三个部分:
- Given:Mock外部依赖以及准备Fake数据。
- When & Then:调用Case设计。
- Assert:断言执行结果。
@RunWith(MockitoJUnitRunner.class)
public class MiniAdServeServiceImplTest {
@InjectMocks
private MiniAdServeServiceImpl miniAdServeService;
@Spy
private PositionIdConfig positionIdConfig;
@Mock
private ApolloBean apolloBean;
@Mock
private CommonAdServeService commonAdServeService;
@Spy
private MiniAdServeServiceHelper helper;
private GetAdsReq commonGetAdsReq;
//指定参数 case 设计
private Predicate<AdsRequestDTO> page_banner = adsRequestDTO -> adsRequestDTO.getAdPositionId().equals("xxxxxxxxxx");
/**
* 初始化相关场景与数据mock
*/
@Before
public void mockInit() {
//设置 @Value mock
ReflectionTestUtils.setField(positionIdConfig, "pid", AdsMockReturnData.POSITION_DEFAULT);
ReflectionTestUtils.setField(miniAdServeService, "bannerPid", AdsMockReturnData.POSITION_BANNER);
//Fake 数据
commonGetAdsReq = GetAdsReq.builder().order_sn("xxxx").merchant_id("xxxx").store_sn("xxxx").uid("xxxxx").amount("15.00").build();
//当满足指定条件参数匹配场景下,返回不同的 mock case数据
when(commonAdServeService.getAds(createArgumentMatcher(page_banner))).thenReturn(AdsMockReturnData.BANNER_Ad_GDT);
}
@Test
public void testGetMultiAds_Banner(){
//发起广告请求
when(commonAdServeService.getAds(defaultArgumentMatcher())).thenReturn(AdsMockReturnData.BANNER_Ad);
AdsResult adsResult = miniAdServeService.getMultiAds(commonGetAdsReq, AdsMockReturnData.USER_AGENT, AdsMockReturnData.PAY_WAY_TEST, AdsMockReturnData.PAY_ENV_MINI);
//Assert 断言验证
helper.assertScenarioResult(adsResult, ScenarioAssertEnum.BANNER_AD, true, false);
helper.assertScenarioResult(adsResult, ScenarioAssertEnum.AD, false, true);
Ads ads = adsResult.getGridAds().get(0);
Assert.assertTrue(ads.getAppId().equals("xxxxxx"));
Assert.assertTrue(ads.getScenarioType().equals(ScenarioStyleEnum.BANNER_LINK.getType()));
Assert.assertTrue(ads.getCampaignType() == 1);
Assert.assertTrue(ads.getLandingPage().equals("/pages/index/index"));
Assert.assertTrue("xxxx".equals(ads.getCreative().getContent().get("title")));
}
/**
* 创建ArgumentMatcher,根据不同参数值来匹配对应的行为
*/
private <T> T createArgumentMatcher(final Predicate<T> predicate) {
return Mockito.argThat(argument -> {
if (null == argument) {
return false;
}
return predicate.apply((T) argument);
});
}
}
行动计划
- 梳理项目
- 项目发布频率
- 项目影响面
总结
编写单元测试能持续够提升代码质量,驱动代码设计,帮助我们更早发现问题,保障持续优化和重构,单元测试也不能覆盖所有的问题。单元只测试程序单元自身的功能。它不能发现集成错误、性能、或者其他系统级别的问题。
