单元测试

单元测试

1 意义

单元测试是对软件基本组成单元进行的测试,比如函数、过程或类的方法,让开发者确保自己的代码在按预期运行。为了让代码可以测试且测试易于维护,应该在编写代码之前先编写单元测试。单元测试的意义主要在于:

· 定义代码行为:代码预期内的“正确行为”,不等于代码在线上的表现,因为线上表现可能是有bug的,或者正确逻辑分支无法观测。因此对于一个模块、子系统,写测试的过程不仅仅是验证的过程,更是作者表达代码预期内正确行为的过程。单测是最佳的、自动化的、可执行的文档。没有单测覆盖的代码,是很难被维护的。

· 减少代码缺陷:我们的工程都是分层分模块的,每个模块都是独立的逻辑部分。通过单元测试保障工程各个“零件”按“规格”(需求)执行,就能保证整个“机器”(项目)运行正确,最大限度减少 bug。

· 促进代码设计:在编写单测的过程中,如果发现单测代码非常难写,一般表明被测试的代码包含了太多的依赖或职责,需要反思代码的合理性,进而促进代码设计的优化。

· 便于多人协作:在多人协助的项目中,所依赖的服务接口不一定已经开发完毕,导致服务进行联调工作。此时,单元测试有效地解决了这个问题——只需 Mock 服务接口数据,变可以完成自己代码的测试。

· 便于缺陷定位:由于单元规模较小,复杂性较低,因而发现错误后容易隔离和定位,有利于代码调试工作。

· 便于代码覆盖:单元测试可以使用白盒测试,能够进行比较充分细致的测试,很容易构造不同的测试用例来实现语句覆盖、分支覆盖、条件覆盖或路径覆盖。。

· 放心代码重构:如今持续型的项目越来越多,代码不断的在变化和重构,通过单元测试,开发可以放心的修改重构代码,减少改代码时的心理负担,提高重构的成功率。

· 增强代码信心:写完代码,单元测试通过,虽说单元测试并不能百分之百保证代码完全正确运行,但起码大部分测过的逻辑都是可用的,这会增强我们的信心,也会增加工作成就感。

2 单测流程

alt

2.1 四大步骤

2.1.1 定义对象阶段

定义对象阶段,主要包括定义被测对象、模拟依赖对象(类成员)、注入依赖对象(类成员)三大部分。

alt

2.1.2 模拟方法阶段

模拟方法阶段,主要包括模拟依赖对象(参数或返回值)、模拟依赖方法2大部分。

alt

2.1.3 调用方法阶段

调用方法阶段,主要包括模拟依赖对象(参数)、调用被测方法、验证参数对象(返回值)3步。 alt

2.1.4 验证方法阶段

验证方法阶段,主要包括验证依赖方法、验证数据对象(参数)、验证依赖对象3步。 alt

2.2 单元测试八大操作

2.2.1 定义测试对象

在编写单元测试时,首先需要定义被测对象,或直接初始化、或通过 Spy 等实例化。

// 1.直接构建对象
UserService userService = new UserService();
// 2.利用Mockito.spy方法
UserService userService = Mockito.spy(UserService.class);
// 3.利用@Spy注解
@Spyprivate 
UserService userService = new UserService();
// 4.利用@InjectMocks注解
@InjectMocks
private UserService userService;Copy

2.2.2 模拟依赖对象

在编写单元测试用例时,需要模拟各种依赖对象——类成员、方法参数和方法返回值。

// 1.直接构建对象
UserDO user = new User(1L, “test”);List<Long> userIdList = Arrays.asList(1L, 2L, 3L);
// 2.反序列化对象
UserDO user = JSON.parseObject(text, UserDO.class);List<UserDO> userList = JSON.parseArray(text, UserDO.class);Map<Long, UserDO> userMap = JSON.parseObject(text, new TypeReference<Map<Long, UserDO>>() {});
// 3.利用Mockito.mock方法
MockClass mockClass = Mockito.mock(MockClass.class);List<Long> userIdList = (List<Long>)Mockito.mock(List.class);
// 4.利用@Mock注解
@Mockprivate 
UserDAO userDAO;
// 5.利用Mockito.spy方法
UserService userService = Mockito.spy(new UserService());AbstractOssService ossService = Mockito.spy(AbstractOssService.class);
// 6.利用@Spy注解
@Spyprivate 
UserService userService = new UserService(); // 必须初始化

