前回は、マイクロサービス(Backend)の単体テストの実装例や検証観点、テスト戦略のポイントを説明しました。今回は、バックエンドで実行されるマイクロサービスの結合テストを進めていきましょう。アプリケーションとテストのパッケージ/コンポーネント構成は前回と同様、以下のようにしています。

アプリケーションおよびテストのパッケージ/コンポーネント構成

各コンポーネントはTERASOLUNAのガイドライン「レイヤの依存関係」を基本的に踏襲していますが、Controller→Service→Repositoryという単方向の呼び出ししかありません。そのため、結合試験としては、下記のイメージ通り、Service→Repositoryおよび、Controller→Service→Repositoryといった順に積み上げ式で試験を進めることにします(Repositoryをスタブ化し、ControllerとServiceのみの結合試験は除外できます)。

積み上げ式で進める結合試験のイメージ

各テストの観点は以下の通りです。コンポーネントの内部構造は意識せず、ブラックボックス的に処理実行後のIOやデータベース反映結果を中心に検証します。

アプリケーション 試験 コンポーネント 検証観点
マイクロサービス(Backend) 結合試験 Service⇔Repository ・データベースから正しく値が取得できるか
・データベースへ正しくデータが反映できるか
・設定ファイルが正しく動作するか
Controller⇔Service⇔Repository ・期待したレスポンスが返却されるか
・モデル間のデータマッピングが正しく実行されているか
・設定ファイルが正しく動作するか

Service⇔Repositoryの結合テスト実装

データベースからの基本的なデータ取得については、Repositoryの単体テストで妥当性確認は取れているので、データベースへの更新結果を中心にDBUnitを用いて検証します(複雑な条件のデータ取得は処理結合レベルでバリエーションテストを実施したほうがよりベターです)。

また、Service単体でも分岐条件などで発生するビジネスエラーや設定されるメッセージの確認は取れているので、ServiceがRepositoryを正しく呼び出すことができるか、プロパティなどの設定が正しく動作するかを@SpringBootTestアノテーションを使って、SpringBootApplicationを起動した際と同様に検証します。

Serviceクラスを起点としたサンプル結合テストコードは以下の通りです。

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

// omit
import com.github.springtestdbunit.DbUnitTestExecutionListener;
import com.github.springtestdbunit.annotation.DbUnitConfiguration;
import com.github.springtestdbunit.annotation.ExpectedDatabase;
import com.github.springtestdbunit.annotation.ExpectedDatabases;
import com.github.springtestdbunit.assertion.DatabaseAssertionMode;
import com.github.springtestdbunit.dataset.AbstractDataSetLoader;

import org.dbunit.dataset.IDataSet;
import org.dbunit.dataset.csv.CsvDataSet;

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;
import org.springframework.test.context.support.DirtiesContextTestExecutionListener;
import org.springframework.test.context.transaction.TransactionalTestExecutionListener;

// omit

@RunWith(SpringRunner.class)                                                 // ...(A)
@SpringBootTest(classes = {
      TestConfig.ServiceTestConfig.class,
}, webEnvironment =  SpringBootTest.WebEnvironment.NONE)                     // ...(B)
@TestExecutionListeners({ DependencyInjectionTestExecutionListener.class,
      DirtiesContextTestExecutionListener.class,
      TransactionalTestExecutionListener.class,
      DbUnitTestExecutionListener.class })                                   // ...(C)
@DbUnitConfiguration(dataSetLoader = IntegrationTest.CsvDataSetLoader.class) // ...(D)
@ActiveProfiles("dev")
public static class IntegrationTest{

    public static class CsvDataSetLoader extends AbstractDataSetLoader{  // ...(E)
        @Override
        protected IDataSet createDataSet(Resource resource) throws Exception {
            return new CsvDataSet(resource.getFile());
        }
    }

    // omit

    @Autowired
    SampleService sampleService;

