在 Quarkus 中 Mock CDI bean

测试 Quarkus 应用程序一直是 Quarkus Developer Joy 的重要组成部分,这就是为什么从第一个版本开始,Quarkus 就提供了 @QuarkusTest 用于测试 JVM 应用程序,以及 @NativeTest 用于对原生镜像进行黑盒测试。然而,社区成员一直有一个反复提出的请求,希望 Quarkus 允许他们为特定测试选择性地 Mock 某些 CDI bean。这篇博文将介绍 1.4 版本带来的新 Mock 功能,旨在解决这些问题,同时也会简要介绍 1.5 版本中该领域的一些额外改进。

旧方法

假设一个 Quarkus 应用程序包含以下(纯粹虚构的)bean

@ApplicationScoped
public class OrderService {

    private final InvoiceService invoiceService;
    private final InvoiceNotificationService invoiceNotificationService;

    public OrderService(InvoiceService invoiceService, InvoiceNotificationService invoiceNotificationService) {
        this.invoiceService = invoiceService;
        this.invoiceNotificationService = invoiceNotificationService;
    }

    public Invoice generateSendInvoice(Long orderId) {
        final Invoice invoice = invoiceService.generateForOrder(orderId);
        if (invoice.isAlreadySent()) {
            invoiceNotificationService.sendInvoice(invoice);
        } else {
            invoiceNotificationService.notifyInvoiceAlreadySent(invoice);
        }
        return invoice;
    }
}

在测试 generateSendInvoice 方法时,我们很可能不想使用实际的 InvoiceNotificationService,因为它会导致发送真实的通知。通过旧的 Quarkus 方法,可以在测试源码中添加以下 bean 来“覆盖” InvoiceNotificationService

@Mock
public class MockInvoiceNotificationService implements InvoiceNotificationService {

    public void sendInvoice(Invoice invoice) {

    }

    public void notifyInvoiceAlreadySent(Invoice invoice) {

    }
}

当 Quarkus 扫描到这段代码时,@Mock 的使用会导致 MockInvoiceNotificationService 在任何注入 InvoiceNotificationService bean 的地方(在 CDI 中称为注入点)被用作 InvoiceNotificationService 的实现。

尽管这个机制相当直接易用,但它仍然存在一些问题:

  • 对于每个需要 Mock 的 bean 类型,都需要使用一个新的类(或一个新的 CDI producer 方法)。在一个需要大量 Mock 的大型应用程序中,样板代码的数量会不合比例地增加。

  • 没有办法让一个 Mock 只用于某些测试。这是因为用 @Mock 注释的 bean 是普通的 CDI bean(因此在整个应用程序中使用)。根据需要测试的内容,这可能会非常成问题。

  • 它没有与 Mockito 进行开箱即用的集成,而 Mockito 是 Java 应用程序中 Mock 的事实标准。用户当然可以使用 Mockito(最常见的是使用 CDI producer 方法),但这涉及到样板代码。

新方法

从 Quarkus 1.4 开始,用户可以使用 io.quarkus.test.junit.QuarkusMock 为普通作用域的 CDI bean 创建和注入每个测试的 Mock。此外,Quarkus 提供了与 Mockito 的开箱即用集成,允许使用 io.quarkus.test.junit.mockito.@InjectMock 注释以零成本 Mock CDI bean。

使用 QuarkusMock

QuarkusMock 为 Mock 普通作用域的 CDI bean 提供了基础,它也在 @InjectMock 的底层使用,所以我们先来看看它。最好的方式是使用一个例子:

@QuarkusTest
public class MockTestCase {

    @Inject
    MockableBean1 mockableBean1;

    @Inject
    MockableBean2 mockableBean2;

    @BeforeAll
    public static void setup() {
        MockableBean1 mock = Mockito.mock(MockableBean1.class);  (1)
        Mockito.when(mock.greet("Stuart")).thenReturn("A mock for Stuart");
        QuarkusMock.installMockForType(mock, MockableBean1.class);  (2)
    }