2.2.3 注入依赖对象

在编写单元测试用例时,需要模拟各种依赖对象——类成员、方法参数和方法返回值。

// 1.利用Setter方法注入
userService.setMaxCount(100);userService.setUserDAO(userDAO);
// 2.利用ReflectionTestUtils.setField方法注入
ReflectionTestUtils.setField(userService, "maxCount", 100);ReflectionTestUtils.setField(userService, "userDAO", userDAO);
// 3.利用Whitebox.setInternalState方法注入
Whitebox.setInternalState(userService, "maxCount", 100);Whitebox.setInternalState(userService, "userDAO", userDAO);
// 4.利用@InjectMocks注解注入
@Mockprivate 
UserDAO userDAO;@InjectMocksprivate UserService userService;
// 5.设置静态常量字段值
FieldHelper.setStaticFinalField(UserService.class, "log", log);

2.2.4 模拟依赖方法

在编写单元测试用例时,需要模拟方法指定参数并返回指定值。

根据返回模拟方法

// 1.模拟无返回值方法
Mockito.doNothing().when(userDAO).delete(userId);
// 2.模拟方法单个返回值Mockito.doReturn(user).when(userDAO).get(userId);Mockito.when(userDAO.get(userId)).thenReturn(user);
// 3.模拟方法多个返回值
Mockito.doReturn(record0, record1, record2, null).when(recordReader).read();Mockito.when(recordReader.read()).thenReturn(record0, record1, record2, null);
// 4.模拟方法定制返回值
Mockito.doAnswer(invocation -> userMap.get(invocation.getArgument(0))).when(userDAO).get(Mockito.anyLong());Mockito.when(userDAO.get(Mockito.anyLong())).thenReturn(invocation -> userMap.get(invocation.getArgument(0)));
// 5.模拟方法抛出单个异常Mockito.doThrow(exception).when(userDAO).get(Mockito.anyLong());Mockito.when(userDAO.get(Mockito.anyLong())).thenThrow(exception);
// 6.模拟方法抛出多个异常
Mockito.doThrow(exception1, exception2).when(userDAO).get(Mockito.anyLong());Mockito.when(userDAO.get(Mockito.anyLong())).thenThrow(exception1, exception2);
// 7.直接调用真实方法Mockito.doCallRealMethod().when(userService).getUser(userId);Mockito.when(userService.getUser(userId)).thenCallRealMethod();

根据参数模拟方法

// 1.模拟无参数方法Mockito.doReturn(deleteCount).when(userDAO).deleteAll();Mockito.when(userDAO.deleteAll()).thenReturn(deleteCount);
// 2.模拟指定参数方法Mockito.doReturn(user).when(userDAO).get(userId);Mockito.when(userDAO.get(userId)).thenReturn(user);
// 3.模拟任意参数方法Mockito.doReturn(user).when(userDAO).get(Mockito.anyLong());Mockito.when(userDAO.get(Mockito.anyLong())).thenReturn(user);
// 4.模拟可空参数方法Mockito.doReturn(user).when(userDAO).queryCompany(Mockito.anyLong(), Mockito.nullable(Long.class));Mockito.when(userDAO.queryCompany(Mockito.anyLong(), Mockito.<Long>any())).thenReturn(user);
// 5.模拟必空参数方法Mockito.doReturn(user).when(userDAO).queryCompany(Mockito.anyLong(), Mockito.isNull());Mockito.when(userDAO.queryCompany(Mockito.anyLong(), Mockito.eq(null))).thenReturn(user);
// 6.模拟不同参数方法Mockito.doReturn(user1).when(userDAO).get(1L);Mockito.doReturn(user2).when(userDAO).get(2L);
// 7.模拟可变参数方法Mockito.when(userService.delete(Mockito.anyLong()).thenReturn(true); 
// 匹配一个Mockito.when(userService.delete(Mockito.<Long>any()).thenReturn(true); 
// 匹配0或多个Mockito.when(userService.delete(1L, 2L, 3L).thenReturn(true);

模拟其它特殊方法添加 @PrepareForTest({UserService.class})