    @Test
    @ExpectedDatabases({                                                       // ...(F)
          @ExpectedDatabase(
                  value = "classpath:/META-INF/dbunit/domain/service/SampleServiceImplTest/add",
                  table = "usr", assertionMode = DatabaseAssertionMode.NON_STRICT),
          @ExpectedDatabase(
                  value = "classpath:/META-INF/dbunit/domain/service/SampleServiceImplTest/add",
                  table = "address", assertionMode = DatabaseAssertionMode.NON_STRICT),
          @ExpectedDatabase(
                  value = "classpath:/META-INF/dbunit/domain/service/SampleServiceImplTest/add",
                  table = "email", assertionMode = DatabaseAssertionMode.NON_STRICT_UNORDERED),
          @ExpectedDatabase(
                  value = "classpath:/META-INF/dbunit/domain/service/SampleServiceImplTest/add",
                  table = "membership", assertionMode = DatabaseAssertionMode.NON_STRICT_UNORDERED),
    })
    public void addNormalTest() throws BusinessException{
        // omit
        User addUser = User.builder()
              .firstName("saburo")
              .familyName("mynavi")
              .loginId("saburo.mynavi")
              .isLogin(false)
              .addressByUserId(addAddress)
              .emailsByUserId(Arrays.asList(new Email[]{addEmail1, addEmail2}))
              .membershipsByUserId(Arrays.asList(new Membership[]{membership1}))
              .build();
       sampleService.add(addUser);
    }
項番 結合テストコードの説明
A テストランナーとして、SpringRunnerを指定します
B @SpringBootTestアノテーションには、テスト向け固有の設定クラスを任意に指定し、Controllerを介さない場合、Webコンテナ(Server)を起動しないオプションを指定しておきます
C DBUnitで使用するTestExecutionListenerの設定を行います。ここでは詳しい説明は割愛しますが、詳細についてはTERASOLUNAガイドライン「TestExecutionListenerの登録」を参照してください
D テストに使うデータベースへのデータ設定にはCSV形式のデータファイルを使用します。なお、データはExcel形式でも良いのですが、実行パフォーマンスの問題やコードコミット時にバイナリファイルで差分比較ができなくなるため、CSVのほうがベターです。詳細は「テストデータのセットアップ」を参照してください
E CSV形式でデータロードするための拡張クラスです
F テストメソッド実行後に期待するデータベースのデータを各テーブルごとに設定します。詳細については「Spring Test DBUnitを利用したテスト」を参照してください

サンプルとして実装したテストケースと検証観点は、以下の通りです。サンプルでは、テストケースの順序をうまく設定すること(AddしてからFindAllする)で、トランザクションの有効化なども合わせて、処理結合テストレベルで検証するようにしています。なお、プロファイル「dev」で有効化する設定クラスを作成し、データベースとテストデータは事前にHSQLなどのインメモリDBに設定しておきます。

package org.debugroom.mynavi.sample.continuous.integration.backend.config;

import javax.sql.DataSource;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;

@Profile("dev")
@Configuration
public class DevConfig {

