Java 单测Mock升级实践
一、背景
众所周知,单元测试是改善代码质量,提升研发交付品质的手段之一,能否写出好的单元测试用例,也是衡量我们研发专业性的标准之一。所以,想要成为一名合格的研发,就应该要有编写单测用例的习惯,也应充分认识到单元测试的重要性。但是,往往在时间紧、任务重、降本增效等既要又要还要的高标准要求下,如何有效提升单元测试覆盖率以及单元测试质量,值得我们思考。
二、研发痛点
时间紧、任务重、资源有限是我们项目开发过程中的常态,客观上无法避免,但在编写单测实操层面还有一些痛点:
1、集成测试:
由于赶项目进度,单测并不是基于代码模块分别编写,而是基于项目启动后的接口调用,即集成测试,接口调通即认为单测通过,如果这样,接口调用顺利与否又依赖于关联方的接口是否可用,所以往往在项目最后才能跑单测,单测效用不理想。
2、数据依赖:
因为单测是集成测试,执行时需要依赖测试环境真实数据,但很多业务场景测试数据很难覆盖全面,项目上线后又由于测试数据状态的变化,原有可运行通过单测Case经常失败,倘若发布流程又依赖单测执行成功结果,就需要反复修改或直接注释掉单测,再重新打包才能发布,重新发布又有新的单测用例执行失败,继而循环往复,研发和测试都苦不堪言。
3、标准不一:
各团队使用的测试框架或参考的单测编写标准各不相同,导致同一项目单测编写的标准不一,多种框架并存,存在很多兼容问题,接入及维护成本很高。
4、流于形式:
单测编写过程中,由于各种原因,部分开发者容易形成逆反思维,认为写单测只是为了满足覆盖率或者认为浪费开发时间,而不被重视。
三、升级改造
1、解决思路:
基于上述现状和痛点,迫切需要一种解决方案来提升研发单测的编写效率,降低接入成本,同时又要能够满足研发规范对单测覆盖率的要求,以便提升单测效用,保障交付质量。
基于上述前两个痛点: 我们自然而然的想到了Mock方式,思路大致如下图,即不真实调用依赖对象,只是Mock一个假的依赖对象,然后预设一个预期返回,再执行方法得出实际结果和我们预期的输出结果做对比,从而验证我们代码的逻辑是否正确。
2、框架选型:
Mock是一个很好的思路,但是市面上那么多Mock框架,我们要使用哪一款,还是要通过各种指标横向对比后才能做决定,为此,我们对市面上常用的各种Mock框架进行了横评,对比结果详见下图:
通过横向对比,最终我们选定了广大Java开发者熟悉的,同时又和SpringBoot技术栈融合良好,基于Mockito框架实现的增强版测试框架PowerMockito,再加上我们常用的Junit作为我们的单测技术选型。
3、规范统一:
有了上述解决方案及具体的落地框架选型,我们已经迈出了坚实的第一步!
但这还不够,我们还需要明确一个具体的实施标准或者落地规范,即什么是好的单测或合格的单测?为此我们参考了现有大厂的规范,确定了我们自己的单测实施规范,具体如下:
4、具体实施:
有了解决方案并建立了统一标准和实施规范,我们就着手落地实施了,实施前我们也做了充分的评估,将现有系统中的单测用例分成了两类:
存量单测:
这个是历史单测用例,我们决定暂不改动,以便减少实际实施的成本,降低改动可能带来的风险。
增量单测:
编写单测用例时要按照新的框架和标准规范实施,这部分内容是我们关注的重点。
5、实施细节:
为了提升单测落地实施的重要性,我们特地进行了内部分享和宣讲,制订了《单测Mock升级手册》供大家实际接入时参考,在手册中明确了各种单测覆盖的场景和Case示例,具体内容如下:
5.1 引入jar包:
<properties>
<powermock.version>2.0.2</powermock.version>
<mockito.version>2.23.4</mockito.version>
</properties>
<dependencies>
<!-- for mock test start-->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
</dependency>
<dependency>
<groupId>org.powermock</groupId>
<artifactId>powermock-module-junit4</artifactId>
<version>${powermock.version}</version>
</dependency>
<dependency>
<groupId>org.powermock</groupId>
<artifactId>powermock-api-mockito2</artifactId>
<version>${powermock.version}</version>
</dependency>
<!-- for mock test end-->
</dependencies>
Module中引用,scope范围为test:
<!-- for mock test start -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.powermock</groupId>
<artifactId>powermock-module-junit4</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.powermock</groupId>
<artifactId>powermock-api-mockito2</artifactId>
<scope>test</scope>
</dependency>
<!-- for mock test end -->
5.2 Mock用例编写:
a. 如何运行单测:
@RunWith(PowerMockRunner.class) :表明用 PowerMockerRunner来运行测试用例,否则无法使用PowerMock。
@PrepareForTest({XXX.class}):所有需要测试的类,列在此处,以逗号分隔。
b. mock、spy的区别:
mock出来的对象,所有的属性、方法都会被置空,如果直接调用原本方法,会直接返回返回值类型对应的默认初始值,并不会执行方法体,通过 CallRealMethod 才能调用原方法。
spy出来的对象,是在目标对象或真实对象的基础上进行包装,可以直接调用原方法,不需要借助 CallRealMethod。
mock出来的对象可以使用 when...then... 或 do...when;
spy出来的对象只能使用 do...when,使用 when...then... 不会报错,但会先执行真实方法,再把 thenReturn 的 mock 数据替代原返回值进行返回,没有达到mock的效果。
建议 mock 一律使用 when...then,spy 一律使用 do...when,避免混淆。
c. 如何测试一个类:
要测哪个类的方法,单测类中哪个类就用@InjectMocks 注解,这个类的方法内依赖的帮助类(xxxService、xxxBusiness、xxxAO)需要被Mock,用@Mock注解。
5.3 单测示例:
5.3.1 Controller层单测:
示例中要测试UserGrayController中的userProfileConfig方法。
@RunWith(PowerMockRunner.class)
public class UserGrayServiceImplTest {
@Mock
private ApplicationConfigService configService;
@Mock
private UserGrayBusiness userGrayBusiness;
@Mock
private UserAccountBusiness userAccountBusiness;
@InjectMocks
private UserGrayServiceImpl userGrayService;
@Test
public void testUserProfileConfig() throws Exception {
ProfilePagesGrayConfigRequest request = new ProfilePagesGrayConfigRequest();
request.setUserId(123456);
request.setPageId("1");
request.setBizId("1111");
request.setUserId(1111);
UserProfileTopTextDTO configTopText = new UserProfileTopTextDTO();
configTopText.setPageId("1");
configTopText.setDynamicRandomMin(0d);
configTopText.setDynamicRandomMax(1d);
configTopText.setBizId("1111");
List<Object> configGrayList = new ArrayList<>();
configGrayList.add(configTopText);
when(configService.queryListByCmp(anyString(), anyString(), any())).thenReturn(configGrayList);
when(userAccountBusiness.queryOpenUniqueId(anyInt())).thenReturn("123456");
when(userGrayBusiness.isRandomGray(anyInt(), anyString(), anyString())).thenReturn(true);
when(userGrayBusiness.isHitIdCardNewUIGray(anyInt(), anyString())).thenReturn(true);
ProfilePagesGrayConfigResponse response = userGrayService.userProfileConfig(request);
Assert.assertTrue(response.getIdCardNewUI());
}
}
5.3.2 Service层单测:
@RunWith(PowerMockRunner.class)
public class UserGrayServiceImplTest {
@Mock
private ApplicationConfigService configService;
@Mock
private UserGrayBusiness userGrayBusiness;
@Mock
private UserAccountBusiness userAccountBusiness;
@InjectMocks
private UserGrayServiceImpl userGrayService;
@Test
public void testUserProfileConfig() throws Exception {
ProfilePagesGrayConfigRequest request = new ProfilePagesGrayConfigRequest();
request.setUserId(123456);
request.setPageId("1");
request.setBizId("1111");
request.setUserId(1111);
UserProfileTopTextDTO configTopText = new UserProfileTopTextDTO();
configTopText.setPageId("1");
configTopText.setDynamicRandomMin(0d);
configTopText.setDynamicRandomMax(1d);
configTopText.setBizId("1111");
List<Object> configGrayList = new ArrayList<>();
configGrayList.add(configTopText);
when(configService.queryListByCmp(anyString(), anyString(), any())).thenReturn(configGrayList);
when(userAccountBusiness.queryOpenUniqueId(anyInt())).thenReturn("123456");
when(userGrayBusiness.isRandomGray(anyInt(), anyString(), anyString())).thenReturn(true);
when(userGrayBusiness.isHitIdCardNewUIGray(anyInt(), anyString())).thenReturn(true);
ProfilePagesGrayConfigResponse response = userGrayService.userProfileConfig(request);
Assert.assertTrue(response.getIdCardNewUI());
}
}
5.3.3 DAO 层Mock单测:
@Test
public void testBatchSave() {
List<BusinessOpportunityExtend> inputList = ResourceFileUtil
.readForList("data/OpportunityExtend/batch_save_list.json", BusinessOpportunityExtend.class);
PowerMockito.doAnswer((Answer<Integer>) invocationOnMock -> {
BusinessOpportunityExtend extend = invocationOnMock.getArgument(0);
extend.setId(2L);
return 1;
}).when(dao).insert(inputList.get(0));
Mockito.when(dao.update(Mockito.any())).thenReturn(1);
assert service.batchSave(inputList).getContent() == inputList.size();
}
5.3.4 特殊场景Mock单测
- 静态方法Mock:
@RunWith(PowerMockRunner.class)
@PrepareForTest({ValidateGrayUtil.class})
public class ProcessServiceMockTest {
@Test
public void xyzUnionLoginUrlNullTest(){
PowerMockito.mockStatic(ValidateGrayUtil.class);
PowerMockito.when(ValidateGrayUtil.filterUser(anyList(),anyObject(),anyObject(),anyString())).thenReturn(Arrays.asList(new xyzCheckGray()));
assertEquals("2",response.getResult().toString());
}
}
- 私有方法Mock:
方式一(推荐):
Whitebox.invokeMethod(csdAuthAO, "hitPromotionCheckGray", request);
方式二:
目标代码:
public class MockPrivateClass {
private String returnTrue() {
return "return true";
}
}
单测代码:
@RunWith(PowerMockRunner.class)
@PrepareForTest(MockPrivateClass.class)
public class PowerMockTest {
@Test
public void testPrivateMethod() throws Exception {
MockPrivateClass mockPrivateClass = PowerMockito.mock(MockPrivateClass.class);
PowerMockito.when(mockPrivateClass, "returnTrue").thenReturn(false);
PowerMockito.when(mockPrivateClass.isTrue()).thenCallRealMethod();
assertThat(mockPrivateClass.isTrue(), is(false));
}
}
- 构造方法单测:
目标代码:
//构造方法所在类:
public class User {
private String username;
private String password;
public User(String username, String password) {
this.username = username;
this.password = password;
}
public User() {
}
public void insert() {
throw new UnsupportedOperationException();
}
}
//使用构造方法的类:
public class UserService {
public void saveUser(String username, String password) {
User user = new User(username, password);
user.insert();
}
}
单测代码:
注意:
1、首先我们要注意的是在@PrepareForTest后面的是使用构造函数的类,而不是构造函数所在的类。
2、使用下面的语句对构造函数进行mock,即当new User.class类且参数为username和password时返回user这个已经mock的对象。
@RunWith(PowerMockRunner.class)
@PrepareForTest({UserService.class})
public class UserServiceTest {
@Mock
private User user;
@Test
public void testSaveUser() throws Exception {
String username = "user1";
String password = "aaa";
//有参构造
PowerMockito.whenNew(User.class).withArguments(username, password).thenReturn(user);
//无参构造
PowerMockito.whenNew(User.class).withNoArguments().thenReturn(user);
PowerMockito.doNothing().when(user).insert();
UserService userService = new UserService();
userService.saveUser(username, password);
Mockito.verify(user).insert();
}
}
- void方法单测:
目标代码:
@Component
public class UserDao{
@Autowired
private UserMapper userMapper
@Autowired
private SystemErrorRecoder systemErrorRecoder
public void putUser(UserDTO userDto){
try{
userMapper.putUser(userDto);
}catch(DataAccessException e){
systemErrorRecoder.addMsgError(e.getMessage());
}
}
}
单测代码:
核心思路: 通过Mockito.verify来验证返回值void方法是否被执行过。
@RunWith(PowerMockRunner.class)
@PowerMockIgnore("javax.management.*") //解决报错previously initiated loading for different type with name "javax/managemen
public class UserDaoTest[
private UserDao userDao;
private UserMapper userMappers;
private SystemErrorRecoder systemErrorRecoder;
//在@Test代码执行前执行,用于初始化
@Before
public void setUp(){
userDao = new UserDao();
//mock获得UserMapper类的代理对象
userMappers = PowerMockito.mock(UserMapper.class);
systemErrorRecoder = PowerMockito.mock(SystemErrorRecoder.class);
//为本类的userDao对象的私有属性userMapper赋值userMappers
Whitebox.setInternalState(userDao,"userMapper",userMappers);
Whitebox.setInternalState(userDao,"systemErrorRecoder",systemErrorRecoder);
}
@Test
public void testPutUser(){
UserDTO user = new UserDTO();
//主要代码
PowerMockito.doNothing().when(userMappers).putUser(Mockito.any(UserDTO.class));
userDao.putUser(user);
Mockito.verify(userMappers).putUser(Mockito.any(UserDTO.class));
DataAccessException exception = PowerMockito.mock(DataAccessException.class);
//b.模拟try内的方法,doThrow异常
PowerMockito.doThrow(exception).when(userMappers).putUser(Mockito.any(UserDTO.class));
//c.模拟catch内的方法(如果catch内不涉及别的方法,可以省略)
PowerMockito.doNothing().when(systemErrorRecoder).addMsgError(Mockito.anyString())
userDao.putUser(user);
Mockito.verify(systemErrorRecoder).addMsgError(Mockito.anyString());
- 异常单测:
@RunWith(PowerMockRunner.class)
@PrepareForTest({UserUtil.class})
public class SysUserUtilTest{
@Test
public void testGetSysUser() throws DataAccessException{
DataAccessException exception = PowerMockito.mock(DataAccessException.class);
PowerMockito.mockStatic(UserUtil.class);
PowerMockito.when(UserUtil.getSysUser(Mockito.anyString())).thenThrow(exception); // 重点
Assert.assertNull(SysUserUtil.getSysUser("test"));
}
}
- 真实方法调用:
Service spy = PowerMockito.spy(Service.class);
//直接调用方法时真实调用
spy.method(parameters);
//使用thenReturn 会真实调用,但返回值使用mock的
PowerMockito.when(spy.method(parameters)).thenReturn(someObject);
Foo mock = mock(Foo.class);
doCallRealMethod().when(mock).someMethod(params);
// 会执行真实方法
mock.someMethod(params);
注意: 使用doReturn 不会真实调用方法
PowerMockito.doReturn(someObject).when(spy).method(someObject);
- 验证方法是否被执行过:
@Test
public void testMockitoBehavior() {
Person person = mock(Person.class);
int age = person.getAge();
//验证getAge动作有没有发生
verify(person).getAge();
//验证person.getName()是不是没有调用
verify(person, never()).getName();
verify(person, atLeast(1)).getAge();
//验证getAge动作是否被调用了2次,前面只用了一次所以这里会报错
verify(person, times(2)).getAge();
}
- 引入Das的DAO类单测覆盖:
@RunWith(PowerMockRunner.class)
@PrepareForTest({MauPraiseRecordDAO.class, DasClientFactory.class})
public class MauPraiseRecordDAOMockTest {
@Test
public void testUpdateMauPraiseNumById() throws Exception {
// Mock the DasClient object and its methods
DasClient dasClient = PowerMockito.mock(DasClient.class);
PowerMockito.mockStatic(DasClientFactory.class);
PowerMockito.when(DasClientFactory.getClient("test_mau_tools")).thenReturn(dasClient);
PowerMockito.when(dasClient.update(Mockito.any(SqlBuilder.class))).thenReturn(1);
// Create a MauPraiseRecordDAO object and call its updateMauPraiseNumById method
MauPraiseRecordDAO dao = new MauPraiseRecordDAO();
int result = dao.updateMauPraiseNumById("testId", "testId", 81L);
Assert.assertEquals(1,result);
}
}
- @Valid修饰的实体类属性校验(如@NotNull):
目标代码:
@Data
@ApiModel(value = "撤回点赞请求参数")
public class RecallPraiseRecordRequest {
@ApiModelProperty(value = "点赞消息id", required = true)
@NotNull
Long praiseId;
}
单测代码:
private Validator validator;
@Before
public void setUpClass() {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
validator = factory.getValidator();
}
@Test
public void validationForNullProperty() {
// Given
RecallPraiseRecordRequest myDto = new RecallPraiseRecordRequest();
myDto.setPraiseId(null);
// When
Set<ConstraintViolation<RecallPraiseRecordRequest>> violations = validator.validate(myDto);
// Then
assertThat(violations).hasSize(1);
ConstraintViolation<RecallPraiseRecordRequest> violation = violations.iterator().next();
assertThat(violation.getPropertyPath().toString()).isEqualTo("praiseId");
}
@Test
public void validationForNotNullProperty() {
// Given
RecallPraiseRecordRequest myDto = new RecallPraiseRecordRequest();
myDto.setPraiseId(1L);
// When
Set<ConstraintViolation<RecallPraiseRecordRequest>> violations = validator.validate(myDto);
// Then
assertThat(violations).hasSize(0);
ConstraintViolation<RecallPraiseRecordRequest> violation = violations.iterator().next();
assertThat(violation.getPropertyPath().toString()).isNotEqualTo("praiseId");
}
10)core包里面new AppclientCpData()如何覆盖:
目标代码:
public Map<String,String> checekMap(String cp){
Map<String, String> map = new AppClientCpData(cp).getMap();
return map;
}
测试代码:
//测试类引入
@PrepareForTest({AESEncoderUtil.class})
@Test
public void test(){
PowerMockito.mockStatic(AESEncoderUtil.class);
userGrayService.checekMap("YXBwaWQ9MTAwODAwMDMmY2hhbm5lbD1BcHBTdG9yZSZkdWlkPTMzREU0NkI3NkFBQThBQjVDRUU2MkRGRTI2Njg2M0MxJmR4aWQ9MTg0MTFERDhCRkU5MjU2NkZBREMwNTBDRDUzMjAxMUMmZW49b3MlNDB1YyZvcz1CaUlVMXNVbFVCVHpMTHpKYzRqMlZzWSUyQk1aenR1Uk9yWDZiME9UMWdnSDAlM0QmcGlkPVBQRExvYW5BcHAmdWM9eGhkcnVOJTJCUnVMaXp6ZzElMkI4RTJ4JTJGRmxaR2QydkZrYU9wbWEwTXIzTmw5byUzRCZ2ZXI9NQ==");
}
- 多线程单元测试case:
目标代码:
public abstract class AbstractSlotStyle {
@Autowired
private ApplicationConfigServiceImpl configService;
private static final Logger LOG = LoggerFactory.getLogger(AbstractSlotStyle.class);
private static final String SLOT_MIX_TYPE_MAP = "test.slot.1-2-3.materialType";
protected void packageFrontMixType(Resource resource, MaterialPO material, SlotActivatePO slotResource) {
String slotCode = slotResource.getSlotCode();
String mixType = material.getMixType();
List<SlotMixTypeConfig> list = configService.queryListByCmp(SLOT_MIX_TYPE_MAP, "[]", SlotMixTypeConfig.class);
if (null == list || list.isEmpty()) {
resource.setMixType(((StyleResource) resource).fromMixType());
}
for (SlotMixTypeConfig unit : list) {
if (null == unit.getSlotCode() || !unit.getSlotCode().equals(slotCode)) {
continue;
}
String frontMixType = unit.findMarkByValue(mixType);
if (null == frontMixType || frontMixType.length() <= 0) {
resource.setMixType(((StyleResource) resource).fromMixType());
} else {
resource.setMixType(frontMixType);
}
return;
}
}
}
单测代码:
@Test
public void testPackageFrontMixType() throws Exception {
AbstractSlotStyle slotStyle = PowerMockito.spy(new AbstractSlotStyle() {});
ApplicationConfigServiceImpl configServiceMock = PowerMockito.mock(ApplicationConfigServiceImpl.class);
Whitebox.setInternalState(slotStyle, "configService", configServiceMock);
String a = "[{\"slotCode\":\"fca634c37d4ec75b\",\"interTypeList\":[{\"label\":\"直接跳转\",\"value\":\"jump\"}],\"mixTypeList\":[{\"label\":\"小图+角标\",\"value\":\"smallCorner\"}]}]\n";
PowerMockito.when(configServiceMock.queryListByCmp(test.slot.1-2-3.materialType", "[]", SlotMixTypeConfig.class))
.thenReturn(JSON.parseArray(a, SlotMixTypeConfig.class));
PowerMockito.whenNew(ApplicationConfigServiceImpl.class).withNoArguments().thenReturn(configServiceMock);
MaterialPO materialMock = Mockito.mock(MaterialPO.class);
Mockito.when(materialMock.getMixType()).thenReturn("mixType");
BTTEBGMCPopupResource resource = new BTTEBGMCPopupResource();
SlotActivatePO slotActivatePO = new SlotActivatePO();
slotActivatePO.setSlotCode("fca634c37d4ec75b");
resource.setMixType("frontMixType");
Whitebox.invokeMethod(slotStyle, "packageFrontMixType", resource, materialMock, slotActivatePO);
Mockito.verify(configServiceMock).queryListByCmp("test.slot.1-2-3.materialType", "[]", SlotMixTypeConfig.class);
}
6. 实施效果:
上述方案落地实施后,实施团队增量单测覆盖率逐步上升,大家统一了标准、规范和认知,能高效的编写出一套标准一致,风格一致的单测代码了,基本解决了本文开头提到的研发单测痛点,符合预期。
另外,实施前期会有一段时间的阵痛期,总体表现为:Mock单测不会写,编写效率低下,但随着大家逐步上手,以及《单测Mock升级手册》的逐步完善,同时引入一些单测Case生成插件,后期编写效率显著提升。
四、后续规划
以上,跟大家分享了Java Mock单测的落地实施过程,后续还有一些思考和规划,总结如下:
1、如何通过单测改善现有代码
虽然我们能写好单测,但是能不能通过单测反向优化我们的代码结构,提升代码的可读性和可维护性,尽量减少代码问题的出现和发生,这可能又是我们追求的新目标。
2、利用新技术更高效的写单测
随着AI技术的盛行,能不能利用或开发一些AI组件,来帮助我们尽量减少手写单测的场景,还有单测用例的自动生成,从而让我们单测编写的过程更轻松,以便有更多的时间花在业务的思考上,值得我们进一步探索。
最后,非常感谢大家的耐心阅读!因作者水平有限,难免有疏漏之处,请大家有任何疑问或建议,一定随时向作者反馈,以便我们能更好的改进,再次感谢!