// 1.模拟final方法Mockito.doReturn(user).when(userService).get(userId);Mockito.when(userService.get(userId)).thenReturn(user);
// 2.模拟私有方法
PowerMockito.doReturn(true).when(UserService.class, "isSuper", userId);PowerMockito.when(UserService.class, "isSuper", userId).thenReturn(true);
LaneServiceImpl spy = PowerMockito.spy(new LaneServiceImpl());PowerMockito.doReturn(1L).when(spy,"buildResources",new ArrayList<String>(),new LaneRequestDTO());spy.countBackboneLine(new LaneRequestDTO());
// 3.模拟构造方法PowerMockito.whenNew(UserDO.class).withNoArguments().thenReturn(userDO);PowerMockito.whenNew(UserDO.class).withArguments(userId, userName).thenReturn(userDO);
// 4.模拟静态方法
// 4.1.模拟静态类
@preparefortest()PowerMockito.mockStatic(HttpHelper.class);PowerMockito.spy(HttpHelper.class);
// 4.2.模拟静态方法PowerMockito.when(HttpHelper.httpPost(SERVER_URL)).thenReturn(response);PowerMockito.doReturn(response).when(HttpHelper.class, "httpPost", SERVER_URL);PowerMockito.when(HttpHelper.class, "httpPost", SERVER_URL).thenReturn(response);

2.2.5 调用被测方法

下面,将根据有访问权限和无访问权限两种情况,来介绍如何调用被测方法。

调用构造方法

// 1.调用有访问权限的构造方法
UserDO user = new User();UserDO user = new User(1L, "admin");
// 2.调用无访问权限的构造方法
Whitebox.invokeConstructor(NumberHelper.class);Whitebox.invokeConstructor(User.class, 1L, "admin");

调用普通方法

// 1.调用有访问权限的普通方法
userService.deleteUser(userId);User user = userService.getUser(userId);
// 2.调用无访问权限的普通方法
User user = Whitebox.invokeMethod(userService, "isSuper", userId);

调用静态方法

// 1.调用有访问权限的普通方法
boolean isPositive = NumberHelper.isPositive(-1);
// 2.调用无访问权限的普通方法
String value = Whitebox.invokeMethod(JSON.class, "toJSONString", object);

2.2.6 验证依赖方法

在单元测试中,验证是确认模拟的依赖方法是否按照预期被调用或未调用的过程。

根据参数验证方法调用

// 1.验证无参数方法调用Mockito.verify(userDAO).deleteAll();
// 2.验证指定参数方法调用Mockito.verify(userDAO).delete(userId);
// 3.验证任意参数方法调用Mockito.verify(userDAO).delete(Mockito.anyLong());
// 4.验证可空参数方法调用Mockito.verify(userDAO).queryCompany(Mockito.anyLong(), Mockito.nullable(Long.class));
// 5.验证必空参数方法调用Mockito.verify(userDAO).queryCompany(Mockito.anyLong(), Mockito.isNull());
// 6.验证可变参数方法调用Mockito.verify(userService).delete(1L, 2L, 3L);Mockito.verify(userService).delete(Mockito.any(Long.class)); // 匹配一个Mockito.verify(userService).delete(Mockito.<Long>any());

验证方法调用次数

// 1.验证方法默认调用1次Mockito.verify(userDAO).delete(userId);
// 2.验证方法从不调用Mockito.verify(userDAO, Mockito.never()).delete(userId);
// 3.验证方法调用n次Mockito.verify(userDAO, Mockito.times(n)).delete(userId);
// 4.验证方法调用至少1次Mockito.verify(userDAO, Mockito.atLeastOnce()).delete(userId);
// 5.验证方法调用至少n次Mockito.verify(userDAO, Mockito.atLeast(n)).delete(userId);
// 6.验证方法调用最多1次Mockito.verify(userDAO, Mockito.atMostOnce()).delete(userId);
// 7.验证方法调用最多n次Mockito.verify(userDAO, Mockito.atMost(n)).delete(userId); 
// 8.验证方法调用指定n次Mockito.verify(userDAO, Mockito.call(n)).delete(userId); 
// 9.验证对象及其方法调用1次Mockito.verify(userDAO, Mockito.only()).delete(userId);

验证方法调用并捕获参数值