    @Bean
    public DataSource dataSource(){
        return (new EmbeddedDatabaseBuilder())
          .setType(EmbeddedDatabaseType.HSQL)
          .addScript("classpath:ddl/schema.sql")
          .addScript("classpath:ddl/data.sql")
          .build();
    }
}
ユースケース 主な処理実装クラスメソッド 検証観点
テストメソッド
[正常系]ユーザーを追加する SampleService#add ・データベースへ正しくデータが反映できるか
SampleServiceTest#addNormalTest()
[正常系]ユーザーを検索する SampleService#findOne ・データベースから正しく値が取得できるか
・(Autowiredインジェクション等)設定が正しいか
SampleServiceTest#findOneNormalTest()
[正常系]全ユーザーを検索する SampleService#findAll ・(トランザクション)設定が正しいか
SampleServiceTest#findAllNormalTest()
[正常系]ユーザーを更新する SampleService#update ・データベースへ正しくデータが反映できるか(想定した部分以外まで更新されていないか)
SampleServiceTest#updateNormalTest()
[正常系]ユーザーを削除する SampleService#delete ・データベースへ正しくデータが反映できるか(想定した部分以外まで削除されていないか)
SampleServiceTest#deleteNormalTest()
[正常系]指定したユーザーの住所を取得する SampleOneToOneService#findAddressOf ・データベースから正しく値が取得できるか
・(Autowiredインジェクション等)設定が正しいか
SampleOneToOneServiceTest#findAddressOfNormalTest()
[正常系]指定した郵便番号の住所を持つユーザーを取得する(1) SampleOneToOneService#findUserHavingAddressOfZipCode ・データベースから正しく値が取得できるか
SampleOneToOneServiceTest#findUserHavingAddressOfZipCodeNormalTest1()
[正常系]指定した郵便番号の住所を持つユーザーを取得する(2) SampleOneToOneService#findUserHavingAddressOfZipCode ・データベースから正しく値が取得できるか
SampleOneToOneServiceTest#findUserHavingAddressOfZipCodeNormalTest2()
[正常系]指定した郵便番号の住所を持たないユーザーを取得する(1) SampleOneToOneService#findUserHavingAddressOfZipCode ・データベースから正しく値が取得できるか
SampleOneToOneServiceTest#findUserHavingAddressOfZipCodeNormalTest1()
[正常系]指定した郵便番号の住所を持たないユーザーを取得する(2) SampleOneToOneService#findUserHavingAddressOfZipCode ・データベースから正しく値が取得できるか
SampleOneToOneServiceTest#findUserHavingAddressOfZipCodeNormalTest2()
[正常系]住所を更新する SampleOneToOneService#update ・データベースへ正しくデータが反映できるか(想定した部分以外まで削除されていないか)
SampleOneToOneServiceTest#updateTestNormal()
[正常系]指定したユーザーのメールリストを取得する SampleOneToManyService#getEmailsOf ・データベースから正しく値が取得できるか
・(Autowiredインジェクション等)設定が正しいか
SampleOneToManyServiceTest#getEmailsOfNormalTest()
[正常系]メールを追加する SampleOneToManyService#add ・データベースへ正しくデータが反映できるか
SampleOneToManyServiceTest#addNormalTest()
[正常系]メールを更新する SampleOneToManyService#update ・データベースへ正しくデータが反映できるか(想定した部分以外まで更新されていないか)
SampleOneToManyServiceTest#updateNormalTest()
[正常系]メールを削除する(1) SampleOneToManyService#delete ・データベースへ正しくデータが反映できるか(想定した部分以外まで削除されていないか)
SampleOneToManyServiceTest#deleteNormalTest1()
[正常系]メールを削除する(2) SampleOneToManyService#delete ・データベースへ正しくデータが反映できるか(想定した部分以外まで削除されていないか)
SampleOneToManyServiceTest#deleteNormalTest2()
[正常系]全てのメールを削除する SampleOneToManyService#deleteAll ・データベースへ正しくデータが反映できるか(想定した部分以外まで削除されていないか)
SampleOneToManyServiceTest#deleteAllNormalTest()
[正常系]ユーザーが所属するグループを取得する SampleManyToManyService#getGroupOf ・データベースから正しく値が取得できるか
・(Autowiredインジェクション等)設定が正しいか
SampleManyToManyServiceTest#getGroupOfUserNormalTest()
[正常系]指定したグループに所属するユーザーを取得する SampleManyToManyService#getUserOf ・データベースから正しく値が取得できるか
SampleManyToManyServiceTest#getUserOfGroupNormalTest()
[正常系]指定したグループに所属しないユーザーを取得する SampleManyToManyService#getUserOfNot ・データベースから正しく値が取得できるか
SampleManyToManyServiceTest#getUserOfNotGroupNormalTest()
[正常系]指定したグループにユーザーを追加する SampleManyToManyService#addUserTo ・データベースへ正しくデータが反映できるか
SampleManyToManyServiceTest#addUserToGroupNormalTest()
[正常系]指定したグループからユーザーを削除する SampleManyToManyService#deleteUserFrom ・データベースへ正しくデータが反映できるか(想定した部分以外まで削除されていないか)
SampleManyToManyServiceTest#deleteUserFromGroupNormalTest()
[正常系]指定したグループを削除する SampleManyToManyService#delete ・データベースへ正しくデータが反映できるか(想定した部分以外まで削除されていないか)
SampleManyToManyServiceTest#deleteGroupNormalTest()

Controller⇔Service⇔Repositoryの結合テスト実装

Controller⇔Service⇔Repositoryの結合テストでは、実際にアプリケーションを起動したときと同様、HTTPリクエストを送信し、期待したHTTPレスポンスが返ってくるかどうかを検証します。データベースへの反映結果は、前節のService⇔Repositoryで確認が取れているので、「Service実行後のアウトプットが期待通り、HTTPレスポンスに変換されるか」「モデルのデータマッピングが正しく実行されているか」「アプリケーションの設定が正しく動作するか」といった項目が主なチェックポイントになります。

Controllerクラスを起点とした結合テストサンプルコードは以下の通りです。

package org.debugroom.mynavi.sample.continuous.integration.backend.app.web;

// omit

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.test.context.junit4.SpringRunner;

// omit

@RunWith(SpringRunner.class)                                      // ...(A)
@SpringBootTest(classes = TestConfig.ControllerTestConfig.class,
   webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)    // ...(B)
public static class IntegrationTest{

