前回は、マイクロサービス(Backend)の単体テストの実装例や検証観点、テスト戦略のポイントを説明しました。 今回はバックエンドで実行されるマイクロサービスの結合テストです。アプリケーションおよびテストのパッケージ・コンポーネント構成は前回と同様、以下としています。
[backend]
└src
├main
│ ├java
│ │ └org
│ │ └debugroom
│ │ └mynavi
│ │ └sample
│ │ └continuous
│ │ └integration
│ │ └backend
│ │ └app ... アプリケーション層のパッケージ
│ │ │ └model ... リクエストパラメータのモデルクラスパッケージ
│ │ │ │ ├Xxxxx.java ... 入力チェックルール等が定義されるモデルクラス
│ │ │ │ └XxxxxMapper.java ... ドメイン層のモデルクラスと相互変換するマッパークラス
│ │ │ └web ... MvcConfigでコンポーネントスキャンの対象とするパッケージ
│ │ │ └BackendRestController.java ... リクエストハンドリング・ドメインサービス呼び出して、Resourceを返却するコントローラクラス
│ │ └domain ... ドメイン層のパッケージ
│ │ │ └model
│ │ │ │ └entity ... JPAConfigでスキャン対象とするエンティティクラスパッケージ
│ │ │ │ └Xxxxx.java ... JPAエンティティクラス
│ │ │ ├repository ... JPAConfigでスキャン対象とするレポジトリクラスパッケージ
│ │ │ │ ├specification ... JPAでテーブル結合等の条件を指定するクラスパッケージ
│ │ │ │ │ └Xxxxx.java ... JPAでテーブル結合等の条件を指定するクラス
│ │ │ │ └XxxxxRepository.java ... レポジトリインタフェースクラス
│ │ │ └service ... DomainConfigでコンポーネントスキャンの対象とするサービスクラスパッケージ
│ │ │ ├SampleService.java ... DBへ基本的なCRUDアクセスを行うサービスインタフェースクラス
│ │ │ ├SampleServiceImpl.java ... SampleServiceの実装クラス
│ │ │ ├SampleOneToOneService.java ... 1対1の関連をもつテーブルアクセスを行うサービスインタフェースクラス
│ │ │ ├SampleOneToOneServiceImpl.java ... SampleOneToOneServiceの実装クラス
│ │ │ ├SampleOneToManyService.java ... 1対多の関連をもつテーブルアクセスを行うサービスインタフェースクラス
│ │ │ ├SampleOneToManyServiceImpl.java ... SampleOneToOneServiceの実装クラス
│ │ │ ├SampleManyToManyService.java ... 多対多の関連をもつテーブルアクセスを行うサービスインタフェースクラス
│ │ │ └SampleManyToManyServiceImpl.java ... サービス実装クラス
│ │ └config ... 設定クラス用のパッケージ
│ │ ├App.java ... アプリケーション起動クラス
│ │ ├DevConfig.java ... 開発環境固有の設定クラス
│ │ ├DomainConfig.java ... ドメイン層に関する設定クラス
│ │ ├JPAConfig.java ... JPA設定クラス
│ │ └MvcConfig.java ... アプリケーション層に関する設定クラス
│ └resources
│ ├application.yml ... アプリケーション設定ファイル
│ └application-dev.yml ... プロファイル"dev"で有効になるアプリケーション設定ファイル
test ... テストパッケージフォルダ
├java
│ └org
│ └debugroom
│ └mynavi
│ └sample
│ └continuous
│ └integration
│ └backend
│ ├app
│ │ └web
│ │ └BackendRestControllerTest.java ... Controllerのテストクラス
│ ├domain
│ │ ├DataJpaTestConfig.java ... Repositoryのテスト設定クラス
│ │ ├repository ... Repositoryテストパッケージ
│ │ │ └XxxxRepositoryTest.java ... Repositoryのテストクラス
│ │ └service ... Serviceテストパッケージ
│ │ └XxxxServiceTest.java ... Serviceのテストクラス
│ └config
│ └TestConfig.java ... Testの汎用設定クラス
└resources
├META-INF
│ └dbunit ... DBUnitのテーブルデータ用パッケージ
│ └domain
│ └service
│ └XxxxServiceTest ... テストクラスごとのフォルダ
│ └Xxxx ... テストケースごとのフォルダ
│ ├Xxxxx.csv ... 各テーブルのテストデータ
│ └table-ordering.txt ... 読み込み対象のテーブル名を記載したテキストファイル
└application.yml ... テスト用のアプリケーション設定ファイル
各コンポーネントは TERASOLUNAのガイドライン レイヤの依存関係 を基本的に踏襲していますが、Controller→Service→Repositoryという単方向の呼び出ししかありません。 そのため、結合試験としては、下記のイメージ通り、Service→Repositoryおよび、Controller→Service→Repositoryといった順に積み上げ式で試験を進めることにします(Repositoryをスタブ化し、ControllerとServiceのみの結合試験は除外できます)。
各テストの観点は以下の通りです。コンポーネントの内部構造は意識せず、ブラックボックス的に処理実行後のIOやデータベース反映結果を中心に検証します。
アプリケーション | 試験 | コンポーネント | 検証観点 |
マイクロサービス (Backend) |
結合試験 | Service⇔Repository | ・データベースから正しく値が取得できるか ・データベースへ正しくデータが反映できるか ・設定ファイルが正しく動作するか |
Controller⇔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);
}
項番 | 説明 |
テストランナーとして、SpringRunnerを指定します。 | |
@SpringBootTestアノテーションには、テスト向け固有の設定クラスを任意に指定し、Controllerを介さない場合、Webコンテナ(Server)を起動しないオプションを指定しておきます。 | |
DBUnitで使用するTestExecutionListenerの設定を行います。ここでは詳しい説明は割愛しますが、詳細は TERASOLUNAガイドライン TestExecutionListenerの登録 を参照してください。 | |
テストに使うデータベースへのデータ設定にはCSV形式のデータファイルを使用します。なお、データはExcel形式でもできますが、実行パフォーマンスの問題やコードコミット時にバイナリファイルで差分比較ができなくなるためCSVの方がベターです。詳細は テストデータのセットアップ を参照してください。 | |
CSV形式でデータロードするための拡張クラスです。 | |
テストメソッド実行後に期待するデータベースのデータを各テーブルごとに設定します。詳細は 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();
}
}
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<UserResource[]> responseEntity = testRestTemplate
.getForEntity(testServerURL + "/api/v1/users", UserResource[].class);
List<UserResource> 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
}
});
}
項番 | 説明 |
テストランナーとして、SpringRunnerを指定します。 | |
@SpringBootTestアノテーションには、テスト向け固有の設定クラスを任意に指定し、Webコンテナ(Server)の起動時のポートをランダムで指定しておきます。 | |
起動したテストアプリケーションに対して、リクエストを送信するTestRestTemplateをインジェクションします。 | |
(B)でランダム指定したポートを取得します。 | |
セットアップメソッドで、HTTPリクエストを送信するテストサーバURLを作成します。 |
サンプルとして実装したテストケースと検証観点は以下になります。異常系のバリエーションは単体テストで検証しているため、 基本的には、エラー時のHTTPレスポンス生成が正しく実行されるかといった点のみを検証しています。
(1)Service⇔Repository、(2)Controller⇔Service⇔Repositoryの結合テストコード実装を解説してきました。単体テストと似通った試験を実施しても非効率になりますので、効率的な結合テスト戦略策定のポイントとしては、
テストクラスの量を増やしたくないのであれば、(1)を除外して、(2)で(1)のテスト観点を含めて検証しても問題ありません。 テスト品質は、ユースケース数に対するテストケースの割合、テストケースの定性的評価などを加えつつ評価するとよいでしょう。
次回は引き続き、マイクロサービスを呼び出すWebアプリケーションにおいて、単体テストコードをSpringBootでどのように実装するか、解説していきます。
川畑 光平(KAWABATA Kohei) - NTTデータ 課長代理
金融機関システム業務アプリケーション開発・システム基盤担当を経て、現在はソフトウェア開発自動化関連の研究開発・推進に従事。
Red Hat Certified Engineer、Pivotal Certified Spring Professional、AWS Certified Solutions Architect Professional等の資格を持ち、アプリケーション基盤・クラウドなど様々な開発プロジェクト支援にも携わる。