转载

JMockit:单元测试利器

单元测试(UT: Unit Test)是保证服务质量的基础。在实际项目的 UT 开发中,我们通常需要执行第三方服务调用、连接数据库等操作,为了让 UT 能够正常运行起来,我们需要执行大量的环境准备工作,这些工作有时比 UT 本身还要费时费力很多,而 mock 机制则能够帮助我们绕开这些必要但不一定要真正需要去做的事情,隔离 UT 与服务的依赖,模拟我们期望的行为和数据。

在 java 生态系统中,有多种 mock 组件可供选择,包括: EasyMock 、 JMock 、 mockito 、 unitils 、 PowerMock ,以及 JMockit 等。本文将要介绍的 JMockit 组件是众多 mock 组件中最为强大的一个。

  • jmockit version: 1.44
  • junit version: 4.12

录制 & 重放 & 验证

录制(record)、重放(replay)和验证(verify)是 JMockit 的设计哲学(下文如果不做特殊说明,均使用 RRV 进行指代),这三个步骤各自所担负的责任如下:

  • 录制 :录制目标方法的期望行为,依据条件返回期望的数据,或者抛出异常
  • 重放 :当执行测试逻辑时,之前录制的行为会被触发,所谓的重放是应用之前录制的行为
  • 验证 :验证被测试逻辑的实际表现,例如期望的方法是否被调用,实际被调用了几次等

实际在日常使用 JUnit 进行 UT 开发时,我们通常也是按照录制、重放、验证这样一个步骤进行的,只不过我们称之为 “Arrange-Act-Assert”,3A 理论与 RRV 本质上是一样的。

下面的代码展示了 RRV 在编写 UT 时的具体框架:

@Test
public void test() {
    // 1. 录制
    new Expectations() {
        {
            // 录制我们期望的目标方法行为
        }
    };

    // 2. 重放:执行测试逻辑

    // 3. 验证
    new Verifications() {
        {
            // 验证目标方法的执行状态
        }
    };
}

使用 JMockit 进行实际 UT 开发时,并不要求同时提供这 3 个步骤实现,一般我们常用的是录制和重放,而业务相关的验证逻辑可以使用断言。

录制:Expectations

Expectations 代码块用于放置录制行为,录制主要分为两种方式:

  1. 引用外部 mock 类或接口实例进行录制
  2. 通过构造方法注入类或对象实例进行录制

引用外部 mock 类或接口实例进行录制

@Mocked
private UserService userService;

@Test
public void recordByMockedInstance() {
    new Expectations() {
        {
            userService.getUserName(anyLong);
            result = "zhenchao";

            userService.getUserAge(anyLong);
            result = 28;
        }
    };

    Assert.assertEquals("zhenchao", userService.getUserName(1001L));
    Assert.assertEquals(28, userService.getUserAge(1002L));
}

通过构造方法注入类或对象实例进行录制

Expectations 提供了带参数的构造方法(定义如下),我们可以将一个类的 Class 对象,或者类实例对象作为参数构造 Expectations 对象,区别在于如果传递的是类 Class 对象则录制会对该类的所有实例生效,如果传递的是类实例对象则仅对当前实例生效。

protected Expectations(@Nonnull Object... classesOrObjectsToBePartiallyMocked) {
  execution = new RecordAndReplayExecution(this, classesOrObjectsToBePartiallyMocked);
}
  • 以类 Class 对象构造 Expectations
@Test
public void recordByClass() {
    User user = new User(1001L);
    new Expectations(User.class) {
        {
            user.getName();
            result = "zhenchao";

            user.getAge();
            result = 28;
        }
    };

    Assert.assertEquals("zhenchao", user.getName());
    Assert.assertEquals(28, user.getAge());

    // 对其它实例同样生效
    User user2 = new User(1002L, "zhangsan", 18);
    Assert.assertEquals("zhenchao", user2.getName());
    Assert.assertEquals(28, user2.getAge());
}
  • 以类对象实例构造 Expectations
@Test
public void recordByInstance() {
    User user = new User(1001L);
    new Expectations(user) {
        {
            user.getName();
            result = "zhenchao";

            user.getAge();
            result = 28;
        }
    };

    Assert.assertEquals("zhenchao", user.getName());
    Assert.assertEquals(28, user.getAge());

    // 对其它实例不生效
    User user2 = new User(1002L, "zhangsan", 18);
    Assert.assertEquals("zhangsan", user2.getName());
    Assert.assertEquals(18, user2.getAge());
}

录制多个期望时序结果