// 1.使用ArgumentCaptor.forClass方法定义参数捕获器ArgumentCaptor<UserDO> userCaptor = ArgumentCaptor.forClass(UserDO.class);Mockito.verify(userDAO).modify(userCaptor.capture());UserDO user = userCaptor.getValue();
// 2.使用@Captor注解定义参数捕获器@Captorprivate ArgumentCaptor<UserDO> userCaptor;
// 3.捕获多次方法调用的参数值列表
ArgumentCaptor<UserDO> userCaptor = ArgumentCaptor.forClass(UserDO.class);Mockito.verify(userDAO, Mockito.atLeastOnce()).modify(userCaptor.capture());List<UserDO> userList = userCaptor.getAllValues();

验证其它特殊方法

// 1.验证final方法调用final方法的验证跟普通方法类似。
// 2.验证私有方法调用PowerMockito.verifyPrivate(myClass, times(1)).invoke("unload", any(List.class));
// 3.验证构造方法调用PowerMockito.verifyNew(MockClass.class).withNoArguments();PowerMockito.verifyNew(MockClass.class).withArguments(someArgs);
// 4.验证静态方法调用PowerMockito.verifyStatic(StringUtils.class);StringUtils.isEmpty(string);

2.2.7 验证依赖方法

在调用被测方法时,需要对返回值和异常进行验证;在验证方法调用时,也需要对捕获的参数值进行验证。

验证数据对象空值

// 1.验证数据对象为空Assert.assertNull("用户标识必须为空", userId);
// 2.验证数据对象非空Assert.assertNotNull("用户标识不能为空", userId);

验证数据对象布尔值

// 1.验证数据对象为真Assert.assertTrue("返回值必须为真", NumberHelper.isPositive(1));
// 2.验证数据对象为假Assert.assertFalse("返回值必须为假", NumberHelper.isPositive(-1));

验证数据对象引用

// 1.验证数据对象一致Assert.assertSame("用户必须一致", expectedUser, actualUser);
// 2.验证数据对象不一致Assert.assertNotSame("用户不能一致", expectedUser, actualUser);

验证数据对象值

// 1.验证简单数据对象Assert.assertNotEquals("用户名称不一致", "admin", userName);Assert.assertEquals("账户金额不一致", 10000.0D, accountAmount, 1E-6D);
// 2.验证简单数组或集合对象Assert.assertArrayEquals("用户标识列表不一致", new Long[] {1L, 2L, 3L}, userIds);Assert.assertEquals("用户标识列表不一致", Arrays.asList(1L, 2L, 3L), userIdList);
// 3.验证复杂数据对象Assert.assertEquals("用户标识不一致", Long.valueOf(1L), user.getId());Assert.assertEquals("用户名称不一致", "admin", user.getName());
// 4.验证复杂数组或集合对象Assert.assertEquals("用户列表长度不一致", expectedUserList.size(), actualUserList.size());UserDO[] expectedUsers = expectedUserList.toArray(new UserDO[0]);UserDO[] actualUsers = actualUserList.toArray(new UserDO[0]);for (int i = 0; i < actualUsers.length; i++) {  Assert.assertEquals(String.format("用户(%s)标识不一致", i), expectedUsers[i].getId(), actualUsers[i].getId());  Assert.assertEquals(String.format("用户(%s)名称不一致", i), expectedUsers[i].getName(), actualUsers[i].getName()); ...};
// 5.通过序列化验证数据对象String text = ResourceHelper.getResourceAsString(getClass(), "userList.json");Assert.assertEquals("用户列表不一致", text, JSON.toJSONString(userList));;
// 6.验证数据对象私有属性字段Assert.assertEquals("基础包不一致", "com.alibaba.example", Whitebox.getInternalState(configurer, "basePackage"));

验证异常对象内容