    private static final String testServer = "localhost";

    @Autowired
    TestRestTemplate testRestTemplate;                           // ...(C)

    @LocalServerPort
    int port;                                                    // ...(D)

    private String testServerURL;

    @Before
    public void setUp(){
        testServerURL = "http://" + testServer + ":" + port;     // ...(E)
    }

    @Test
    public void getUsersNormalTest(){
        ResponseEntity responseEntity = testRestTemplate
          .getForEntity(testServerURL + "/api/v1/users", UserResource[].class);

        List userResources = Arrays.asList(responseEntity.getBody());

        assertThat(userResources.size(), is(3));
        userResources.forEach(userResource -> {
            switch (Long.toString(userResource.getUserId())){
                case "1":
                    assertThat(userResource.getFirstName(), is("hanako"));
                    assertThat(userResource.getFamilyName(), is("mynavi"));
                    break;
                // omit
            }
        });

   }
項番 結合テストコードの説明
A テストランナーとして、SpringRunnerを指定します
B @SpringBootTestアノテーションには、テスト向け固有の設定クラスを任意に指定し、Webコンテナ(Server)の起動時のポートをランダムで指定しておきます
C 起動したテストアプリケーションに対して、リクエストを送信するTestRestTemplateをインジェクションします
D (B)でランダム指定したポートを取得します
E セットアップメソッドで、HTTPリクエストを送信するテストサーバURLを作成します
ユースケース 主な処理実装クラスメソッド 検証観点
テストメソッド
[正常系]ユーザーの一覧を取得する BackendController#getUsers ・期待したレスポンスが返却されるか
・設定ファイルが正しく動作するか
BackendControllerTest#getUsersNormalTest()
[正常系]ユーザーを取得する BackendController#getUser ・期待したレスポンスが返却されるか
・モデル間のデータマッピングが正しく実行されているか
BackendControllerTest#getUserNormalTest()
[異常系]ユーザーを取得する BackendController#getUser ・期待したレスポンスが返却されるか(エラー)
BackendControllerTest#getUserAbnormalTest()
[正常系]ユーザーを追加する BackendController#addUser ・期待したレスポンスが返却されるか
BackendControllerTest#addUserNormalTest()
[正常系]ユーザーを更新する BackendController#updateUser ・期待したレスポンスが返却されるか
BackendControllerTest#updateUserNormalTest()
[正常系]ユーザーを削除する BackendController#deleteUser ・期待したレスポンスが返却されるか
BackendControllerTest#deleteUserNormalTest()
[正常系]指定したログインIDを持つユーザーを検索する BackendController#findUserOfLoginId ・期待したレスポンスが返却されるか
BackendControllerTest#findUserOfLoginIdNormalTest()
[正常系]指定したユーザーの住所を取得する BackendController#findAddressOfUser ・期待したレスポンスが返却されるか
・モデル間のデータマッピングが正しく実行されているか
BackendControllerTest#findAddressOfUserNormalTest()
[正常系]指定した郵便番号の住所を持つユーザーを検索する BackendController#findUsersHavingAddressOfZipCode ・期待したレスポンスが返却されるか
BackendControllerTest#findUsersHavingAddressOfZipCodeNormalTest()
[正常系]指定した郵便番号の住所を持たないユーザーを検索する BackendController#findUsersNotHavingAddressOfZipCode ・期待したレスポンスが返却されるか
BackendControllerTest#findUsersNotHavingAddressOfZipCodeNormalTest()
[正常系]住所を更新する BackendController#updateAddress ・期待したレスポンスが返却されるか
BackendControllerTest#updateAddressNormalTest()
[正常系]指定したユーザーのメールアドレスリストを取得する BackendController#findEmailsOfUser ・期待したレスポンスが返却されるか
・モデル間のデータマッピングが正しく実行されているか
BackendControllerTest#findEmailsOfUserNormalTest()
[正常系]指定したメールアドレスを持つユーザーを取得する BackendController#findUserHavingEmail ・期待したレスポンスが返却されるか
BackendControllerTest#findUserHavingEmailNormalTest()
[正常系]メールアドレスを追加する BackendController#addEmail ・期待したレスポンスが返却されるか
BackendControllerTest#addEmailNormalTest()
[正常系]メールアドレスを更新する BackendController#updateEmail ・期待したレスポンスが返却されるか
BackendControllerTest#updateEmailNormalTest()
[正常系]メールアドレスを削除する BackendController#deleteEmail ・期待したレスポンスが返却されるか
BackendControllerTest#deleteEmailNormalTest()
[正常系]ユーザーが所属するグループを取得する BackendController#findGroupsOfUser ・期待したレスポンスが返却されるか
・モデル間のデータマッピングが正しく実行されているか
BackendControllerTest#findGroupsOfUserNormalTest()
[正常系]指定したグループに所属するユーザーを取得する BackendController#findUsersOfGroup ・期待したレスポンスが返却されるか
BackendControllerTest#findUsersOfGroupNormalTest()
[正常系]指定したグループに所属しないユーザーを取得する BackendController#findUsersOfNotGroup ・期待したレスポンスが返却されるか
BackendControllerTest#findUsersOfNotGroupNormalTest()
[正常系]ユーザーを指定したグループに追加する BackendController#addUserToGroup ・期待したレスポンスが返却されるか
BackendControllerTest#addUserToGroupNormalTest()
[正常系]ユーザーを指定したグループから削除する BackendController#deleteUserFromGroup ・期待したレスポンスが返却されるか
BackendControllerTest#deleteUserFromGroupNormalTest()
[正常系]グループを削除する BackendController#deleteGroup ・期待したレスポンスが返却されるか
BackendControllerTest#deleteGroupNormalTest()

マイクロサービスにおける結合テスト戦略と品質評価

本稿では、Service⇔Repository、Controller⇔Service⇔Repositoryの結合テストコード実装を解説してきました。単体テストと似通った試験を実施してもあまり意味がありません。効率的な結合テスト戦略策定のポイントとしては、以下の項目が挙げられます。

  • コンポーネントの内部構造は意識せず、ブラックボックス的に処理実行後のInput/Outputやデータベース反映結果を中心に検証する
  • テストケースの順序をうまく設定すること(AddしてからFindAllするなど)で、トランザクションの有効化なども合わせて、処理結合テストレベルで検証する
  • 探索的テストを導入し、実装状況に応じてテストケースの重複を極力減らしながらテストコードを作成する
  • 機能や処理の重要度に応じて、テスト実施内容に濃淡をつける(ビジネス的にそこまで重要ではない処理の参照系はテストしないなど)

もしテストクラスの量を増やしたくないのであれば、Service⇔Repositoryの結合テストを割愛し、Controller⇔Service⇔Repositoryの結合テストに含めるかたちで検証しても問題ありません。テスト品質は、ユースケース数に対するテストケースの割合、テストケースの定性的評価などを加えつつ評価するとよいでしょう。

次回は引き続き、マイクロサービスを呼び出すWebアプリケーションの単体テストコードをSpringBootでどのように実装するかについて解説していきます。

著者紹介


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

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

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