如果我们希望目标方法在被调用时,每次返回的期望结果都不一样,那么可以在录制时指定返回一个结果值数组(或列表),数组(或列表)的每个值都对应该方法被调用时期望返回的结果。示例:

@Test
public void recordSequenceResult() {
    User user = new User(1001L);
    new Expectations(user) {
        {
            user.getName();
            result = new String[] {"zhangsan", "lisi", "wanger"};

            user.getAge();
            result = new int[] {16, 17, 18};
        }
    };

    // 第一次调用
    Assert.assertEquals("zhangsan", user.getName());
    Assert.assertEquals(16, user.getAge());

    // 第二次调用
    Assert.assertEquals("lisi", user.getName());
    Assert.assertEquals(17, user.getAge());

    // 第三次调用
    Assert.assertEquals("wanger", user.getName());
    Assert.assertEquals(18, user.getAge());

    // 第四次调用,因为没有录制结果,所以返回最后一次录制的期望值
    Assert.assertEquals("wanger", user.getName());
    Assert.assertEquals(18, user.getAge());
}

示例中我们为方法 User#getNameUser#getAge 录制了一组返回值,然后在相应方法依次被调用时会返回对应下标的返回值,可以看到当第 4 次调用时仍然返回第 3 组值,这是因为没有为第 4 次调用录制期望值,所以继续沿用最后一组期望值。我们也可以使用 returns(v1, v2, ...) 方法来代替 result = (...) 的语法,即 returns("zhangsan", "lisi", "wanger") 等价于 result = new String[] {"zhangsan", "lisi", "wanger"} 语法。

条件性录制

上面的示例在录制期望返回值时只设定了一个具体的值,如果期望依据入参条件性设置返回值,可以使用 Delegate 接口。例如下面的示例中我们依据入参 userId 的值,条件性返回具体的用户名称:

@Test
public void delegate() {
    UserService userService = new UserServiceImpl();
    new Expectations(userService) {
        {
            userService.getUserName(anyLong);
            result = new Delegate() {
                // 方法名可以任意
                public String delegate(long userId) {
                    return userId > 1003L ? "unknown" : "zhenchao";
                }

            };
        }
    };

    Assert.assertEquals("zhenchao", userService.getUserName(1001L));
    Assert.assertEquals("zhenchao", userService.getUserName(1002L));
    Assert.assertEquals("unknown", userService.getUserName(1004L));
}

另外一个比较经典的应用场景就是依据入参条件性的选择录制还是委托给目标方法,此时我们需要引入 Invocation 对象。示例:

@Test
public void delegate() {
    UserService userService = new UserServiceImpl();
    new Expectations(userService) {
        {
            userService.getUserInfo(anyLong);
            result = new Delegate() {
                // 方法名可以任意
                public User delegate(Invocation inv, long userId) {
                    if (userId > 1003) {
                        return new User(userId, "u_" + userId, (int) (userId % 100));
                    }
                    // 对于 userId 小于等于 1003 的全部交由目标方法处理
                    return inv.proceed(userId);
                }

            };
        }
    };

    Assert.assertEquals(UserServiceImpl.REGISTRY.get(1001L), userService.getUserInfo(1001L));
    Assert.assertEquals(UserServiceImpl.REGISTRY.get(1002L), userService.getUserInfo(1002L));
    Assert.assertEquals(UserServiceImpl.REGISTRY.get(1003L), userService.getUserInfo(1003L));
    User user = userService.getUserInfo(1004L);
    Assert.assertEquals(1004L, user.getId());
    Assert.assertEquals("u_1004", user.getName());
    Assert.assertEquals(4, user.getAge());
}

示例中我们对于 userId 小于等于 1003 的请求全部委托给目标方法执行。

灵活的参数匹配策略

当我们在录制期望时,对于方法的参数设置如果指定了具体的值,那么在重放测试逻辑时只能匹配对应的值,但是很多时候我们并不希望这样精确的匹配。幸运的是 JMockit 对于参数提供了灵活的匹配策略,即 any 语法和 with 语法。

  • Any 语法

通过 any 语法,我们可以匹配任意类型的参数值,示例:

@Test
public void any() throws Exception {
    UserService userService = new UserServiceImpl();
    new Expectations(userService) {
        {
            // 匹配任意类型为 long 的参数值
            userService.getUserName(anyLong);
            result = "zhenchao";
        }
    };
    Assert.assertEquals("zhenchao", userService.getUserName(RandomUtils.nextLong()));
}

