Red Hat で Solution Architect として Quarkus を担当している伊藤ちひろです。
この記事は、Quarkus.io のブログ記事、Mocking CDI beans in Quarkus の翻訳記事です。
Quarkusアプリケーションのテストは、Quarkus Developer Joyの重要な部分です。JVMアプリケーションをテストするための@QuarkusTestと、ネイティブイメージのブラックボックステストのための@NativeTestは、最初のリリース以来、Quarkusの一部となっています。しかし、私たちのコミュニティメンバーの間では、特定のテスト用に特定のCDI Beanを選択的にモックすることをQuarkusに許可してほしいという要望が何度もありました。この記事では、1.4 がもたらす新しいモッキング機能を紹介します。これらの関心事を解決することを目的としています。また、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を使用すると、InvoiceNotificationService Beanが注入された場所(CDI用語では注入ポイントと呼ばれています)のすべてで、MockInvoiceNotificationServiceがInvoiceNotificationServiceの実装として使用されることになりました。
このメカニズムは非常に使いやすいのですが、いくつかの問題点があります。
- 新しいクラス(または新しいCDIプロデューサメソッド)は、モックを必要とする各Beanタイプに使用する必要があります。多くのモックが必要とされる大規模なアプリケーションでは、定型コードの量が許容できないほど増えてしまいます。
- モックが特定の試験にしか使えないなんてことはありえません。これは、@MockでアノテーションされたBeanが通常のCDI Beanであるという事実に起因しています(したがって、アプリケーション全体で使用されます)。テストの必要性にもよりますが、これは非常に問題があります。
- JavaアプリケーションでのモッキングのデファクトスタンダードであるMockitoとの統合はありません。ユーザーは確かにMockitoを使用することができます(CDIのプロデューサーメソッドを使用することで最も一般的です)が、ボイラープレートコードが関与しています。
新しいアプローチ
Quarkus 1.4以降、ユーザーは、io.quarkus.test.junit.QuarkusMockを使用して、通常のスコープ付きCDI Beanのテストごとのモックを作成して注入できるようになりました。さらに、QuarkusはMockitoとの統合を提供しており、io.quarkus.test.junit.mockito.@InjectMockアノテーションを使用してCDI Beanのモッキングをなんの労力なく行えます。
QuarkusMockの使用
QuarkusMockは通常のスコープ付きCDI Beanをモックするための基盤を提供します。それは@InjectMockによって内部でも使われています。まずはこれを検証してみましょう。一番良いのは、以下の例を使った方法です。
@QuarkusTest public class MockTestCase { @Inject MockableBean1 mockableBean1; @Inject MockableBean2 mockableBean2; @BeforeAll public static void setup() { MockableBean1 mock = Mockito.mock(MockableBean1.class); ① Mockito.when(mock.greet("Stuart")).thenReturn("A mock for Stuart"); QuarkusMock.installMockForType(mock, MockableBean1.class); ② } @Test public void testBeforeAll() { Assertions.assertEquals("A mock for Stuart", mockableBean1.greet("Stuart")); ③ Assertions.assertEquals("Hello Stuart", mockableBean2.greet("Stuart")); ④ } @Test public void testPerTestMock() { QuarkusMock.installMockForInstance(new BonjourMockableBean2(), mockableBean2); ⑤ 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; } } public static class BonjourMockableBean2 extends MockableBean2 { @Override public String greet(String name) { return "Bonjour " + name; } } }
- ①この例の部分では、便宜上のためだけにMockitoを使用しています。QuarkusMockはMockitoとは何ら結びついていません。
- ②注入されたBeanインスタンスがまだ利用できないので、QuarkusMock.installMockForType()を使用します。JUnit の @BeforeAll メソッドのモックの設定で特筆する非常に重要なことは、そのクラスのすべてのテストメソッドで使用されるということです (他のテストクラスはこの影響を受けません)。
- ③MockableBean1用のモックは、そのクラスのすべてのテストメソッドに定義されていたものを使用されます。
- ④MockableBean2にはモックが設定されていないので、CDI Beanが使用されます。
- ⑤ここではQuarkusMock.installMockForInstance()を使用していますが、これはテストメソッドの中で注入されたBeanインスタンスが利用できるからです。
- ⑥MockableBean1用のモックは、クラスのすべてのテストメソッドに定義されていたものを使用されます。
- ⑦BonjourMockableBean2をモックMockableBean2として使用していたので、このクラスは使用されるようになりました。
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を実装したクラスを新たに作成する必要はないことに注意してください。さらに、私たちはモックを完全に、そしてテストごとにコントロールすることができます。それはテストを書くときに、多くの柔軟性を与えてくれます。
例えば、実際のInvoiceNotificationServiceを使用したいテストがあった場合、そのテストではInvoiceNotificationServiceのモッキングを行わないようにします。
他のテストで InvoiceNotificationService を別の方法でモックする必要がある場合は、OrderServiceTest が使用しているのと同じメソッドを使用して、他のテストに問題を与えることなく、完全に自由に行うことができます。
最後に、上の例では 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; ① @InjectMock MockableBean2 mockableBean2; @BeforeEach public void setup() { Mockito.when(mockableBean1.greet("Stuart")).thenReturn("A mock for Stuart"); ② } @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"); ③ 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; } } }
- ①@InjectMockの結果、モックが作成され、テストクラスのすべてのテストメソッドで利用可能になります(他のテストクラスはこの影響を受けません)
- ②ここでは、クラスのすべてのテストメソッドに対してmockableBean1が設定されています。
- ③このテストのためだけにmockableBean2を設定します。
@InjectMockは内部でQuarkusMockを使用しているため、同じ制限が適用されます。 さらに、@InjectMockはBeanのインジェクションポイントのように動作します。ターゲットBeanがCDI修飾子を使用しているときに正しく動作するためには、それらの修飾子もフィールドに追加する必要があります。次のセクションでは、@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 } }
@RestClientで@InjectMockを使用します。
非常に一般的な要求は、@RestClientのBeanをモックすることです。ありがたいことに、それは@InjectMockによって十分にカバーされている必要があります - 以下の2つの原則が守られている限り。
- Beanは@ApplicationScopedに作成されます。(@RegisterRestClientが暗示するデフォルトのスコープである@Dependentを受け入れる代わりに)
- テストにBeanを注入する際には、@RestClient CDI修飾子が使用されます。
いつものように、例はこれらの要件を最もよく示しています。Restクライアントを構築するために使用したいGreetingServiceがあるとします。
@Path("/") @ApplicationScoped ① @RegisterRestClient public interface GreetingService { @GET @Path("/hello") @Produces(MediaType.TEXT_PLAIN) String hello(); }
① GreetingServiceをモック可能にするためには、@ApplicationScopedを使用する必要があります。テストクラスの例としては、次のようなものが考えられます。
@QuarkusTest public class GreetingResourceTest { @InjectMock @RestClient ① 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")); } }
① Quarkusはこの修飾子を使用してGreetingService Beanを作成するので、@RestClient CDI修飾子を使用する必要があります。
Quarkus 1.5のその他のモッキング
Quarkus 1.5には新しいテストモジュール(quarkus-panache-mock)が同梱され、Panacheエンティティのモックが簡単にできるようになります。この機能がどのようなものなのか見てみたいと思っている方は、ぜひこれをチェックしてみて、早めのご意見をお聞かせください。