    @Test
    public void testBeforeAll() {
        Assertions.assertEquals("A mock for Stuart", mockableBean1.greet("Stuart"));  (3)
        Assertions.assertEquals("Hello Stuart", mockableBean2.greet("Stuart")); (4)
    }

    @Test
    public void testPerTestMock() {
        QuarkusMock.installMockForInstance(new BonjourMockableBean2(), mockableBean2); (5)
        Assertions.assertEquals("A mock for Stuart", mockableBean1.greet("Stuart"));  (6)
        Assertions.assertEquals("Bonjour Stuart", mockableBean2.greet("Stuart")); (7)
    }

    @ApplicationScoped
    public static class MockableBean1 {

        public String greet(String name) {
            return "Hello " + name;
        }
    }

    @ApplicationScoped
    public static class MockableBean2 {

        public String greet(String name) {
            return "Hello " + name;
        }
    }

    public static class BonjourMockableBean2 extends MockableBean2 {
        @Override
        public String greet(String name) {
            return "Bonjour " + name;
        }
    }
}
1 示例的这部分仅出于方便使用 Mockito。QuarkusMock 在任何方面都不与 Mockito 绑定。
2 我们使用 QuarkusMock.installMockForType(),因为注入的 bean 实例尚未可用。非常重要的一点是,在 JUnit @BeforeAll 方法中设置的 Mock,将用于该类的所有测试方法(其他测试类不受此影响)。
3 MockableBean1 的 Mock 已按定义用于该类的所有测试方法。
4 由于没有为 MockableBean2 设置 Mock,因此正在使用 CDI bean。
5 我们在这里使用 QuarkusMock.installMockForInstance(),因为在测试方法内部,注入的 bean 实例是可用的。
6 MockableBean1 的 Mock 已按定义用于该类的所有测试方法。
7 由于我们将 BonjourMockableBean2 用作 MockableBean2 的 Mock,因此现在使用此类。

QuarkusMock 可用于任何普通作用域的 CDI bean——最常见的有 @ApplicationScoped@RequestScoped。这意味着具有 @Singleton@Dependent 作用域的 bean不能QuarkusMock 一起使用。

此外,当 QuarkusMock 在同一 JVM 中并行运行的测试中使用时,它将无法正常工作。

回到博文的原始示例,我们可以去掉 MockInvoiceNotificationService 类,转而使用类似以下的代码:

public class OrderServiceTest {

    @Inject
    OrderService orderService;

    @BeforeAll
    public static void setup() {
        MockableBean1 mock = Mockito.mock(InvoiceNotificationService.class);
        Mockito.doNothing().when(mock).sendInvoice(any());
        Mockito.doNothing().when(mock).notifyInvoiceAlreadySent(any());
        QuarkusMock.installMockForType(mock, MockableBean1.class);
    }

    public void testGenerateSendInvoice() {
        // perform some setup

        Invoice invoice = orderService.generateSendInvoice(1L);

        // perform some assertions
    }
}

请注意,在这种情况下,我们不需要创建实现 InvoiceNotificationService 的新类。此外,我们对 Mock 拥有完全的、针对每个测试的控制,这在编写测试时提供了很大的灵活性。

例如,如果我们有另一个测试,我们确实想使用真正的 InvoiceNotificationService,那么在该测试中,我们根本不做 InvoiceNotificationService 的 Mock。

如果另一个测试需要以其他方式 Mock InvoiceNotificationService,那么它完全可以这样做,使用与 OrderServiceTest 相同的方法,而不会给其他测试带来任何问题。

最后,请注意在上面的示例中,我们没有 Mock InvoiceService,这意味着在 OrderServiceTest 中使用的是真正的 InvoiceService

使用 @InjectMock

希望上一节让您相信 QuarkusMock 相对于旧方法的优点。您可能还在想,是否有办法进一步减少样板代码并与 Mockito 进行更紧密的集成。这时 @InjectMock 就派上用场了。

为了演示 @InjectMock,让我们重写上一节的 MockTestCase

首先,我们需要添加以下依赖:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-junit5-mockito</artifactId>
    <scope>test</scope>
</dependency>

现在我们可以这样重写 MockTestCase

