前回までに、マイクロサービス(Backend)の単体/結合テストコードや効率的なテスト戦略のポイントなどについて解説しました。今回からは、マイクロサービスを呼び出す側のWebアプリケーション(BFF:BackendForFrontend)における単体テストと、マイクロサービスを含めたEndToEndテストを実装する際のポイントや戦略を説明していきます。

アプリケーションおよびテストのパッケージ/コンポーネント構成は以下としています。

テストのパッケージ/コンポーネント構成

実際のソースコードはGitHub上で公開しているので、必要に応じて適宜参照してください。

テストを実装するにあたって

マイクロサービスのアプリケーション構成同様、BFFアプリケーションでもコンポーネントを構成する主な単位は「Controller」「Service」「Repository」です。

主要なビジネス処理はバックエンド側のサービスにあるため、一見するとControllerやServiceなどから直接RestTemplateなどのRestClientを使ってマイクロサービスを呼び出したほうがシンプルで効率的に思えるかもしれません。しかし、マイクロサービスの呼び出しがレスポンスを待つ同期型中心である場合、呼び出し側アプリケーションもこのようなレイヤ構造にしておくと以下の2つのメリットがあります。

  • Repositoryの実装クラスでRestClientを使ったバックエンドのマイクロサービスを呼び出すようにしておくことで、マイクロサービスで発生したビジネスエラーやシステムエラー、サービス呼び出しの通信エラーなどの例外ハンドリングを1カ所に集約できる
  • エラー発生時のリトライや、複数のマイクロサービスを呼び出した際に発生したエラーに伴う処理(例えば、SAGAパターンを使ったロールバック処理など)をServiceクラスに実装し、トランザクション境界を明確化できる

それなりの規模のアプリケーションになってくると、単純にRestTemplateを使って呼び出すだけでは、バックエンドのマイクロサービスを呼び出す際のTryCatch文が乱立してコードの見通しも悪くなります。そのため、バックエンドのマイクロサービスをResourceの永続先レポジトリと捉え、ControllerからService経由で呼び出したほうがスッキリします。本アプリケーションでは、こうした構成を前提にテストクラスを以下のような観点で実装していきます。

※ SAGAパターンについては後述します。

テストクラスの実装

アプリケーション 試験 コンポーネント 検証観点
Webアプリケーション(BFF) 単体試験 Respository(RestClient) ・正しくビジネス例外が返されるか
・異常なレスポンスを受け取った場合、正しくシステム例外が返されるか
・例外に正しくメッセージが設定されるか
・マイクロサービス側のサーバエラー発生時に正しくシステム例外が返されるか
Service ・Service実行の結果、正しくアウトプットが返されるか
・Service実行の結果、正しくビジネス例外が返されるか
・例外に正しくメッセージが設定されているか
(View⇔)Controller ・指定したHTTPメソッドやURLで正しくリクエストハンドリングされるか
・リクエストパラメータやパス変数が正しくマッピングされるか
・入力チェックが正しく行われているか
・入力チェックやビジネスエラー発生時に正しいメッセージやパラメータを返却するか
・サービス実行結果が正しく画面に表示されるか
・非同期通信の実行結果が正しく画面に表示されるか
EndToEndテスト [BFF]⇔[Backend] ・ユースケースシナリオ通り操作したときに、正しく画面に結果が表示されるか
・ユースケースシナリオ通り操作したときに、エラーメッセージが正しく表示されるか
・データベースへ正しくデータが反映できるか
・画面表示のレイアウトが崩れていないか
・ブラウザごとに表示が異なっていないか

また、以降では、SpringBootを使ってテストコードの実装を進めていきますが、プロジェクトのpom.xmlにspring-boot-starter-testのライブラリを含めておいてください(以下参照)。

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

マイクロサービスを呼び出すWebAPのRepository単体テスト実装

