Quarkus での CDI Beanのモック

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エンティティのモックが簡単にできるようになります。この機能がどのようなものなのか見てみたいと思っている方は、ぜひこれをチェックしてみて、早めのご意見をお聞かせください。

* 各記事は著者の見解によるものでありその所属組織を代表する公式なものではありません。その内容については非公式見解を含みます。