前回は、Repositoryのテストコードを実装しました。今回はService、Controllerと解説を進めていきます。
Serviceは TERASOLUNAのガイドライン Serviceの実装 でも述べている通り、 ビジネス処理のトランザクション境界であり、ロジックの中核となるコンポーネントです。Service内でデータベースにアクセスする場合は、 Serviceの実装クラスに@AutowiredでインジェクションしたRepositoryを介して行いますが、単体テストを行う場合は、 モックなどでデータアクセス部分をスタブ化してからテストコードを実装します。Serviceは可能な限りPOJOで実装されるのが望ましいですが、 ビジネスロジック中に頻出するビジネス例外発生時のメッセージなどは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のテストにおけるカバレッジはテスト品質を表す指標としては意味がないので注意しましょう。
本アプリケーションでは、マイクロサービスを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 BackendControllerTest#getUserNormalTest() |
・指定したHTTPメソッドやURLで正しくリクエストハンドリングされるか ・リクエストパラメータやパス変数が正しくマッピングされるか ・レイヤ間のモデルオブジェクト変換は正しくマッピングされるか |
[異常系]ユーザリソースを取得する | BackendController#getUser(Long userId) BackendControllerTest#getUserAbnormalTest() |
・入力チェックエラーやビジネスエラー発生時に正しいHTTPステータスを返却するか ・入力チェックエラーやビジネスエラー発生時に正しいメッセージやパラメータを返却するか |
[正常系]ユーザリソースを追加する | BackendController#addUser(User user) User BackendControllerTest#addUserInputParamNormalTest() |
・指定したHTTPメソッドやURLで正しくリクエストハンドリングされるか ・リクエストパラメータやパス変数が正しくマッピングされるか ・入力チェックが正しく行われているか |
[異常系]ユーザリソースを追加する | BackendController#addUser(User user) User BackendControllerTest#addUserInputParamAbnormalTest() |
・入力チェックエラーやビジネスエラー発生時に正しいHTTPステータスを返却するか ・入力チェックエラーやビジネスエラー発生時に正しいメッセージやパラメータを返却するか |
[正常系]ユーザリソースを更新する | BackendController#updateUser(User user) User BackendControllerTest#updateUserInputParamNormalTest() |
・指定したHTTPメソッドやURLで正しくリクエストハンドリングされるか ・リクエストパラメータやパス変数が正しくマッピングされるか |
[異常系]指定されたログインIDをもつユーザリソースを取得する | BackendController#findUserOfLoginId(User user) User BackendControllerTest#findUserByloginIdInputParamAbnormalTest() |
・入力チェックエラーやビジネスエラー発生時に正しいHTTPステータスを返却するか ・入力チェックエラーやビジネスエラー発生時に正しいメッセージやパラメータを返却するか |
[異常系]住所リソースを更新する | BackendController#updateAddress(Address address) Address AddressMapper BackendControllerTest#updateAddressInputParamAbnormalTest() |
・入力チェックエラーやビジネスエラー発生時に正しいHTTPステータスを返却するか ・入力チェックエラーやビジネスエラー発生時に正しいメッセージやパラメータを返却するか ・レイヤ間のモデルオブジェクト変換は正しくマッピングされるか |
[異常系]指定されたEmailをもつユーザリソースを取得する | BackendController#findUserHavingEmail(Email email) EmailMapper BackendControllerTest#findUserHavingEmailInputParamabnormalTest() |
・入力チェックエラーやビジネスエラー発生時に正しいHTTPステータスを返却するか ・入力チェックエラーやビジネスエラー発生時に正しいメッセージやパラメータを返却するか ・レイヤ間のモデルオブジェクト変換は正しくマッピングされるか |
[異常系]メールリソースを追加する | BackendController#addEmail(Email email) BackendControllerTest#addEmailInputParamAbnormalTest() |
・入力チェックエラーやビジネスエラー発生時に正しいHTTPステータスを返却するか ・入力チェックエラーやビジネスエラー発生時に正しいメッセージやパラメータを返却するか |
[異常系]メールリソースを更新する | BackendController#updateEmail(Email email) BackendControllerTest#updateEmailInputParamAbnormalTest() |
・入力チェックエラーやビジネスエラー発生時に正しいHTTPステータスを返却するか ・入力チェックエラーやビジネスエラー発生時に正しいメッセージやパラメータを返却するか |
[異常系]メールリソースを削除する | BackendController#deleteEmail(Email email) BackendControllerTest#deleteEmailInputParamAbnormalTest() |
・入力チェックエラーやビジネスエラー発生時に正しいHTTPステータスを返却するか ・入力チェックエラーやビジネスエラー発生時に正しいメッセージやパラメータを返却するか |
特にControllerのテスト対象は、ソースコードとテストコードを見て分かる通り、リクエストのマッピングの妥当性だけではなく、リクエストパラメータのバリデーション定義が期待通り動作するかや、チェックが正しいタイミング(ユースケース)で実行されるかなど検証内容が複雑かつ多岐に渡ります。 単純にデータを取得するだけの正常系のユースケースは後々の結合試験で確認できますので、Controllerの単体テストでは、境界値試験など含め、リクエストパラメータの異常系バリエーションを充実させて検証した方が良いでしょう。 Controllerの設定誤りはセキュリティホールに直結しますので、各実装が少なくとも一度はテストパスすることを推奨します。
注釈
SpringMVCにおける入力チェックの基本は TERASOLUNAのガイドライン 入力チェック や、 RESTful Web Serviceにおける入力エラー例外のハンドリング実装 を適宜参照してください。
これまで、Repository、Service、Controllerの単体テストコード実装を解説してきました。単体テストのコードだけでも実アプリケーションのコードよりもはるかにボリュームが多く、 あらゆる異常系のテストを網羅しようとするとかなり大変なことがお分りいただけたのではないでしょうか。前回の説明の再掲にはなりますが、繰り返しのテストが発生しがちなマイクロサービスですが、 初めから完璧にテストコードを整備しておく必要もありませんし、必要以上のテストコードの実装でかえって開発のアジリティを損なうようでは本末転倒です。 ただ、テストが疎かになるとせっかくの継続的インテグレーションも機能しません。開発のスピードと品質を両立するために、テスト計画やスコープ、検証の観点を明示的に策定しておくことが重要です。
効率的な単体テスト戦略策定のポイントとしては、
テスト品質は、これまでも見てきた通り、Serviceを覗き、単純なカバレッジのみでは評価できないので、ユースケース数に対するテストケースの割合、テストケースの定性的評価などを加えつつ評価するとよいでしょう。
次回は引き続き、SpringBootを使った結合試験のテストコードを実装し、解説していきます。
川畑 光平(KAWABATA Kohei) - NTTデータ 課長代理
金融機関システム業務アプリケーション開発・システム基盤担当を経て、現在はソフトウェア開発自動化関連の研究開発・推進に従事。
Red Hat Certified Engineer、Pivotal Certified Spring Professional、AWS Certified Solutions Architect Professional等の資格を持ち、アプリケーション基盤・クラウドなど様々な開発プロジェクト支援にも携わる。