• 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包:

        2.0.2

        2.23.4

    org.mockito

    mockito-core

    <version>${mockito.version}version>

    org.powermock

    powermock-module-junit4

    <version>${powermock.version}version>

    org.powermock

    powermock-api-mockito2

    <version>${powermock.version}version>

    Module中引用,scope范围为test:

    org.mockito

    mockito-core

    test

    org.powermock

    powermock-module-junit4

    test

    org.powermock

    powermock-api-mockito2

    test

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

        }

    }

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

        }

    }

    3) 构造方法单测:

    目标代码:

    //构造方法所在类:

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

        }

    }

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

    5) 异常单测:

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

        }

    }

    6) 真实方法调用:

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

    7) 验证方法是否被执行过:

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

    }

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

        }

    }

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

    }

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

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

  • 相关阅读:
    嵌入式系统开发笔记88:认识51微控制器系统架构
    Java序列化和反序列化
    Qt控件设置Icon
    如何使用SMS向客户传递服务信息?指南在这里!
    代码随想录第二十九天打卡| 491.递增子序列,46.全排列,47.全排列 II
    【全开源】多功能投票小程序源码(Uniapp+ThinkPHP+FastAdmin)
    Java线程池基础
    【落地应用】华为之图神经网络在推荐系统中的应用
    语句和表达式有什么不同
    Leetcode 2926. Maximum Balanced Subsequence Sum
  • 原文地址:https://blog.csdn.net/xinyebudaoshi/article/details/136472803