@QuarkusTest
public class MockTestCase {

    @InjectMock
    MockableBean1 mockableBean1; (1)

    @InjectMock
    MockableBean2 mockableBean2;

    @BeforeEach
    public void setup() {
        Mockito.when(mockableBean1.greet("Stuart")).thenReturn("A mock for Stuart"); (2)
    }

    @Test
    public void firstTest() {
        Assertions.assertEquals("A mock for Stuart", mockableBean1.greet("Stuart"));
        Assertions.assertEquals(null, mockableBean2.greet("Stuart"));
    }

    @Test
    public void secondTest() {
        Mockito.when(mockableBean2.greet("Stuart")).thenReturn("Bonjour Stuart"); (3)
        Assertions.assertEquals("A mock for Stuart", mockableBean1.greet("Stuart"));
        Assertions.assertEquals("Bonjour Stuart", mockableBean2.greet("Stuart"));
    }

    @ApplicationScoped
    public static class MockableBean1 {

        public String greet(String name) {
            return "Hello " + name;
        }
    }

    @ApplicationScoped
    public static class MockableBean2 {

        public String greet(String name) {
            return "Hello " + name;
        }
    }
}
1 @InjectMock 会创建一个 Mock,并在该测试类的所有测试方法中可用(其他测试类不受此影响)。
2 mockableBean1 已在此处为该类的所有测试方法进行配置。
3 仅为此测试配置 mockableBean2

由于 @InjectMock 在底层使用 QuarkusMock,因此对其使用也适用相同的限制。

此外,@InjectMock 的作用类似于 bean 的注入点,因此为了在目标 bean 使用 CDI 限定符时正常工作,还需要将这些限定符添加到字段上。我们将在下一节关于 Mock @RestClient bean 的内容中看到一个示例。

作为最后一个示例,我们可以这样重写 OrderServiceTest 测试:

public class OrderServiceTest {

    @Inject
    private OrderService orderService;

    @InjectMock
    private InvoiceNotificationService invoiceNotificationService;

    @BeforeAll
    public static void setup() {
        doNothing().when(invoiceNotificationService).sendInvoice(any());
        doNothing().when(invoiceNotificationService).notifyInvoiceAlreadySent(any());
    }

    public void testGenerateSendInvoice() {
        // perform some setup

        Invoice invoice = orderService.generateSendInvoice(1L);

        // perform some assertions
    }
}

使用 @InjectMock 与 @RestClient

一个非常常见的需求是 Mock @RestClient bean。幸运的是,这是一个由 @InjectMock 很好地满足的需求——只要遵循两个原则:

  • 该 bean 被设置为 @ApplicationScoped(而不是接受 @RegisterRestClient 暗示的默认作用域,即 @Dependent)。

  • 在将 bean 注入到测试中时,使用 @RestClient CDI 限定符。

一如既往,一个示例最好地演示了这些要求。假设我们有一个 GreetingService,我们希望用它来构建一个 REST 客户端:

@Path("/")
@ApplicationScoped  (1)
@RegisterRestClient
public interface GreetingService {

    @GET
    @Path("/hello")
    @Produces(MediaType.TEXT_PLAIN)
    String hello();
}
1 @ApplicationScoped 需要被用来使 GreetingService 可以被 Mock。

一个示例测试类可能是:

@QuarkusTest
public class GreetingResourceTest {

    @InjectMock
    @RestClient (1)
    GreetingService greetingService;

    @Test
    public void testHelloEndpoint() {
        Mockito.when(greetingService.hello()).thenReturn("hello from mockito");

        given()
          .when().get("/hello")
          .then()
             .statusCode(200)
             .body(is("hello from mockito"));
    }

}
1 我们需要使用 @RestClient CDI 限定符,因为 Quarkus 使用此限定符创建 GreetingService bean。

Quarkus 1.5 中的更多 Mock 功能

Quarkus 1.5 将会发布一个新的测试模块(quarkus-panache-mock),它将使 Mock Panache 实体变得轻而易举。如果您渴望了解这项功能,请查看 ,并随时给我们早期反馈。