前回は、Repositoryのテストコードを実装しました。今回はService、Controllerについて解説していきます。
Serviceの単体テスト実装
Serviceは、TERASOLUNAのガイドライン「Serviceの実装」でも述べられている通り、ビジネス処理のトランザクション境界であり、ロジックの中核となるコンポーネントです。Service内でデータベースにアクセスする場合は、Serviceの実装クラスに@AutowiredでインジェクションしたRepositoryを介して行いますが、単体テストを行う場合は、モックなどでデータアクセス部分をスタブ化してからテストコードを実装します。
Serviceは、可能な限りPOJO(Plain Old Java Object)で実装するのが望ましいのですが、ビジネスロジック中に頻出する例外発生時のメッセージなどはSpringが提供するMessageSourceを使って取得するのが一般的です。こうした部分までをスタブ化するとセットアップが大変なので、実際の処理と同様、SpringのDIコンテナから取得できるようにしておくほうがよいでしょう。
そのため、Serviceのテストでは、SpringBootアプリケーション起動時と同様にDIコンテナと共に実行に必要なコンポーネントをオートコンフィグレーションする@SpringBootTestアノテーションを使います。これにより、MessageSourceなどのコンポーネントはSpringのDIコンテナから取得できるようにしておき、Repositoryなど手動実装がメインの部分はモック化します。
また、@SpringBootTestアノテーションのclasses属性に指定したテストクラスは、そのテストクラスと同一パッケージにある@Configurationアノテーションが付与された設定クラスを読み込みます。したがって、src/test配下に、src/main/配下と同じconfigパッケージを作成し、そこに配置したテスト用のConfigクラスを設定することで、アプリケーション起動時と同様、src/main/配下のconfigパッケージ配下にある設定クラスを読み込むようになります。
コードの例は以下の通りです。
// omit
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
// omit
import org.debugroom.mynavi.sample.continuous.integration.backend.config.TestConfig;
// omit
@RunWith(Enclosed.class)
public class SampleServiceImplTest {
@RunWith(SpringRunner.class)
@SpringBootTest(classes = {
TestConfig.UnitTestConfig.class,
SampleServiceImplTest.UnitTest.Config.class,
}, webEnvironment = SpringBootTest.WebEnvironment.NONE)
public static class UnitTest{
@Configuration
public static class Config{
@Bean
SampleService sampleService(){
return new SampleServiceImpl();
}
}
@MockBean
UserRepository userRepositoryMock;
// omit
@Autowired
SampleService sampleService;
@Rule
public ExpectedException expectedException = ExpectedException.none();
@Before
public void setUp(){
//omit
when(userRepositoryMock.findById(new Long(1))).thenReturn(Optional.empty());
//omit
}
// omit
@Test
public void findOneAbnormalTest() throws BusinessException{
expectedException.expect(BusinessException.class);
expectedException.expectMessage("指定されたユーザは存在しないか、IDが誤っています。 UserID : 1");
User user = sampleService.findOne(
User.builder().userId(new Long(1)).build());
}
// omit
}
こうしたテスト実装により、Sevice実行時のアウトプットオブジェクトの妥当性やビジネス例外発生の妥当性、ビジネス例外のメッセージなどを検証できます。また、単純にRepositoryを呼び出してデータを返すだけのメソッドは試験内容が重複するので結合試験で確認するものとしました。
テストケースのユースケース/検証観点
サンプルで作成したテストケースは、Serviceの異常系処理を中心に以下のようなユースケース/検証観点を基に実装しています。
ソースコードとテストケースを突き合わせるとわかる通り、Serviceのテストではカバレッジ率が上昇するほどテストケースの網羅率が上がります。逆に、前節で示したRepositoryや、次節で紹介するControllerのテストにおけるカバレッジは、テスト品質を表す指標としては意味がないので注意しましょう。
Controllerの単体テスト実装
本アプリケーションでは、マイクロサービスをRESTfulなアプリケーションとして作成しています。RESTfulなアプリケーションをSpringで実装する場合の考え方や実装する方法については、TERASOLUNAのガイドライン「RESTful Web Service」を適宜参照してください。
今回、SpringMVCにおけるControllerは@RestControllerアノテーションを付与し、JSONレスポンスを返却する「RestController」として作成します。正常応答時はHTTPステータス200でResourceクラスのJSONレスポンスを、ビジネス例外(業務的に想定される例外)や入力チェックエラー発生時はHTTPステータス400(BadRequest)で、原因やパラメータを設定したBusinessExceptionやValidationErrorのJSONレスポンスを、システム例外発生時はHTTPステータスで500(InternalServerError)で原因やパラメータを設定したSystemExceptionのJSONレスポンスを返却する仕様です。
また、例外のハンドリングは、@ControllerAdviceを付与したCommonExceptionHandlerクラスで実装しています。TERASOLUNAでは、共通ライブラリとしてBusinessExceptionやSystemExceptionを提供していますが、本アプリケーションでは純粋にSpringのみの依存としたいため、例外ハンドラに加え、BusinessExceptionやSystemExceptionはTERASOLUNAが提供しているものは使用せず、個別にAP基盤部品として作成しています。
RestControllerの単体テストでは、Serviceの単体テストと同じく@SpringBootTestを使ってテスト用のコンテナを起動し、処理の妥当性を検証することも可能です。しかし今回は、DIコンテナ生成に伴うオーバヘッドを軽減するために、よりテスト環境構築をライトに構築できる@WebMvcTestを使ってテストケースを実装します。
上記に示したイメージ通り、MockMvcがドライバのような位置付けでテスト対象のControllerクラスを呼び出し、Sevice以下のコンポーネントはMockとしてスタブ化した上で、Controllerクラスの実装の妥当性を検証します。
サンプルのテストコードは以下のようになります。
package org.debugroom.mynavi.sample.continuous.integration.backend.app.web;
// omit
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.experimental.runners.Enclosed;
import org.junit.runner.RunWith;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import org.debugroom.mynavi.sample.continuous.integration.common.apinfra.exception.BusinessExceptionResponse;
import org.debugroom.mynavi.sample.continuous.integration.common.apinfra.exception.ErrorResponse;
// omit
@RunWith(Enclosed.class)
public class BackendControllerTest {
@RunWith(SpringRunner.class)
@WebMvcTest(controllers = BackendContoller.class)
public static class UnitTest{
@Autowired
ObjectMapper objectMapper;
@Autowired
MockMvc mockMvc;
@MockBean
SampleService sampleService;
//omit
@Before
public void setUp() throws Exception{
//omit
User mockUser2 = User.builder()
.userId(1).firstName("hanako").familyName("mynavi").loginId("hanako.mynavi")
.isLogin(false).addressByUserId(mockAddress2).emailsByUserId(Arrays.asList(new Email[]{mockEmail3, mockEmail4}))
.build();
Mockito.when(sampleService.findOne(mockUser2)).thenReturn(mockUser2);
Mockito.when(sampleService.findOne(User.builder().userId(3).build()))
.thenThrow(new BusinessException("E0001", "", new Long[]{3L}));
//omit
}
@Test
public void getUserNormalTest() throws Exception{
// omit
UserResource userResource = UserResource.builder()
.userId(1).firstName("hanako").familyName("mynavi").loginId("hanako.mynavi")
.address(addressResource).emailList(Arrays.asList(new EmailResource[]{emailResource1, emailResource2}))
.build();
mockMvc.perform(MockMvcRequestBuilders
.get("/api/v1/users/{userId}", 1)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(MockMvcResultMatchers.status().is(HttpStatus.OK.value()))
.andExpect(MockMvcResultMatchers.content().string(
objectMapper.writeValueAsString(userResource)));
}
@Test
public void getUserAbnormalTest() throws Exception{
MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.get(
"/api/v1/users/{userId}", "3"))
.andExpect(MockMvcResultMatchers.status().is(HttpStatus.BAD_REQUEST.value()))
.andReturn();
BusinessExceptionResponse businessExceptionResponse = (BusinessExceptionResponse)
objectMapper.readValue(mvcResult.getResponse().getContentAsString(),
ErrorResponse.class);
assertThat(businessExceptionResponse.getBusinessException().getCode(), is("E0001"));
assertThat(businessExceptionResponse.getBusinessException().getArgs(), is(new Integer[]{3}));
}
//omit
}
}
正常系、異常系共に戻り値はJSON文字列になるので、レスポンスとなるリソースやException(ここでは抽象的なエラーレスポンスインタフェースクラスとしてErrorResponse※をJacsonのObjectMapperでシリアライズして期待値と一致するかを検証しています。
※ ErrorResponseではcom.fasterxml.jackson.annotation.JsonSubTypesやcom.fasterxml.jackson.annotation.JsonTypeInfoを使用してデシリアライズ時に動的にクラスを切り替えられるようにしています。
テストケースのユースケース/検証観点
Mapperの単体テストはわざわざ書き起こさなくても、ドメイン層の入出力オブジェクト/Resourceクラスのオブジェクトマッピングなど、レイヤ間を跨ぐ変換処理実装の妥当性も合わせて検証できるようにテストケースを作成しておきます。サンプルで作成したテストケースのユースケース/検証観点は以下の通りです。
ユースケース | 主な処理実装クラスメソッド | 検証観点 |
---|---|---|
テストメソッド | ||
[正常系]ユーザーリソースを取得する |
BackendController#getUser(Long userId) UserMapper |
・指定したHTTPメソッドやURLで正しくリクエストハンドリングされるか ・リクエストパラメータやパス変数が正しくマッピングされるか ・レイヤ間のモデルオブジェクト変換は正しくマッピングされるか |
BackendControllerTest#getUserNormalTest() | ||
[異常系]ユーザーリソースを取得する | BackendController#getUser(Long userId) | ・入力チェックエラーやビジネスエラー発生時に正しいHTTPステータスを返却するか ・入力チェックエラーやビジネスエラー発生時に正しいメッセージやパラメータを返却するか |
BackendControllerTest#getUserAbnormalTest() | ||
[正常系]ユーザーリソースを追加する |
BackendController#addUser(User user) User |
・指定したHTTPメソッドやURLで正しくリクエストハンドリングされるか ・リクエストパラメータやパス変数が正しくマッピングされるか ・入力チェックが正しく行われているか |
BackendControllerTest#addUserInputParamNormalTest() | ||
[異常系]ユーザーリソースを追加する |
BackendController#addUser(User user) User |
・入力チェックエラーやビジネスエラー発生時に正しいHTTPステータスを返却するか ・入力チェックエラーやビジネスエラー発生時に正しいメッセージやパラメータを返却するか |
BackendControllerTest#addUserInputParamAbnormalTest() | ||
[正常系]ユーザーリソースを更新する |
BackendController#updateUser(User user) User |
・指定したHTTPメソッドやURLで正しくリクエストハンドリングされるか ・リクエストパラメータやパス変数が正しくマッピングされるか |
BackendControllerTest#updateUserInputParamNormalTest() | ||
[異常系]指定されたログインIDをもつユーザーリソースを取得する |
BackendController#findUserOfLoginId(User user) User |
・入力チェックエラーやビジネスエラー発生時に正しいHTTPステータスを返却するか ・入力チェックエラーやビジネスエラー発生時に正しいメッセージやパラメータを返却するか |
BackendControllerTest#findUserByloginIdInputParamAbnormalTest() | ||
[異常系]住所リソースを更新する |
BackendController#updateAddress(Address address) Address AddressMapper |
・入力チェックエラーやビジネスエラー発生時に正しいHTTPステータスを返却するか ・入力チェックエラーやビジネスエラー発生時に正しいメッセージやパラメータを返却するか ・レイヤ間のモデルオブジェクト変換は正しくマッピングされるか |
BackendControllerTest#updateAddressInputParamAbnormalTest() | ||
[異常系]指定されたEmailをもつユーザーリソースを取得する |
BackendController#findUserHavingEmail(Email email) EmailMapper |
・入力チェックエラーやビジネスエラー発生時に正しいHTTPステータスを返却するか ・入力チェックエラーやビジネスエラー発生時に正しいメッセージやパラメータを返却するか ・レイヤ間のモデルオブジェクト変換は正しくマッピングされるか |
BackendControllerTest#findUserHavingEmailInputParamabnormalTest() | ||
[異常系]メールリソースを追加する |
BackendController#addEmail(Email email) |
・入力チェックエラーやビジネスエラー発生時に正しいHTTPステータスを返却するか ・入力チェックエラーやビジネスエラー発生時に正しいメッセージやパラメータを返却するか |
BackendControllerTest#addEmailInputParamAbnormalTest() | ||
[異常系]メールリソースを更新する |
BackendController#updateEmail(Email email) |
・入力チェックエラーやビジネスエラー発生時に正しいHTTPステータスを返却するか ・入力チェックエラーやビジネスエラー発生時に正しいメッセージやパラメータを返却するか |
BackendControllerTest#updateEmailInputParamAbnormalTest() | ||
[異常系]メールリソースを削除する |
BackendController#deleteEmail(Email email) |
・入力チェックエラーやビジネスエラー発生時に正しいHTTPステータスを返却するか ・入力チェックエラーやビジネスエラー発生時に正しいメッセージやパラメータを返却するか |
BackendControllerTest#deleteEmailInputParamAbnormalTest() |
特にControllerのテスト対象は、ソースコードとテストコードを見てわかる通り、リクエストのマッピングの妥当性だけではありません。リクエストパラメータのバリデーション定義が期待通り動作するかどうかや、チェックが正しいタイミング(ユースケース)で実行されるかなど、検証内容が複雑かつ多岐に渡ります。
単純にデータを取得するだけの正常系のユースケースは後々の結合試験で確認できるので、Controllerの単体テストでは、境界値試験など含め、リクエストパラメータの異常系バリエーションを充実させて検証したほうが良いでしょう。Controllerの設定誤りはセキュリティホールに直結しますので、各実装が少なくとも一度はテストパスすることを推奨します。
なお、SpringMVCにおける入力チェックの基本については、TERASOLUNAのガイドライン「入力チェック」や、「RESTful Web Serviceにおける入力エラー例外のハンドリング実装」を適宜参照してください。
マイクロサービスにおける単体テスト戦略と品質評価
これまで、Repository、Service、Controllerの単体テストコード実装を解説してきました。単体テストのコードだけでも実アプリケーションのコードよりもはるかにボリュームが多く、あらゆる異常系のテストを網羅しようとするとかなり大変なことがおわかりいただけたのではないでしょうか。
前回も説明したように、繰り返しのテストが発生しがちなマイクロサービスですが、初めから完璧にテストコードを整備しておく必要もありませんし、必要以上のテストコードの実装でかえって開発のアジリティを損なうようでは本末転倒です。ただ、テストが疎かになるとせっかくの継続的インテグレーションも機能しません。開発のスピードと品質を両立するために、テスト計画やスコープ、検証の観点を明示的に策定しておくことが重要です。
効率的な単体テスト戦略策定の主なポイントを以下に挙げておきます。
- ServiceやRepositoryにおける単純なデータ取得の正常系テストなど、結合試験でも重複して登場するテストケースは単体テストから除外する
- データベース更新結果など結合試験で効率的に検証できるテストケースは単体テストから除外する
- Controllerの設定誤りなどはセキュリティホールに直結するため、異常系のバリエーションを充実させるほか、少なくとも1度は実装をテストパスさせる
- 探索的テストを導入し、実装状況に応じてテストケースの重複を極力減らしながらテストコードを作成する
- 機能や処理の重要度に応じて、テスト実施内容に濃淡をつける(ビジネス的にそこまで重要でない処理の参照系はテストしないなど)
テスト品質は、これまでも見てきた通り、Serviceを除き、単純なカバレッジのみでは評価できません。ユースケース数に対するテストケースの割合や、テストケースの定性的評価などを加えつつ評価するとよいでしょう。
次回は引き続き、SpringBootを使った結合試験のテストコードを実装し、解説していきます。
著者紹介
川畑 光平(KAWABATA Kohei) - NTTデータ 課長代理
金融機関システム業務アプリケーション開発・システム基盤担当を経て、現在はソフトウェア開発自動化関連の研究開発・推進に従事。
Red Hat Certified Engineer、Pivotal Certified Spring Professional、AWS Certified Solutions Architect Professional等の資格を持ち、アプリケーション基盤・クラウドなどさまざまな開発プロジェクト支援にも携わる。2019 APN AWS Top Engineers & Ambassadors選出。