Java单测Mock升级实践

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单测
  1. 静态方法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());

}

}

  1. 私有方法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));

}

}

  1. 构造方法单测:

目标代码:

//构造方法所在类:

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();

}

}

  1. 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);

//d.verify验证

Mockito.verify(userMappers).putUser(Mockito.any(UserDTO.class));

//a.对异常打桩

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);

//d.verify验证

Mockito.verify(systemErrorRecoder).addMsgError(Mockito.anyString());

  1. 异常单测:

@RunWith(PowerMockRunner.class)

@PrepareForTest({UserUtil.class})

public class SysUserUtilTest{

@Test

public void testGetSysUser() throws DataAccessException{

//a.对异常打桩

DataAccessException exception = PowerMockito.mock(DataAccessException.class);

//b.mockststic静态类

PowerMockito.mockStatic(UserUtil.class);

//c.thenThrow覆盖异常

PowerMockito.when(UserUtil.getSysUser(Mockito.anyString())).thenThrow(exception); // 重点

//d.断言真实对象调用结果

Assert.assertNull(SysUserUtil.getSysUser("test"));

}

}

  1. 真实方法调用:

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);

  1. 验证方法是否被执行过:

@Test

public void testMockitoBehavior() {

Person person = mock(Person.class);

int age = person.getAge();

//验证getAge动作有没有发生

verify(person).getAge();

//验证person.getName()是不是没有调用

verify(person, never()).getName();

//验证是否最少调用过一次person.getAge

verify(person, atLeast(1)).getAge();

//验证getAge动作是否被调用了2次,前面只用了一次所以这里会报错

verify(person, times(2)).getAge();

}

  1. 引入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);

}

}

  1. @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==");

}

  1. 多线程单元测试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组件,来帮助我们尽量减少手写单测的场景,还有单测用例的自动生成,从而让我们单测编写的过程更轻松,以便有更多的时间花在业务的思考上,值得我们进一步探索。

最后,非常感谢大家的耐心阅读!因作者水平有限,难免有疏漏之处,请大家有任何疑问或建议,一定随时向作者反馈,以便我们能更好的改进,再次感谢!

相关推荐
上海_彭彭24 分钟前
【提效工具开发】Python功能模块执行和 SQL 执行 需求整理
开发语言·python·sql·测试工具·element
3345543232 分钟前
element动态表头合并表格
开发语言·javascript·ecmascript
沈询-阿里36 分钟前
java-智能识别车牌号_基于spring ai和开源国产大模型_qwen vl
java·开发语言
AaVictory.42 分钟前
Android 开发 Java中 list实现 按照时间格式 yyyy-MM-dd HH:mm 顺序
android·java·list
残月只会敲键盘1 小时前
面相小白的php反序列化漏洞原理剖析
开发语言·php
ac-er88881 小时前
PHP弱类型安全问题
开发语言·安全·php
ac-er88881 小时前
PHP网络爬虫常见的反爬策略
开发语言·爬虫·php
爱吃喵的鲤鱼1 小时前
linux进程的状态之环境变量
linux·运维·服务器·开发语言·c++
LuckyLay1 小时前
Spring学习笔记_27——@EnableLoadTimeWeaving
java·spring boot·spring
向阳12181 小时前
Dubbo负载均衡
java·运维·负载均衡·dubbo