工作3年,还不会写单元测试?新技能get(二)

发表于:2022-1-27 09:24

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:Java码农之路    来源:今日头条

  Fake,Stub和Mock
  我们的被测试代码存在的外部依赖的行为往往是不可预测的,我们需要将这些"变化"变得可控,根据职责不同,可以分为Fake,Stubs,Mock三种。
  假数据(Fake), 一些针对当前场景构建的简化版的对象,这些对象作为数据源供我们使用,职责就像内存数据库一样。
  比如在常见的三层架构中,业务逻辑层需要依赖数据访问层,当业务逻辑层开发完成后即使数据访问层没有开发完成,也能通过构建Fake数据的方式完成业务逻辑层的测试。
  CopyUserDO fakeUser = new UserDO("zhangsan", "zhangsan@163.com");
  public UserVO getUser(Long userId) {
    // do something
    User user = fakeUser;  // 测试阶段替换:User user = userDao.getById(userId);
    // do something
  }
  Fake数据虽然可以测试逻辑,但是当数据访问层开发完毕后可能需要修改代码,将Fake数据替换为实际的方法调用来完成代码集成,显然这不是一种优雅的实现,于是便有了Stub。
  桩代码(Stub)是用来代替真实代码的临时代码,是在测试环境对依赖接口的一种专门实现。
  比如,UserService中调用了UseDao,为了对UserService中的函数进行测试,这时候需要构建一个UserDao接口的实现类UserDaoStub(返回Fake数据),这个临时代码就是所谓的桩代码。


  Copypublic class UserDaoStub implements UserDao {
      UserDO fakeUser = new UserDO();
      {
          fakeUser.setUserName("zhangsan");
          fakeUser.setEmail("zhangsan@163.com");
          LocalDateTime dateTime = LocalDateTime.of(2021, 7, 1, 12, 30, 0);
          fakeUser.setCreateTime(dateTime);
          fakeUser.setUpdateTime(dateTime);
      }
      @Override
      public UserDO getById(Long id) {
          if (Objects.isNull(id) || id <= 0) {
              return new UserDO();
          }
          return fakeUser;
      }
  }
  这种面向接口编程,使得在不同场景下通过不同的实现类替换接口的编程设计原则就是我们常说的里氏替换原则。
  Mock 代码和桩代码非常类似,都是用来代替真实代码的临时代码。不同的是在被调用时,会记录被调用信息,执行完毕后验证执行动作或结果是否符合预期。
  对于 Mock 代码来说,我们的关注点是 Mock 方法有没有被调用,以什么样的参数被调用,被调用的次数,以及多个 Mock 函数的先后调用顺序。
  Copy    @Test
      public void testAddUser4SendEmail() {
          // GIVEN:
          AddUserRequest fakeAddUserRequest = new AddUserRequest("zhangsan", "zhangsan@163.com");
          // WHEN
          ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
          // THEN
          assertTrue(addResult.isSuccess());
          // 验证sendVerifyEmail的调用1次,并且调用参数为我们fake数据中指定的邮箱
          verify(emailService, times(1)).sendVerifyEmail(any());
          verify(emailService).sendVerifyEmail(fakeAddUserRequest.getEmail());
      }
  当然,我们也可以通过修改Stub的实现,达到和Mock一样的效果。


  当然,我们也可以通过修改Stub的实现,达到和Mock一样的效果。
  Copypublic class EmailServiceStub implements EmailService{
      public int invokeCount = 0;
      @Override
      public boolean sendVerifyEmail(String email) {
          invokeCount ++;
          // do something
          return true;
      }
  }
  public class UserServiceImplTest {
      AddUserRequest fakeAddUserRequest;
      private UserServiceImpl userService;
      private EmailServiceStub emailServiceStub;
      @Before
      public void init() {
          fakeAddUserRequest = new AddUserRequest("zhangsan", "zhangsan@163.com");
          emailServiceStub = new EmailServiceStub();
          userService= new UserServiceImpl();
          userService.setEmailService(emailServiceStub);
      }
      @Test
      public void testAddUser4SendEmail() {
          // GIVEN: fakeAddUserRequest
          // WHEN
          ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
          // THEN:发送邮件接口被调用次数是否为1
          Assert.assertEquals(emailServiceStub.invokeCount, 1);
      }
  }

  Stub和Mock的区别
  Stub和Mock的区别在于,Stub偏向于结果验证,Mock则更加偏向于行为验证。
  比如,测试addUser方法时,如果是Stub方式则关注方法返回结果,即用户是否添加成功,邮件是否发送成功;而Mock方式则倾向于本次添加的行为验证,比如sendEmail方法调用次数等。
  Mock替代Stub
  Mock和Stub本质上是不同的,但是随着各种Mock框架的引入,Stub和Mock的边界越来越模糊,使得Mock不仅可以进行行为验证,同样也具备Stub对接口的假实现的能力。
  目前大多数的mock工具都提供mock退化为stub的支持,以Mockito为例,我们可以通过anyObject(), any等方式对参数的进行匹配;使用verify方法可以对方法的调用次数和参数进行检验,这和stub就几乎没有本质区别了。
  Copywhen(userDao.insert(any())).thenReturn(1L);
  when(emailService.sendVerifyEmail(anyString())).thenReturn(true);
  stub理论上也是可以向mock的方向做转化,上文也提及stub是可以通过增加代码来实现一些expectiation的特性,而从使得两者的界限更加的模糊。
  所以,如果对于Stub和Mock的概念还是比较模糊,也不必过度纠结,这并不影响写出优秀的单测。
  R—Repeatable:可重复执行
  单测是可以重复执行的,不能受到外界环境的影响。 同一测试用例,即使是在不同的机器,不同的环境中运行多次,每次运行都会产生相同的结果。
  避免隐式输入(Hidden imput),比如测试代码中不能依赖当前日期,随机数等,否则程序就会变得不可控从而变得不可重复执行。
  S—Self-verifying:自我验证
  单测需要通过断言进行结果验证,即当单测执行完毕之后,用来判断执行结果是否和假设一致,无需人工检查是否执行成功。
  当然,除了对执行结果进行检查,也能对执行过程进行校验,如方法调用次数等。下面是笔者在工作中经常见到的写法,这些都是无效的单测。
  Copy// 直接打印结果
  public void testAddUser4DbError() {
      // GIVEN
      fakeAddUserRequest.setUserName("badcase");
      // WHEN
      ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
      // THEN
      System.out.println(addResult);
  }
  // 吞没异常失败case
  public void testAddUser4DbError() {
      // GIVEN
      fakeAddUserRequest.setUserName("badcase");
      // WHEN
    try {
        ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
      // THEN
        Assert.assertTrue(addResult.isSuccess());
      } catch(Exception e) {
          System.out.println("测试执行失败");
      }
  }
  正解如下:
  Copy@Test
  public void testAddUser4DbError() {
    // GIVEN
    fakeAddUserRequest.setUserName("badcase");
    // WHEN
    ResultDTO<Long> addResult = userService.addUser(fakeAddUserRequest);
    // THEN
    Assert.assertEquals(addResult.getMsg(), "添加用户失败,请稍后重试");
  }

  T—Timely&Thorough:及时,全面
  理想状态当然是TDD模式开发,即测试驱动开发。如前面提到的,编写代码逻辑之前写最佳,边开发边写次之,等代码稳定运行再来补单测收益可能是最低的。
  除了及时性,笔者认为T应当有另一层含义,即全面性(Thorough)。理想情况下每行代码都要被覆盖到,每一个逻辑分支都必须有一个测试用例。
  不过想要100%的测试覆盖率是非常耗费精力的,甚至会和我们最初提高效率的初衷相悖。所以花合理的时间抓出大多数bug,要好过穷尽一生抓出所有bug。
  通常情况下我们要至少考虑到参数的边界,特殊值,正常场景(与设计文档结合)以及异常场景,保证我们的核心流程是正确的。

  本文内容不用于商业目的,如涉及知识产权问题,请权利人联系51Testing小编(021-64471599-8017),我们将立即处理
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号