示例中我们使用 anyLong 录制方法匹配任意 long 类型的参数值。此外,JMockit 还提供了以下 any 语法:

protected final Object any = null;
protected final String anyString = new String();
protected final Long anyLong = 0L;
protected final Integer anyInt = 0;
protected final Short anyShort = 0;
protected final Byte anyByte = 0;
protected final Boolean anyBoolean = false;
protected final Character anyChar = '/0';
protected final Double anyDouble = 0.0;
protected final Float anyFloat = 0.0F;
  • With 语法

With 语法相对于 any 语法提供了更加灵活的匹配策略,当 any 无法满足我们的需求时,可以尝试从 with 语法中寻找解决方案。示例:

@Test
public void with() throws Exception {
    UserService userService = new UserServiceImpl();
    new Expectations(userService) {
        {
            userService.batchUpdateUserInfo(this.withNotNull());
            result = 10;
        }
    };
    Assert.assertEquals(10, userService.batchUpdateUserInfo(new ArrayList<>()));
    // 错误,参数值不允许为 null
    // Assert.assertEquals(10, userService.batchUpdateUserInfo(null));
}

示例中 UserService#batchUpdateUserInfo 方法接收一个 List<User> 类型的参数,这里我们使用了 withNotNull 方法,期望匹配任何不为 null 入参。此外,JMockit 还提供了以下类型的 with 语法:

protected final <T> T with(@Nonnull Delegate<? super T> objectWithDelegateMethod);
protected final <T> T withAny(@Nonnull T arg);
protected final <T> T withEqual(@Nonnull T arg);
protected final double withEqual(double value, double delta);
protected final float withEqual(float value, double delta);
protected final <T> T withInstanceLike(@Nonnull T object);
protected final <T> T withInstanceOf(@Nonnull Class<T> argClass);
protected final <T> T withNotEqual(@Nonnull T arg);
protected final <T> T withNull();
protected final <T> T withNotNull();
protected final <T> T withSameInstance(@Nonnull T object);
protected final <T extends CharSequence> T withSubstring(@Nonnull T text);
protected final <T extends CharSequence> T withPrefix(@Nonnull T text);
protected final <T extends CharSequence> T withSuffix(@Nonnull T text);
protected final <T extends CharSequence> T withMatch(@Nonnull T regex);

录制方法调用次数限制

JMockit 在录制期望时提供了 timesminTimesmaxTimes 语法,用于限制当前方法被调用的次数。示例:

@Test
public void times() throws Exception {
    UserService userService = new UserServiceImpl();
    new Expectations(userService) {
        {
            userService.getUserName(anyLong);
            result = "zhenchao";
            times = 1; // 限制当前方法仅允许被调用 1 次
        }
    };
    Assert.assertEquals("zhenchao", userService.getUserName(RandomUtils.nextLong()));
    // 错误,方法限制仅允许调用 1 次
    // Assert.assertEquals("zhenchao", userService.getUserName(RandomUtils.nextLong()));
}

验证:Verifications

Verifications 用于验证被测试方法调用的状态,即方法是否被调用过,调用了多少次等。Verifications 同样提供了 timesminTimesmaxTimes 语法,这与 Expectations 中的设置相同。示例:

@Mocked
private UserService userService;

@Test
public void verifyForMockedInstance() {
    Assert.assertNull(userService.getUserName(1001L));
    Assert.assertNull(userService.getUserName(1001L));
    Assert.assertEquals(0, userService.getUserAge(1001L));

    new Verifications() {
        {
            // 限定 getUserName 方法只能被调用 2 次
            userService.getUserName(anyLong);
            times = 2;

            // 限定 getUserAge 方法只能被调用 1 次
            userService.getUserAge(anyLong);
            times = 1;
        }
    };
}

@Test
public void verifyForInstance() {
    User user = new User(1001L, "zhenchao", 28);
    Assert.assertEquals("zhenchao", user.getName());
    Assert.assertEquals(28, user.getAge());
    Assert.assertEquals(28, user.getAge());

    new Verifications() {
        {
            // 限定 getName 方法只能被调用 1 次
            user.getName();
            times = 1;

            // 限定 getAge 方法只能被调用 2 次
            user.getAge();
            times = 2;
        }
    };
}

限制方法的调用顺序

如果期望目标方法按照一定的顺序执行,可以使用 VerificationsInOrder 代替 Verifications。示例:

@Mocked
private UserService userService;