前節でも説明した通り、このBFFアプリケーションでは、バックエンドのマイクロサービスの呼び出しをResourceクラスのRepositoryとして実装します。ソースコードをご覧いただくとわかるように、マイクロサービスから返却されるレスポンスはResourceオブジェクトに加え、マイクロサービスで発生したエラーの場合もあります。例えば、HTTPステータスコードが400(BadRequest)でセットされたビジネスエラーやバリデーションエラー、ステータスコードが500のサーバエラーや通信エラーなどです。

単体テストでは、エラーが発生した場合の異常系のバリエーションケースを中心に、正しく例外ハンドリングが行われるかを検証します。と言っても、実際にバックエンドのマイクロサービスを起動させてテストを実施するわけではありません。REST通信に関わるエラーレスポンスなどを擬似的に生成可能なorg.springframework.test.web.client.MockRestServiceServerを使って、マイクロサービスの呼び出しをスタブ化して実行します。

また、RestTemplateを使ったテスト環境を簡易的に構築するorg.springframework.boot.test.autoconfigure.web.client.RestClientTestアノテーションを使用します。サンプルのテストコードは以下の通りです。

package org.debugroom.mynavi.sample.continuous.integration.bff.domain.repository;

// omit

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
import org.springframework.context.MessageSource;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.test.web.client.match.MockRestRequestMatchers;
import org.springframework.test.web.client.response.MockRestResponseCreators;
import org.springframework.web.client.RestTemplate;

@RunWith(Enclosed.class)
public class UserResourceRepositoryImplTest {


    @RunWith(SpringRunner.class)                                  // ...(A)
    @RestClientTest                                               // ...(B)
    @ContextConfiguration(classes = {UnitTest.Config.class,
             TestConfig.UnitTestConfig.class})                    // ...(C)
    public static class UnitTest{

        //omit

        @Autowired
        RestTemplate restTemplate;

        @Autowired
        MessageSource messageSource;

        @Autowired
        ObjectMapper objectMapper;

        @Autowired
        UserResourceRepository userResourceRepository;

        @Rule
        public ExpectedException expectedException = ExpectedException.none();

