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

    }

}

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

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

  • 9
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值