// 1.验证数据对象为空:@Test(expected = ExampleException.class)public void testGetUser() { 
// 模拟依赖方法 Long userId = 123L; Mockito.doReturn(null).when(userDAO).get(userId); 
// 调用被测方法 userService.getUser(userId);}
// 2.通过@Rule注解验证异常对象:
@Rule
private ExpectedException exception = ExpectedException.none();
@Test
public void testGetUser() { 
// 模拟依赖方法 
Long userId = 123L; 
Mockito.doReturn(null).when(userDAO).get(userId);  
// 调用被测方法 
exception.expect(ExampleException.class);  
exception.expectMessage(String.format("用户(%s)不存在", userId));  userService.getUser(userId);}
// 3.通过Assert.assertThrows验证异常对象:
@Test
public void testGetUser() { 
// 模拟依赖方法 Long userId = 123L; Mockito.doReturn(null).when(userDAO).get(userId);  
// 调用被测方法 ExampleException exception = Assert.assertThrows("异常类型不一致", ExampleException.class, () -> userService.getUser(userId)); Assert.assertEquals("异常消息不一致", String.format("用户(%s)不存在", userId), exception.getMessage());}

2.2.8 验证依赖对象

验证模拟对象有没有方法调用。

// 1.验证模拟对象没有任何方法调用Mockito.verifyNoInteractions(idGenerator, userDAO);
// 2.验证模拟对象没有更多方法调用Mockito.verifyNoMoreInteractions(idGenerator, userDAO);Mockito.verifyZeroInteractions (idGenerator, userDAO); 

3 单元测试案例

依赖

<!-- PowerMock -->
<dependency>
  <groupId>junit</groupId>
  <artifactId>junit</artifactId>
  <version>4.13.1</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.mockito</groupId>
  <artifactId>mockito-core</artifactId>
  <version>3.3.3</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.powermock</groupId>
  <artifactId>powermock-module-junit4</artifactId>
  <version>2.0.9</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.powermock</groupId>
  <artifactId>powermock-api-mockito2</artifactId>
  <version>2.0.9</version>
  <scope>test</scope>
</dependency>

准备

@Before
    public void setUp(){
        MockitoAnnotations.initMocks(this);
    }

普通方法

@Test
public void test_queryBackboneLineDetail_with_scheduleDoEqualNull(){
    Mockito.doReturn(null).when(scheduleDOMapper).selectByPrimaryKey(1L);
    laneService.queryBackboneLineDetail(1L);
    }

静态方法:测试类上添加preparefortest注解

@Test
    public void test_queryLaneDetailPage(){
        PowerMockito.mockStatic(LandlordContext.class);
        LaneDetailRequestDTO requestDTO = new LaneDetailRequestDTO();
        List<LineScheduleRelationDetailDO> relationDetailDOs = new ArrayList<>();
        relationDetailDOs.add(new LineScheduleRelationDetailDO());
        Mockito.when(LandlordContext.getCurrentTenantId()).thenReturn("");
        Mockito.doReturn(relationDetailDOs).when(lineScheduleRelationDetailDOMapperExt).select("LAZADA_ID",null,null,null,null,null,requestDTO.getScheduleId());
        laneService.queryLaneDetailPage(requestDTO);
    }

私有方法

@SneakyThrows
    @Test
    public void test_countBackboneLine(){
        LaneServiceImpl spy = PowerMockito.spy(new LaneServiceImpl());
        PowerMockito.doReturn(1L).when(spy,"buildResources",new ArrayList<String>(),new LaneRequestDTO());
        spy.countBackboneLine(new LaneRequestDTO());
    }

个人遇到的坑

mock方法返回null

原因:mock匹配是equals,传入对象没有重写equals和hash code方法

解决:使用mock的模糊匹配

@Before
    public void setup(){
        MockitoAnnotations.initMocks(this);
    }
@Test
public void test_queryById_when_dataNotNull() {
        List<DataCompareInstanceDO> data = new ArrayList<>();
        data.add(new DataCompareInstanceDO());
        PowerMockito.mockStatic(LandlordContext.class);
        PowerMockito.when(LandlordContext.getCurrentTenantId()).thenReturn("LAZADA_SG");
        Mockito.when(dataCompareInstanceDOMapper.selectByCondition(Mockito.any(DataCompareInstanceQuery.class))).thenReturn(data);
        DataCompareInstanceDO result = dataCompareService.queryById(1L);
        Assert.assertEquals("不一致", result, data.get(0));
    }

mock @value 注解

@InjectMocks
    private NetWorkKeyCenterConfig centerConfig;

    @Before
    public void setup(){
        MockitoAnnotations.initMocks(this);
        // Mock @value 注解
        ReflectionTestUtils.setField(centerConfig,"httpServiceAddress","httpServiceAddress");
        ReflectionTestUtils.setField(centerConfig,"appPublishNum","appPublishNum");
    }

最新验证方法:AssertJ

依赖

<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.4.1</version>
    <scope>test</scope>
</dependency>

用法

import static org.assertj.core.api.Assertions.assertThat;
assertThat(frodo)
  .isNotEqualTo(sauron)
  .isIn(fellowshipOfTheRing);

assertThat(frodo.getName())
  .startsWith("Fro")
  .endsWith("do")
  .isEqualToIgnoringCase("frodo");

assertThat(fellowshipOfTheRing)
  .hasSize(9)
  .contains(frodo, sam)
  .doesNotContain(sauron);

POJO验证:mean bean

Mean Bean is an open source Java test library that helps you rapidly and reliably test fundamental objects within your software system, namely your domain and data objects. Mean Bean:

\1. Tests that the getter and setter method pairs of a JavaBean/POJO function correctly.

\2. Verifies that the equals and hashCode methods of a class comply with the Equals Contract and HashCode Contract respectively.

Verifies property significance in object equality.

用法

new BeanTester().testBean(MyDomainObject.class);
全部评论

相关推荐

避坑恶心到我了大家好,今天我想跟大家聊聊我在成都千子成智能科技有限公司(以下简称千子成)的求职经历,希望能给大家一些参考。千子成的母公司是“同创主悦”,主要经营各种产品,比如菜刀、POS机、电话卡等等。听起来是不是有点像地推销售公司?没错,就是那种类型的公司。我当时刚毕业,急需一份临时工作,所以在BOSS上看到了千子成的招聘信息。他们承诺无责底薪5000元,还包住宿,这吸引了我。面试的时候,HR也说了同样的话,感觉挺靠谱的。于是,我满怀期待地等待结果。结果出来后,我通过了面试,第二天就收到了试岗通知。试岗的内容就是地推销售,公司划定一个区域,然后你就得见人就问,问店铺、问路人,一直问到他们有意向为止。如果他们有兴趣,你就得摇同事帮忙推动,促进成交。说说一天的工作安排吧。工作时间是从早上8:30到晚上18:30。早上7点有人叫你起床,收拾后去公司,然后唱歌跳舞(销售公司都这样),7:55早课(类似宣誓),8:05同事间联系销售话术,8:15分享销售技巧,8:30经理训话。9:20左右从公司下市场,公交、地铁、自行车自费。到了市场大概10点左右,开始地推工作。中午吃饭时间大约是12:00,公司附近的路边盖饭面馆店自费AA,吃饭时间大约40分钟左右。吃完饭后继续地推工作,没有所谓的固定中午午休时间。下午6点下班后返回公司,不能直接下班,需要与同事交流话术,经理讲话洗脑。正常情况下9点下班。整个上班的一天中,早上到公司就是站着的,到晚上下班前都是站着。每天步数2万步以上。公司员工没有自己的工位,百来号人挤在一个20平方米的空间里听经理洗脑。白天就在市场上奔波,公司的投入成本几乎只有租金和工资,没有中央空调。早上2小时,晚上加班2小时,纯蒸桑拿。没有任何福利,节假日也没有3倍工资之类的。偶尔会有冲的酸梅汤和西瓜什么的。公司的晋升路径也很有意思:新人—组长—领队—主管—副经理—经理。要求是业绩和团队人数,类似传销模式,把人留下来。新人不能加微信、不能吐槽公司、不能有负面情绪、不能谈恋爱、不能说累。在公司没有任何坐的地方,不能依墙而坐。早上吃早饭在公司外面的安全通道,未到上班时间还会让你吃快些不能磨蹭。总之就是想榨干你。复试的时候,带你的师傅会给你营造一个钱多事少离家近的工作氛围,吹嘘工资有多高、还能吹自己毕业于好大学。然后让你早点来公司、无偿加班、抓住你可能不会走的心思进一步压榨你。总之,大家在找工作的时候一定要擦亮眼睛,避免踩坑!———来自网友
qq乃乃好喝到咩噗茶:不要做没有专业门槛的工作
点赞 评论 收藏
分享
05-26 10:24
门头沟学院 Java
qq乃乃好喝到咩噗茶:其实是对的,线上面试容易被人当野怪刷了
找工作时遇到的神仙HR
点赞 评论 收藏
分享
评论
点赞
1
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务