单元测试框架和覆盖率统计原理简析(二)

发表于:2022-4-02 10:01

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

 作者:阿里技术    来源:阿里技术

分享:
  四、Mock 编程
  单元测试中,一个重要原则就是不扩大测试范围,尽可能将 mock 外部依赖,例如外部的 RPC 服务、数据库等中间件。被 mock 的对象可以称作。
  「测试替身」,它来源于电影中的特技替身的概念。Meszaros 在他的文中[2]定义了五类替身。
  测试替身的分类
  1.fake、spy、stub、mock 如何区分
  为了帮助更好的理解「测试替身」在实际单元测试中的应用,我们看几个例子:
  fake
  假设有一个库存系统,当有订单时会从仓库中提货,如果货物不足则无法完成订单。单元测试是不应该依赖外部服务的,例如网络,因为网络是不可靠状态,所以我们应该用一个 fake warehouse 来伪造发送邮件的功能,它需要实现 Warehouse 抽象类,是一个可用的库存服务实现,但它只会将内容维护在内存中,而不会持久化到数据库或外部存储中。
  private static String APPLE = "Apple";
  private static String PEACH = "Peach";
  private Warehouse warehouse = new WarehouseImpl();
  @Before
  public void setUp() throws Exception {
    warehouse.add(APPLE, 50);
    warehouse.add(PEACH, 25);
  }
  @After
  public void tearDown() {
    warehouse = new WarehouseImpl();
  }
  @Test
  public void testOrderIsFilledIfEnoughInWarehouse() {
    Order order = new Order(APPLE, 50);
    warehouse.fill(order);
    assertTrue(order.isFilled());
    assertEquals(0, warehouse.countGoods(APPLE));
  }
  @Test
  public void testOrderDoesNotRemoveIfNotEnough() {
    Order order = new Order(APPLE, 51);
    warehouse.fill(order);
    assertFalse(order.isFilled());
    assertEquals(50, warehouse.countGoods(APPLE));
  }

  stub
  stub 是具有固定响应行为的 mock,目的是让测试用例跑通,不作为关键测试环节。
  我们假定一个测试场景,当库存不能满足订单所需要的货物数量时,我们需要自动发送一封邮件,因此邮件服务的 Stub 可以简单实现为:
  public interface MailService {
    public void send (Mail mail);
  }
  public class MailServiceStub implements MailService {
    private List<Mail> sentMails = new ArrayList<Mail>();
    public void send (Mail mail) {
      sentMails.add(mail);
    }
    public int mailSent() {
      return sentMails.size();
    }
  }

  代码中由于订单需要货物不足,会发送一封邮件,我们需要验证是否发送。
  private static String APPLE = "Apple";
  private static String PEACH = "Peach";
  private MailService mailService = new MailServiceStub()
  private Warehouse warehouse = new WarehouseImpl(mailService);
  // 省略 @Before 和 @After
  public void testOrderSendsMailIfUnfilled() {
    Order order = new Order(APPLE, 51);
    warehouse.fill(order);
    assertEquals(1, mailService.mailSent());
  }

  我们在测试中使用了状态验证,即验证当前数据的状态和预期是否一致。如果是使用 mock 来做,两者区别就在 stub 使用了状态验证,而 mock 则使用行为验证,即验证某些方法是否被调用验证分支路径是否已覆盖或符合我们的业务设计。
  public void testOrderSendsMailIfUnfilled() {
      Order order = new Order(APPLE, 51);
      Warehouse warehouse = new WarehouseImpl();
      MailService mailService = mock(MailService.class);
      warehouse.setMailer(mailService);
      warehouse.fill(order);
     
      Mockito.verify(mailService).send(Mockito.any());
   }

  spy
  spy 这个单词是间谍的意思,顾名思义间谍主要的工作就是收集情报,因此 spy 对象的作用就是去收集每次调用的参数、返回值、调用this、抛出的异常。spy 最主要的特性就是它只收集情报,不提供任何默认行为。
  mock
  mock 对象只有传入的参数满足设定时,才会触发 mock 行为。因此 mock 对象多使用行为验证,其他三类对象也可以使用行为验证。
  对于第一个例子,我们只是要确定对于某一个订单,在库存不足时订单 fill 会失败,主要测试对象是订单 Order,过程中依赖对象是仓库 Warehouse。
  private static String APPLE = "Apple";
  public void testFillingRemovesInventoryIfInStock() {
    //setup
    Order order = new Order(APPLE, 50);
    Warehouse warehouseMock = mock(Warehouse.class);
    //exercise
    warehouseMock.fill(order);
    //verify
    Mockito.verify(warehouseMock).remove(Mockito.eq(APPLE), Mockito.eq(50));
    assertTrue(order.isFilled());
  }

  2.Mockito
  在上面的例子中,我们大量使用了 Mockito 作为 mock 工具,现在我们来简单对这个工具做个介绍,帮助大家进一步理解上面 mock 和验证过程中发生的事。下面的例子是一个 JUnit 结合 Mockito 单元测试,通过 @InjectMocks 声明被测试的对象,通过 @Mock 声明被测试类依赖的对象。通过 Mockito.doReturn 等方法即可定义 mock 对象的行为。
  @RunWith(MockitoJUnitRunner.class)
  public class UserControllerTest {
    @InjectMocks
    private UserController userController;  
    @Mock
    private UserService userService;  
    @Test
    public void testService() {
        doReturn(null).when(userService).listUser();
        userController.listUser();
    }
  }

  @Mock 对象 @Spy 对象
  在 Mockito 中,mock 对象和 spy 对象都可以进行 mock。区别是 mock 会代理的全部方法,对应方法没有 stubbing 时返回默认值。而 spy 只是将有桩实现(stubbing)的调用进行 mock,其余方法仍然是实际调用原对象的方法。
  Mockito 默认使用 bytebuddy 生成类,这里的实现过程,类似动态代理问题中,常见的基于接口实现和子类实现两种代理方式。为了更好了解 mock 对象工作方式,我们先 dump 一个接口类型 mock 后的 class 文件。
  public class UserService$MockitoMock$450450480 implements UserService, MockAccess {
      private static final long serialVersionUID = 42L;
      private MockMethodInterceptor mockitoInterceptor;
      // 这里省略了 equals、toString、hashCode、clone 方法的代理
      private static Method cachedValue$jWkXotML$7kplrf1;
      static {
          cachedValue$jWkXotML$7kplrf1 = UserService.class.getMethod("listUser");
      }
      public List<User> listUser() {
          return (List<User>)DispatcherDefaultingToRealMethod.interceptAbstract(this, this.mockitoInterceptor, false, cachedValue$jWkXotML$7kplrf1);
      }
  }

  被 mock 的类,方法被代理到 MockHandler;
  经过上面的分析,这里可以推出两点:
  ·对于无接口的类来说,会生成被 mock 类的子类,内部调用略有不同,但最终仍然会调用到 MockHandler。
  · Mockito.spy 本质仍然是做 mock,只是添加了默认调用原始方法的策略。
  // Mockito.mock or @Mock
  public static <T> T mock(Class<T> classToMock, 
                           MockSettings mockSettings) {
    return MOCKITO_CORE.mock(classToMock, mockSettings);
  }


  // Mocktio.spy or @Spy
  public static <T> T spy(Class<T> classToSpy) {
    return MOCKITO_CORE.mock(classToSpy, 
                  withSettings().useConstructor()
                  // 默认响应方式优先调用原类的方法
                  .defaultAnswer(CALLS_REAL_METHODS));
  }

  从 Mockito.mock 和 Mockito.spy 的方法实现可以看出,spy 方法也仅是注入了默认的 answer 行为,即调用真实方法。
  这里可以推理一下 spy 一个接口后,默认的返回是什么?接口一般是没有默认实现,spy 的接口调用时又该调用什么呢?
  @Mock 对象方法的调用和验证
  MockHandler 最主要的实现类为 MockHandlerImpl,我们来看下这个类的主要流程。MockHandler 默认会拦截 mock 对象所有的方法调用(super、equals、toString、hashCode 等方法先不讨论)。
  mock 对象方法被调用时,先查找是否已经在当前线程中植入过调用验证对象 「VerificationMode」(可以通过 Mockito.verify 植入),如果存在则执行方法调用验证,不再调用 mock 方法。
  这个例子可以帮助我们理解 Mockito 做方法验证的过程。userDao 是 mock 对象,userService 内部会调用 userDao#deleteUser 方法。
  @InjectMocks
    UserService userService;
    @Mock
    UserDao userDao;
    @Test
    public void testAddResourcePoliciesWithoutMember() {
      // setup
      Long userId = 100L;
      DeleteUserRequest request = new DeleteUserRequest();
      request.setUserId(userId);
      // exercise
      DeleteUserResult result = userService.deleteUser(request);
      // verify
      Assert.assertTrue(result.isSuccess());
      //|----- 植入验证 ------|-- 再调用触发验证 --------------|
      Mockito.verify(userDao).deleteUser(request.getUserId());
    }

  在 verify 调用前,userService.deleteUser 内部会调用 userDao#deleteUser 记录一次方法调用,Mockito#verify 时注入验证对象「VerificationMode」,再链式调用了 deleteUser 再次调用方法触发验证。
  @Mock 对象方法参数的验证
  mock 对象的参数匹配,是基于栈做方法调用和参数记录。核心类是 ArgumentMatcher,当查找 mock 方法的 stub 对象时,不仅需要匹配方法的 invocation 标识,还需要匹配对应的参数,即 Mockito.eq()、 Mockito.anyList() 等。
  匹配的实现原理可以类比 Java 的 equals,如使用 Mockito.anyString(),则入参必须是不为 null 的 String。
  3.字节码编辑
  Mockito 默认实现的 mock 也是一种动态代理技术,在方法级别进行拦截和调用我们指定的 stub 对象,与我们经常讨论的 JDK Proxy、Cglib 等 AOP 技术非常相似。
  从 Java 动态代理实现上来看,可分为两种策略和手段:操作原始类字节码或者生成子类或实现接口的新类。在实际的使用中,代理类的生成仍然可能依赖字节码的动态生成方式,并没有严格的界限。
  常见的动态代理仅限于实例方法级别,对于方法内部如构造方法、静态方法和静态块、初始块、new、字段访问、catch、instanceof 等字节码指令通常无能为力,只能求助于操作原始的字节码来达到目的。
  生成接口实现或者子类的代理也有一定的局限性:例如父类的 final 方法是无法被动态子类代理的。
  私有方法也无法通过 Mockito 进行打桩,因此在项目的单元测试编写中,Mockito 的手段有些不够用,于是就有了基于 Mockito 的 PowerMockito。
  4.PowerMockito
  PowerMockito 使用 Javaassist 作为字节码编辑的框架。PowerMockito 会默认对相关类的字节码做以下修改:
  ·去除 final class 的 final 修饰符
  · 将所有构造方法修改为 public
  · 为 new 对象、字段访问、构造器注入代理
  · 去除 static final fields 的 final 声明
  · 为类的修饰符添加 public
  · 为方法注入代理对象 MockGateway
  · 将 @SuppressStaticInitializationFor 声明的类的静态块替换为 {}
  将超长方法体(超 65535 字节)替换为抛异常
  来看一个 PowerMock 的使用例子:
  @RunWith(PowerMockRunner.class)
  @PrepareForTest({ListUtils.class})
  public class UserControllerTest {
      @InjectMocks
      private UserController userController;
      @Mock
      private UserService userService;
      @Test
      public void testService() {
          doReturn(null).when(userService).listUser();
          userController.listUser();
      }
  }

  这里与 Mockito 的区别是 JUnit Runner 指定为 PowerMockRunner,在新注解 @PrepareForTest 中声明的类,运行测试用例时,会创建一个新的 org.powermock.core.classloader.MockClassLoader 类加载器实例,来加载声明的类,从而完成对目标类指令级别的修改。
  在 surefire 插件的 Runner 创建时,可以在下面的调用栈中看到,由 surefire 插件的 JUnit4Provider 代理到 JUnit,JUnit 负责 Runner 对象的初始化和调用。在 PowerMockRunner 初始化的过程中,基于自定义类加载器做到类的修改。
  有时候我们需要对某些类的静态块屏蔽,保证测试用例可以正常运行,PowerMock 提供了 @SuppressStaticInitializationFor 注解,只需要在测试类上声明即可。
  需要注意的是,屏蔽静态块代码后,类的静态字段也不会被初始化,因为静态字段的初始化是被编译在静态块中,这点需要注意。如果你屏蔽了静态块中的一些方法,但仍然依赖一些静态字段,可能会产生一些异常情况,如空指针。这时候需要额外的 mock 或者手动初始化静态字段。
  我们来看一段对 new 操作符修改后的方法,下面表格中提供了一些 Javassist 的变量说明便于理解下面的例子。