        @Test
        public void findOneAbnormalTest1() throws Exception{

            MockRestServiceServer mockRestServiceServer = MockRestServiceServer
                  .bindTo(restTemplate).build();              // ...(D)

            Long userId = 0L;

            String errorCode = "E0001";
            BusinessException businessException = new BusinessException(errorCode,
                  messageSource.getMessage(errorCode, new Long[]{userId},
                      Locale.getDefault()), Long.toString(userId));
            String jsonResponseBody1 = objectMapper.writeValueAsString(
                  BusinessExceptionResponse.builder()
                          .businessException(businessException)
                          .build());                         // ...(E)

            mockRestServiceServer
              .expect(MockRestRequestMatchers.requestTo("/backend/api/v1/users/0"))
              .andExpect(MockRestRequestMatchers.method(HttpMethod.GET))
              .andRespond(MockRestResponseCreators.withBadRequest().body(
                          jsonResponseBody1));               // ...(F)

            expectedException.expect(BusinessException.class);
            expectedException.expect(BusinessExceptionMatcher.builder()
                  .businessException(businessException).build());

           userResourceRepository.findOne(userId);

      }
      // omit
}
項番 説明
A テストランナーとして、SpringRunnerを指定します
B @RestClientTestアノテーションを付与します
C UserResourceRepositoryでは、異常系のエラーメッセージの取得にMessageSourceを使用するため、テスト用のDIコンテナから取得できるように設定しておきます。@ContextConfigurationについては、TERASOLUNAガイドライン「Spring TestのDI機能」を適宜参照してください
D MockRestServiceServerを動作させるRestTemplateを設定します。なお、@AutowiredでMockRestServiceServerをインジェクションしてもかまいません
E エラーメッセージを設定したビジネス例外をJSON表現の文字列化します
F MockRestServiceServerが指定したURLでHTTPステータスコード400で(E)の文字列を返却するように設定します

サンプルとして実装したテストケースと検証観点は以下の通りです。

ユースケース 主な処理実装クラスメソッド 検証観点
テストメソッド
[異常系]ユーザーリソースを検索する1 UserResourceRepository#findOne ・正しくビジネス例外が返されるか
UserResourceRepositoryTest#findOneAbnormalTest1()
[異常系]ユーザーリソースを検索する2 UserResourceRepository#findOne ・異常なレスポンスを受け取った場合正しくシステム例外が返されるか
・例外に正しくメッセージが設定されるか
UserResourceRepositoryTest#findOneAbnormalTest2()
[異常系]ユーザーリソースを検索する3 UserResourceRepository#findOne ・異常なレスポンスを受け取った場合正しくシステム例外が返されるか
・例外に正しくメッセージが設定されるか
UserResourceRepositoryTest#findOneAbnormalTest3()
[異常系]ユーザーリソースを検索する4 UserResourceRepository#findOne ・マイクロサービス側のサーバエラー発生時に正しくシステム例外が返されるか
・例外に正しくメッセージが設定されるか
UserResourceRepositoryTest#findOneAbnormalTest4()
[異常系]全てのユーザーリソースを検索する1 UserResourceRepository#findAll ・異常なレスポンスを受け取った場合正しくシステム例外が返されるか
・例外に正しくメッセージが設定されるか
UserResourceRepositoryTest#findAllAbnormalTest1()
[異常系]全てのユーザーリソースを検索する2 UserResourceRepository#findAll ・マイクロサービス側のサーバエラー発生時に正しくシステム例外が返されるか
・例外に正しくメッセージが設定されるか
UserResourceRepositoryTest#findAllAbnormalTest2()
[正常系]ユーザーリソースを追加する UserResourceRepository#save ・正しいステータスコードでアウトプットが返されるか
UserResourceRepositoryTest#saveNormalTest()
[異常系]ユーザーリソースを追加する1 UserResourceRepository#save ・正しくビジネス例外が返されるか
UserResourceRepositoryTest#saveAbnormalTest1()
[異常系]ユーザーリソースを追加する2 UserResourceRepository#save ・正しくビジネス例外が返されるか
・例外に正しくメッセージが設定されるか
UserResourceRepositoryTest#saveAbnormalTest2()
[異常系]ユーザーリソースを追加する3 UserResourceRepository#save ・異常なレスポンスを受け取った場合正しくシステム例外が返されるか
・例外に正しくメッセージが設定されるか
UserResourceRepositoryTest#saveAbnormalTest3()
[異常系]ユーザーリソースを追加する4 UserResourceRepository#save ・異常なレスポンスを受け取った場合正しくシステム例外が返されるか
・例外に正しくメッセージが設定されるか
UserResourceRepositoryTest#saveAbnormalTest4()
[異常系]ユーザーリソースを追加する5 UserResourceRepository#save ・マイクロサービス側のサーバエラー発生時に正しくシステム例外が返されるか
・例外に正しくメッセージが設定されるか
UserResourceRepositoryTest#saveAbnormalTest5()
[異常系]ユーザーリソースを削除する1 UserResourceRepository#delete ・正しくビジネス例外が返されるか
UserResourceRepositoryTest#deleteAbnormalTest1()
[異常系]ユーザーリソースを削除する2 UserResourceRepository#delete ・異常なレスポンスを受け取った場合正しくシステム例外が返されるか
・例外に正しくメッセージが設定されるか
UserResourceRepositoryTest#deleteAbnormalTest2()
[異常系]ユーザーリソースを削除する3 UserResourceRepository#delete ・異常なレスポンスを受け取った場合正しくシステム例外が返されるか
・例外に正しくメッセージが設定されるか
UserResourceRepositoryTest#deleteAbnormalTest3()
[異常系]ユーザーリソースを削除する4 UserResourceRepository#delete ・マイクロサービス側のサーバエラー発生時に正しくシステム例外が返されるか
・例外に正しくメッセージが設定されるか
UserResourceRepositoryTest#deleteAbnormalTest4()
[異常系]指定したログインIDを持つユーザーリソースを検索する1 UserResourceRepository#findByLoginId ・正しくビジネス例外が返されるか
UserResourceRepositoryTest#findByLoginIdAbnormalTest1()
[異常系]指定したログインIDを持つユーザーリソースを検索する2 UserResourceRepository#findByLoginId ・異常なレスポンスを受け取った場合正しくシステム例外が返されるか
・例外に正しくメッセージが設定されるか
UserResourceRepositoryTest#findByLoginIdAbnormalTest2()
[異常系]指定したログインIDを持つユーザーリソースを検索する3 UserResourceRepository#findByLoginId ・異常なレスポンスを受け取った場合正しくシステム例外が返されるか
・例外に正しくメッセージが設定されるか
UserResourceRepositoryTest#findByLoginIdAbnormalTest3()
[異常系]指定したログインIDを持つユーザーリソースを検索する4 UserResourceRepository#findByLoginId ・マイクロサービス側のサーバエラー発生時に正しくシステム例外が返されるか
・例外に正しくメッセージが設定されるか
UserResourceRepositoryTest#findByLoginIdAbnormalTest4()

今回は、サンプルとしてシステム例外時のハンドリングもテストケースに含めています。しかし本来、システム例外は各コンポーネントの中で個別にハンドリングするのではなく、AOPなどで一律ハンドリングし、各業務開発者が意識せずに済むようにAP基盤部品として作成しておくほうが望ましいものです。

マイクロサービスを呼び出すWebAPのService単体テスト実装

先述の通り、このBFFアプリケーションではRepositoryに実際のマイクロサービスの呼び出し処理を委譲し、Serviceではエラー発生時のリトライや、複数のマイクロサービスを呼び出した際に発生したエラー時のロールバック処理などの責務を持たせて実装します。

このようなサービスのフロー制御を実行する役割を「オーケストレーション」と呼び、エラー発生時のマイクロサービスの処理結果をロールバックする補償トランザクションを実行するパターンを「SAGAパターン」と呼びます。SAGAパターンの詳細は、microservices.io Pattern:Sagaによくまとまっているのでそちらを適宜参考にしてください。

以下に示すのは、ユーザーデータを複数保存する場合に、マイクロサービスを複数回呼び出し、エラーが発生した際、SAGAパターンに従ってロールバック処理するServiceコードの例です。

package org.debugroom.mynavi.sample.continuous.integration.bff.domain.service;

// omit

@Service
public class OrchestrateServiceImpl implements OrchestrateService {