@Test
public void verifyInOrder() {
    // 错误,方法执行顺序不满足期望
    Assert.assertNull(userService.getUserName(1001L));
    Assert.assertEquals(0, userService.getUserAge(1001L));

    new VerificationsInOrder() {
        {
            // 限定 getUserName 方法在 getUserAge 方法前执行
            userService.getUserName(anyLong);
            userService.getUserAge(anyLong);
        }
    };
}

需要注意的是,VerificationsInOrder 对于局部实例不生效。

强制验证所有测试方法

如果期望所有在重放阶段调用的测试方法都需要被验证,可以使用 FullVerifications 代替 Verifications。示例:

@Mocked
private UserService userService;

@Test
public void fullVerify() {
    // 错误,因为 getUserAge 方法并未被验证
    Assert.assertNull(userService.getUserName(1001L));
    Assert.assertEquals(0, userService.getUserAge(1001L));

    new FullVerifications() {
        {
            userService.getUserName(anyLong);
        }
    };
}

需要注意的是,FullVerifications 对于局部实例不生效。

捕获重放阶段的方法参数

JMockit 允许在 Verifications 代码块中通过 withCapture 方法捕获重放阶段传递的方法参数,分为 3 种情况:

  1. 捕获单次方法调用的参数
  2. 捕获多次方法调用的参数集合
  3. 捕获方法调用时新创建的对象

具体示例如下:

@Test
public void capturingSingle() {
    userService.checkPassword(1001L, "aaa");

    new Verifications() {
        {
            // 捕获单次方法调用的参数
            long uid;
            String pwd;
            userService.checkPassword(uid = this.withCapture(), pwd = this.withCapture());
            Assert.assertEquals(1001L, uid);
            Assert.assertEquals("aaa", pwd);
        }
    };
}

@Test
public void capturingMultiple() {
    userService.getUserInfo(1001L);
    userService.getUserInfo(1002L);
    userService.getUserInfo(1003L);

    new Verifications() {
        {
            // 捕获多次方法调用的参数集合
            List<Long> userIds = new ArrayList<>();
            userService.getUserInfo(this.withCapture(userIds));
            Assert.assertEquals(3, userIds.size());
            Assert.assertEquals(Arrays.asList(1001L, 1002L, 1003L), userIds);
        }
    };
}

@Test
public void capturingNewInstances(@Mocked User user) { // 不要忘了 @Mocked User
    userService.updateUserInfo(new User(1001L, "zhangsan", 16));
    userService.updateUserInfo(new User(1002L, "lisi", 17));
    userService.updateUserInfo(new User(1003L, "wanger", 18));

    new Verifications() {
        {
            // 捕获方法调用时新创建的对象
            List<User> users = this.withCapture(new User(anyLong, anyString, anyInt));
            Assert.assertEquals(3, users.size());

            List<User> userInfos = new ArrayList<>();
            userService.updateUserInfo(this.withCapture(userInfos));

            Assert.assertEquals(users, userInfos);
        }
    };
}

Mocking:模拟测试所依赖的运行环境

Mock 注解的特点与使用场景

JMockit 提供了多个 mock 注解,用于模拟被测试对象所依赖的运行环境,包括 @Mocked@Tested@Injectabe 、以及 @Capturing 。这些注解可以修饰我们在测试类中声明的 mock 属性 和测试方法的 mock 参数 。被这些注解修饰的属性和参数(类型包括接口、普通类、抽象类、final 类、注解,以及枚举),JMockit 会依据相应注解的语义对其进行实例化。这几个注解的基本语义如下:

  • @Mocked :委托 JMockit 创建目标接口或者类的实例,所有实例都会被托管(不管是不是 JMockit 创建的),所有方法(包括静态方法、构造方法)均返回对应类型的初始值,如果是引用类型则递归初始化。
  • @Injectabe :委托 JMockit 创建目标接口或者类的实例,仅 JMcokit 创建的实例会被托管,类实例方法(不包括静态方法、构造方法)均返回对应类型的初始值,如果是引用类型则递归初始化。
  • @Tested :标记一个实例是被测试实例,如果能够依据类型推断出用于具体实例化的类,则 JMockit 会自动执行实例化操作,否则需要手动实例化,当我们调用实例的方法时,相应方法并不会被 JMockit 托管,但是配合 @Injectabe 注解,可以注入方法中使用到的依赖实例。
  • @Capturing :委托 JMockit 代理目标接口或者父类,被代理的接口或父类不管子类如何实现(可以是人工编写、匿名类,甚至是动态代理类),其相应的方法都将被 JMockit 托管,适用于代理子类的行为,并且子类是不易描述的。