$0,$1,$2,··· this and actual parameters 
$args An array of parameters. The type of $args is Object[]. 
$$
All actual parameters.
For example, m($$) is equivalent to m($1,$2,...) 
$cflow(...)cflow variable
$rThe result type. It is used in a cast expression.
$wThe wrapper type. It is used in a cast expression.
$_The resulting value
$sigAn array of java.lang.Class objects representing the formal parameter types.
$typeA java.lang.Class object representing the formal result type.
$classA java.lang.Class object representing the class currently edited.
$proceedThe name of the method originally called in the expression.


  PowerMock 会将 new 对象的字节码替换为代理到 MockGateway#newInstanceCall 的静态方法调用,方法返回的对象如果不是 PROCEED,则调用原始方法,否则使用返回的构造方法进行反射调用或者直接使用 MockGateway 返回的对象。
  Object instance = org.powermock.core.MockGateway.newInstanceCall($type,$args,$sig);
  if(instance != org.powermock.core.MockGateway.PROCEED) {  
    if(instance instanceof java.lang.reflect.Constructor) {    
      $_ = ($r) sun.reflect.ReflectionFactory.getReflectionFactory()
        .newConstructorForSerialization($type, 
                                        java.lang.Object.class.getDeclaredConstructor(null))
        .newInstance(null);  
    } else {    
      $_ = ($r) instance;  
    }
  } else {  
    $_ = $proceed($$);
  }

  通过以上替换,PowerMock 就成功将方法内部的 new 操作,代理到了 MockGateway。对于一般的方法(无论是 private 还是 static 甚至 native 方法),都是相似的,对于普通方法,会代理到 MockGateway#methodCall。methodCall 中在过滤完一些特殊方法后,如 toString、equals 等,会按照是否被抑制调用、是否有 stub、是否有 mock 等策略执行。下图中说明了 methodCall 的核心流程。
  PowerMock 支持的 private 方法 mock、static 方法 mock 可以理解为在 API 层面提供了一套工具入口,剩下的 mock 对象生成、方法验证等仍旧利用了 Mockito 提供的能力。
  5.mock 静态方法
  有时不可避免会遇到要 mock 静态方法的地方,Mockito 2.0 版本不支持 Mock 静态方法,目前的方式是引入 PowerMock,但是引入后,JaCoCo 又会出现覆盖率统计错误的问题,需要将 JaCoCo 的采集模式改为离线方式。
  新版的 Mockito 从 3.4.0 开始,已经支持了静态方法的mock。
  需要引入
  <dependency>
      <groupId>org.mockito</groupId>
      <artifactId>mockito-inline</artifactId>
      <version>3.4.6</version>
      <scope>test</scope>
  </dependency>

  需要注意 mock 静态方法是万不得已才去做的,在 mock 静态方法前,首先应该考虑的是优化业务代码,提高代码可测试度。

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

关注51Testing

联系我们

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

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

沪ICP备05003035号

沪公网安备 31010102002173号