    @Autowired
    UserResourceRepository userResourceRepository;

    @Override
    public List<UserResource> addUsers(List<UserResource> addUserResources)
          throws BusinessException{
        List<UserResource> userResources = new ArrayList<>();
        for (UserResource addUserResource : addUserResources){
            try{
                userResources.add(userResourceRepository.save(addUserResource));
            }catch (BusinessException e){
            // Rollback for SAGA Pattern.
                for(UserResource userResource : userResources){
                    userResourceRepository.delete(userResource.getUserId());
                }
                throw e;
            }
       }
       return userResources;
    }
}

こうした処理が実装されたServiceですが、実際のマイクロサービスの呼び出しはRepositoryに隠蔽されているため、テストコードもマイクロサービスにおけるServiceのそれと基本的に違いはありません。

Repositoryをモック化して、DIコンテナとともに実行に必要なコンポーネントをオートコンフィグレーションする@SpringBootTestアノテーションを使い、MessageSourceなどのコンポーネントはSpringのDIコンテナから取得できるようにしてテストコードを実装します。

Serviceの単体サンプルコードは以下の通りです。

package org.debugroom.mynavi.sample.continuous.integration.bff.domain.service;

// omit

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(Enclosed.class)
public class OrchestrateServiceImplTest {

    // omit