注解:Mocked

注解 @Mocked 可以修饰类和接口,其语义是告诉 JMockit 生成当前被 mock 类或接口的对象,该对象的方法(包括静态方法、构造方法)均返回对应类型的初始值,例如对于 int 类型则返回 0,String 类型返回 null,如果返回类型是一个引用类型,则 JMockit 会递归创建该类型的对象,同样该对象的属性都会被设置为相应的初始值。

修饰接口类型

@Mocked
private UserService userService;

@Test
public void mockedInterface() {
    // String 类型方法直接返回 null
    Assert.assertNull(userService.getUserName(1001L));

    // int 类型方法直接返回 0
    Assert.assertEquals(0, userService.getUserAge(1001L));

    // 引用类型方法返回一个新的 User 对象,但是对象的属性值都是初始值
    User user = userService.getUserInfo(1001L);
    Assert.assertNotNull(user);
    Assert.assertEquals(0L, user.getId());
    Assert.assertNull(user.getName());
    Assert.assertEquals(0, user.getAge());
}

总结:

  1. 注解会创建一个接口的实现类对象
  2. 返回值为基本类型的方法直接返回初始值
  3. 返回值为 String 类型的方法直接返回 null
  4. 返回值为引用类型的方法会递归初始化

修饰类类型

@Mocked
private UserServiceImpl userServiceImpl;

@Test
public void mockedClass() {
    // String 类型实例方法直接返回 null
    Assert.assertNull(userService.getUserName(1001L));

    // int 类型实例方法直接返回 0
    Assert.assertEquals(0, userService.getUserAge(1001L));

    // 引用类型方法返回一个新的 User 对象,但是对象的属性值都是初始值
    User user = userService.getUserInfo(1001L);
    Assert.assertNotNull(user);
    Assert.assertEquals(0L, user.getId());
    Assert.assertNull(user.getName());
    Assert.assertEquals(0, user.getAge());

    // 类的静态方法也会被托管
    User newUser = UserServiceImpl.newUserInfo("zhenchao", 28);
    Assert.assertEquals(0L, newUser.getId());
    Assert.assertNull(newUser.getName());
    Assert.assertEquals(0, newUser.getAge());

    // 自己 new 一个被 mock 类型对象也会被托管
    UserService localService = new UserServiceImpl();
    Assert.assertNull(localService.getUserName(1001L));
    Assert.assertEquals(0, localService.getUserAge(1001L));
}

总结:

  1. 满足接口类型所有的特点
  2. 静态方法同样会被托管,效果与实例方法一样
  3. 即使自己 new 出来的实例也会被托管

注解:Injectabe

注解 @Injectable 同样可以修饰类和接口,其语义是告诉 JMockit 生成当前被 mock 类或接口的对象。相对于 @Mocked 的区别在于, @Injectable 仅托管修饰类或接口的当前实例,而不是所有实例。此外, @Injectable 对类的静态方法、构造方法没有影响。修饰类的示例如下:

@Injectable
private UserServiceImpl userServiceImpl;

@Test
public void injectableClass() {
    // String 类型实例方法直接返回 null
    Assert.assertNull(userService.getUserName(1001L));

    // int 类型实例方法直接返回 0
    Assert.assertEquals(0, userService.getUserAge(1001L));

    // 引用类型方法返回一个新的 User 对象,但是对象的属性值都是初始值
    User user = userService.getUserInfo(1001L);
    Assert.assertNotNull(user);
    Assert.assertEquals(0L, user.getId());
    Assert.assertNull(user.getName());
    Assert.assertEquals(0, user.getAge());

    // 类的静态方法不会被托管
    User newUser = UserServiceImpl.newUserInfo(1004L, "zhenchao", 28);
    Assert.assertEquals(1004L, newUser.getId());
    Assert.assertEquals("zhenchao", newUser.getName());
    Assert.assertEquals(28, newUser.getAge());

    // 自己 new 一个被 mock 类型对象也不会被托管
    UserService localService = new UserServiceImpl();
    Assert.assertEquals("zhangsan", localService.getUserName(1001L));
    Assert.assertEquals(18, localService.getUserAge(1001L));
}

总结:

  1. 注解会创建一个接口的实现类对象,并且仅托管当前创建的实例
  2. 返回值为基本类型的方法直接返回初始值
  3. 返回值为 String 类型的方法直接返回 null
  4. 返回值为引用类型的方法会递归初始化
  5. 构造方法、静态方法不会被托管

注解:Tested

