在 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,因此现在使用此类。 |
此外,当 |
回到博文的原始示例,我们可以去掉 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 。 |
由于 此外, |
作为最后一个示例,我们可以这样重写 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 实体变得轻而易举。如果您渴望了解这项功能,请查看 此,并随时给我们早期反馈。