    @RunWith(SpringRunner.class)
    @SpringBootTest(classes = {
        OrchestrateServiceImplTest.UnitTest.Config.class
     }, webEnvironment = SpringBootTest.WebEnvironment.NONE)
    public static class UnitTest{

        // omit

        @MockBean
        UserResourceRepository userResourceRepositoryMock;

        @Autowired
        OrchestrateService orchestrateService;

        @Rule
        public ExpectedException expectedException = ExpectedException.none();

        // omit
        @Before
        public void setUp() throws Exception{
            // omit
            when(userResourceRepositoryMock.save(mockUser1)).thenReturn(mockUser1);
            when(userResourceRepositoryMock.save(mockUser2)).thenThrow(BusinessException.class);
            // omit
        }

        @Test
        public void addUsersAbnormalTest1() throws Exception{
            //omit
            UserResource user1 = UserResource.builder()
                     .userId(userId1).firstName("taro").familyName("mynavi")
                     .loginId("taro.mynavi").address(address1)
                     .emailList(Arrays.asList(new EmailResource[]{email1, email2})).build();
            UserResource user2 = UserResource.builder()
                     .userId(userId2).familyName("mynavi").firstName("hanako")
                     .loginId("hanako.mynavi").address(address2)
                     .emailList(Arrays.asList(new EmailResource[]{email3})).build();
            //omit
            expectedException.expect(BusinessException.class);

            orchestrateService.addUsers(Arrays.asList(
                new UserResource[]{user1, user2}));
       }
       // omit

上記のテスト実装により、Sevice実行時のアウトプットオブジェクトの妥当性やビジネス例外発生の妥当性、ビジネス例外のメッセージなどを検証できます。サンプルで作成したテストケースは、Serviceの異常系処理を中心に、以下のようなユースケース/検証観点を基に実装しています。Serviceが複数のサービスにアクセスする場合のSAGAパターンによるロールバックや、リトライ処理などオーケストレーションの責務を負う場合は、Seviceの単体テストで適宜異常系のバリエーションを追加して検証するとよいでしょう。

ユースケース 主な処理実装クラスメソッド 検証観点
テストメソッド
[異常系]複数のユーザー情報を追加する(1) OrchestrateService#addUsers ・Service実行の結果、正しくビジネス例外が返されるか
OrchestrateService#addUsers
[異常系]複数のユーザー情報を追加する(2) OrchestrateService#addUsers ・マイクロサービス側のサーバエラー発生時に正しくシステム例外が返されるか
OrchestrateServiceImplTest#addUsersAbnormalTest2()

このサンプルでは、ユーザーを保存するマイクロサービスを複数回呼び出し、処理の途中でビジネスエラーが発生した場合、それまで成功した保存データを逆に削除していくロールバック処理をcatch節の中で実装します。ロールバック処理も含めて正常に完了した場合はビジネスエラーを返却し、マイクロサービスへの呼び出しの途中でサーバエラーが発生した場合は、Repositoryからシステムエラーをスローする仕様を想定したテストケースとしています。

* * *

次回は、HTMLUnitを使用したBFFアプリケーションでのControllerの単体テスト、Seleniumを使用したEndToEndのテストコードをSpringBootを使って実装していきます。

著者紹介


川畑 光平(KAWABATA Kohei) - NTTデータ 課長代理

金融機関システム業務アプリケーション開発・システム基盤担当を経て、現在はソフトウェア開発自動化関連の研究開発・推進に従事。

Red Hat Certified Engineer、Pivotal Certified Spring Professional、AWS Certified Solutions Architect Professional等の資格を持ち、アプリケーション基盤・クラウドなどさまざまな開発プロジェクト支援にも携わる。2019 APN AWS Top Engineers & Ambassadors選出。