注解 @Tested 一般和 @Injectable 配合使用以 mock 被测试类或接口的对象,如果该对象没有被赋值,则 JMockit 会对该对象进行实例化。实例化过程中如果被测试类具备带参数的构造方法,则 JMockit 会尝试在 mock 属性和 mock 参数中寻找被 @Injectable 修饰的 mock 对象进行注入,否则使用无参构造方法进行实例化,并尝试属性注入。

下面以一个订单示例说明注解 @Tested@Injectable 的配合使用。假设有一个订单服务 OrderService,当调用该服务的 OrderService#submitOrder 方法提交订单时需要调用用户服务 UserService 验证当前提交订单用户的密码,只有在密码验证通过的情况下才会处理订单,并在订单处理成功之后调用邮件服务 EmailService 发送邮件通知用户。订单服务实现如下:

public class OrderService {

    /** 用户服务 */
    private UserService userService;

    /** 邮件服务 */
    @Resource
    private EmailService emailService;

    public OrderService(UserService userService) {
        this.userService = userService;
    }

    /**
     * 提交订单
     *
     * @param userId 用户 ID
     * @param orderId 订单 ID
     * @param password 用户密码
     * @return 如果订单提交成功则返回 true,否则返回 false
     */
    public boolean submitOrder(long userId, long orderId, String password) {
        // 校验用户密码
        if (userService.checkPassword(userId, password)) {

            // 密码验证通过,处理订单,省略 ...

            // 发送通知邮件
            User user = userService.getUserInfo(userId);
            emailService.send(user.getEmail(), "submit order success", "submit order success, orderId: " + orderId);
            System.out.println("user submit order success, userId: " + userId + ", orderId: " + orderId);
            return true;
        }
        System.out.println("invalid password, userId: " + userId);
        return false;
    }

    public OrderService setEmailService(EmailService emailService) {
        this.emailService = emailService;
        return this;
    }
}

在订单服务 OrderService 中,我们设定了通过构造方法注入 UserService 实例,通过属性注入 EmailService 实例,相应的测试方法实现如下:

@Tested
private OrderService orderService;

@Injectable
private UserService userService;

@Test
public void submitOrder(@Injectable EmailService emailService) {
    User zhangsan = new User(1001L, "zhangsan", 18).setEmail("zhangsan@gmail.com").setPassword("aaa");
    new Expectations() {
        {
            userService.getUserInfo(zhangsan.getId());
            result = zhangsan;

            userService.checkPassword(zhangsan.getId(), zhangsan.getPassword());
            result = true;

            emailService.send(zhangsan.getEmail(), anyString, anyString);
            result = true;
        }
    };

    Assert.assertTrue(orderService.submitOrder(zhangsan.getId(), RandomUtils.nextLong(), zhangsan.getPassword()));
    Assert.assertFalse(orderService.submitOrder(zhangsan.getId(), RandomUtils.nextLong(), ""));
    Assert.assertFalse(orderService.submitOrder(1002L, RandomUtils.nextLong(), zhangsan.getPassword()));
}

我们在测试方法中同时使用了 mock 属性和 mock 参数,并录制了相应服务方法的期望行为,借助 JMockit 可以将这些录制在执行具体测试逻辑时重放。

注解:Capturing

有时候我们只知道父类或接口,但是希望控制它所有的子类(包括人工编写、匿名类,以及动态代理生成等)的行为,这时可以使用 @Capturing 注解。该注解平时虽然较少用到,但在一些场景下还非它不可,尤其是动态代理相关应用场景。

假设我们现在有一个密码校验服务接口 PasswordService,我们并不知道该接口的实现类有哪些,以及如何实现,可能是匿名类,也可能是动态代理,如下:

public interface PasswordService {
    boolean check(long userId, String password);
}

/**
 * 匿名类
 */
PasswordService passwordService1 = new PasswordService() {
    @Override
    public boolean check(long userId, String password) {
        return 1001L != userId && StringUtils.isNotBlank(password);
    }
};

/**
 * 动态代理类
 */
PasswordService passwordService2 = (PasswordService) Proxy.newProxyInstance(
        Thread.currentThread().getContextClassLoader(),
        new Class[] {PasswordService.class},
        new InvocationHandler() {
            @Override
            public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                return args.length == 2
                        && (Long) args[0] != 1001L && StringUtils.isNotBlank(String.valueOf(args[1]));
            }
        });

假设我们现在想托管所有 PasswordService 接口的实现类,不管是匿名类还是动态生成的,这个时候就可以使用 @Capturing 注解:

@Test
public void withCapturing(@Capturing PasswordService passwordService) {
    final long UID = 1001L;
    new Expectations() {
        {
            passwordService.check(UID, anyString);
            result = true;
        }
    };
    // 不管子类实现如何,全部应用录制的行为
    Assert.assertTrue(passwordService1.check(UID, "aaa"));
    Assert.assertTrue(passwordService2.check(UID, "bbb"));
}

@Test
public void withoutCapturing() {
    final long UID = 1001L;
    Assert.assertFalse(passwordService1.check(UID, "aaa"));
    Assert.assertFalse(passwordService2.check(UID, "bbb"));
}

由示例可以看出,如果目标接口使用 @Capturing 注解修饰,则不管子类如何实现,都不再生效。

注:1.38 版本存在 bug,对于动态代理生成的类无法代理。

上面的示例也可以使用 MockUp 实现,我们会在后面对 MockUp 的使用进行详细介绍,这里先给出一个示例:

@Test
public <T extends PasswordService> void mockup() {
    final long UID = 1001L;
    new MockUp<T>() {
        @Mock
        public boolean check(long userId, String password) {
            return true;
        }
    };

    PasswordService passwordService1 = new PasswordService() {
        @Override
        public boolean check(long userId, String password) {
            return false;
        }
    };
    Assert.assertTrue(passwordService1.check(UID, "aaa"));

    PasswordService passwordService2 = (PasswordService) Proxy.newProxyInstance(
            Thread.currentThread().getContextClassLoader(),
            new Class[] {PasswordService.class},
            new InvocationHandler() {
                @Override
                public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                    return false;
                }
            });
    Assert.assertTrue(passwordService2.check(UID, "aaa"));
}

Faking:替换目标方法的实现

MockUp 可以伪造目标方法的实现,以返回我们期望的数据或者行为,其语义与 Expectations 相似,但是更加直观和简单。MockUp 适用于一个项目中对一些通用类的 mock 操作,以减少大量重复的 Exceptations 录制代码。示例:

@Test
public void mockup() {
    new MockUp<UserService>(UserServiceImpl.class) {

        @Mock
        public int getUserAge(long userId) {
            if (1001L == userId) {
                return 1;
            } else if (1002L == userId) {
                return 2;
            } else if (1003L == userId) {
                return 3;
            } else {
                return -1;
            }
        }

    };

    UserService userService = new UserServiceImpl();
    Assert.assertEquals(1, userService.getUserAge(1001L));
    Assert.assertEquals(2, userService.getUserAge(1002L));
    Assert.assertEquals(3, userService.getUserAge(1003L));
    Assert.assertEquals(-1, userService.getUserAge(1004L));
}

示例中我们通过组合 MockUp 和 @Mock 注解对 UserServiceImpl#getUserAge 方法进行录制。

如果我们希望像 Expectations 那样对目标方法进行条件性 mock,例如依据参数选择是 mock 还是直接调用原始方法,可以引入 Invocation 对象,实现如下:

@Test
public void mockup() {
    new MockUp<UserService>(UserServiceImpl.class) {

        @Mock
        public User getUserInfo(Invocation inv, long userId) {
            if (userId > 1003L) {
                return new User(userId, "u_" + userId, (int) userId % 100);
            }
            // 当 userId 小于等于 1003 时直接调用原始方法
            return inv.proceed(userId);
        }

    };

    UserService userService = new UserServiceImpl();
    Assert.assertEquals(UserServiceImpl.REGISTRY.get(1001L), userService.getUserInfo(1001L));
    Assert.assertEquals(UserServiceImpl.REGISTRY.get(1002L), userService.getUserInfo(1002L));
    Assert.assertEquals(UserServiceImpl.REGISTRY.get(1003L), userService.getUserInfo(1003L));
    User user = userService.getUserInfo(1004L);
    Assert.assertEquals(1004L, user.getId());
    Assert.assertEquals("u_1004", user.getName());
    Assert.assertEquals(4, user.getAge());
}

可以看到相对于 Expectations 而言,MockUp 的条件性 mock 更加简单。

MockUp 使用简单、直观,与 Expectations 形成互补,从而极大增强了 JMockit 的功能,下面将对 MockUp 的常见应用场景进行举例说明,不过在开始之前我们还是先概括一下 MockUp 在使用上的一些限制:

  1. 无法对单个实例进行 mock
  2. 无法对动态代理类进行 mock
  3. 当需要对类的所有方法进行 mock 时,开发效率较低

Mock 私有方法和 native 方法

Expectations 在 mock 目标类的私有方法和 native 方法时有些力不从心,这个时候可以使用 MockUp 代替,示例:

public class MyClass {
    // native 方法
    public native int nativeMethod();
    // 私有方法
    private int privateMethod() {
        return 1;
    }
}

@Test
public void recordByMockUp() throws Exception {
    new MockUp<MyClass>(MyClass.class) {
        @Mock
        public int privateMethod() {
            return 2019;
        }
        @Mock
        public int nativeMethod() {
            return 2019;
        }
    };

    MyClass mc = new MyClass();
    Method privateMethod = MyClass.class.getDeclaredMethod("privateMethod");
    privateMethod.setAccessible(true);
    Assert.assertEquals(2019, privateMethod.invoke(mc));
    Assert.assertEquals(2019, mc.nativeMethod());
}

Mock 构造方法和静态代码块

前面介绍的 mock 方式,即使目标类被 JMockit 托管,但是在实例化时还是会调用我们自定义的构造方法对类进行实例化,当遇到一些实现复杂或者不规范的类时,可能会在类实例化的过程中执行大量的操作,比如创建数据库连接等,这个时候我们可能希望将构造方法、静态代码块都 mock 调用,以简化 UT 的开发。这个时候我们就可以使用 MockUp 来达到目的:

public class UserServiceImpl implements UserService {

    private static Connection connection;

    static {
        try {
            // 创建数据库连接
            connection = DBSource.getConnection();
        } catch (SQLException e) {
            System.exit(-1);
        }
    }

    private int port;

    public UserServiceImpl() {
    }

    public UserServiceImpl(int port) {
        this.port = port;
    }

}

@Test
public void mockupInitialization() {
    new MockUp<UserService>(UserServiceImpl.class) {
        @Mock
        public void $clinit() {
            // mock 静态代码块
        }

        @Mock
        public void $init(int port) {
            // mock 构造方法
        }
    };

    UserServiceImpl userService = new UserServiceImpl(8080);
    Assert.assertEquals(0, userService.getPort());
    Assert.assertNull(UserServiceImpl.getConnection());
}

示例中目标类 UserServiceImpl 在初始化时需要创建数据库连接,这是一个耗时且易出错的操作,所以我们将其 mock 掉以简化 UT 的编写。在 MockUp 中使用 $clinit 表示类初始化方法,使用 $init 表示类构造方法,之所以这样命名是因为我们的静态代码块在编译成字节码时会组织在一个名为 $clinit 的方法中,而构造方法在编译成字节码时则使用 $init 作为方法名。

织入切面增强

MockUp 还允许定义另外一类名为 $advice 的方法,用于实现切面增强的目的,示例:

@Test
public void advice() {
    new MockUp<UserService>(UserServiceImpl.class) {

        @Mock
        public Object $advice(Invocation inv) {
            long start = System.nanoTime();
            Object result = inv.proceed();
            System.out.println("time elapse: " + (System.nanoTime() - start));
            return result;
        }

    };

    UserService userService = new UserServiceImpl();
    Assert.assertEquals(UserServiceImpl.REGISTRY.get(1002L), userService.getUserInfo(1002L));
}

示例中我们在目标方法前后添加了时间打点,用于记录目标方法的执行时间。

集成 Spring 框架

对于 java 服务端应用来说,Spring 基本上算是必备框架,如果我们希望 mock 被 spring 创建的 bean,可以实现如下:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "classpath:spring.xml")
public class SpringMockTest {

    @Resource
    private UserService userService;

    @Test
    public void recordByExpectations() {
        new Expectations(userService) {
            {
                userService.getUserName(anyLong);
                result = "zhenchao";

                userService.getUserAge(anyLong);
                result = 28;
            }
        };

        Assert.assertEquals("zhenchao", userService.getUserName(1001L));
        Assert.assertEquals(28, userService.getUserAge(1001L));
    }

    @Test
    public void recordByMockup() {
        new MockUp<UserService>(UserServiceImpl.class) {
            @Mock
            public String getUserName(long userId) {
                return "zhenchao";
            }

            @Mock
            public int getUserAge(long userId) {
                return 28;
            }
        };

        Assert.assertEquals("zhenchao", userService.getUserName(1001L));
        Assert.assertEquals(28, userService.getUserAge(1001L));
    }
}

示例中分别实现了基于 Expectations 的录制和基于 MockUp 的录制。

原文  http://www.zhenchao.org/2019/01/14/java/jmockit/
正文到此结束
Loading...