SpringBoot2 + JUnit5 + DBUnitでREST API(POST)のブラックボックステスト

はじめに
-
REST API(POST)のテストをController,Service,Daoのレイヤ単位ではなく機能単位でJUnitでテストするために必要な準備、テストケースの作成方法を記載します。

※API単位の試験ならPostman叩いて実行すればいいじゃん！ってなるのですが、
以下の理由によりJUnitで行っています。

- レスポンスのJSON、実行後のDBの状態、機能内で呼び出している外部APIへのリクエストの内容をアサーションしたい
- テストデータを自動登録して実行後は元の状態に戻したい
- 変更が発生しても容易に回帰テストできるようにしたい
- カバレッジを取得できるようにしたい

クラス構成
-
今回のテスト対象機能のクラス構成は下図の通りです。
![クラス構成.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1141594/f472bbd2-67af-2197-74f9-5793dcf2211b.png)
JUnitからRestControllerにリクエストを送信し、実行結果を確認します。
外部API（緑色の部分）はモック化します。
DBはローカルに構築したDBを使用しました。DBUnitでテストデータの登録/検証を行います。

環境
-
- SpringBoot 2.3
- MyBatis 3
- JUnit 5
- Mockit 3.7.7
- DBUnit 2.7.0
- spring-ws-test（SOAPサーバをモック化するために使用）
- Oracle 19c

必要なライブラリの追加
-
spring-boot-starter-testにDBUnit、spring-ws-testが含まれていないため依存関係に追加します。

```groovy:build.gradle
dependencies {
  testImplementation group: 'org.dbunit', name: 'dbunit', version: '2.7.0'
  testImplementation group: 'org.springframework.ws', name: 'spring-ws-test', version: '2.0.2.RELEASE'
}  
```
```XML:pom.xml
<dependencies> 
    <dependency>
        <groupId>org.dbunit</groupId>
        <artifactId>dbunit</artifactId>
        <version>2.7.0</version>
        <scope>test</scope>
    </dependency> 
    <!-- Soapサーバモック化するために使用 -->
    <dependency>
        <groupId>org.springframework.ws</groupId>
        <artifactId>spring-ws-test</artifactId>
        <scope>test</scope>
    </dependency> 
</dependencies> 
```
DBUnitを使うための設定クラス作成
-
以下の記事を参考にさせていただき、「テスト用DB設定クラス」「Excel用ローダークラス」を作成しました。
[Spring Boot + JUnit5 + DBUnitでExcelをテストデータとしたUTを行う方法](https://qiita.com/asachan/items/9d8cbb1965633531c868)

```java:DBConfig.java
@Configuration
public class DBConfig {
   /*
    * テスト用のDBコネクションファクトリのセットアップ
    */
    @Bean
    public DatabaseDataSourceConnectionFactoryBean dbUnitDatabaseConnection(final DataSource dataSource) {
        final DatabaseDataSourceConnectionFactoryBean connectionFactory = 
            new DatabaseDataSourceConnectionFactoryBean();
        connectionFactory.setDataSource(dataSource);
        connectionFactory.setSchema("TESTSCHEMA"); //ORACLEの場合スキーマ設定必須
        return connectionFactory;
    }
}
```
```java:XlsDataSetLoader.java
public class XlsDataSetLoader extends AbstractDataSetLoader {
   /*
    * DBUnitのデータのセットアップをExcelから行うための設定
    */
    @Override
    protected IDataSet createDataSet(Resource resource) throws Exception {
        try (InputStream in = resource.getInputStream()) {
            return new XlsDataSet(in);
        }
    }
}
```
テストデータの準備
-
#### JSONファイル
テストケース毎に以下のJSONファイルを作成します。
　① テスト対象のAPIに送信するリクエスト
　② テスト対象のAPIのレスポンス期待値
　③ 外部API(REST)に送信するリクエスト期待値　　　　　※SOAPの場合はXMLファイル
　④ 外部API(REST)のモックが返却する固定のレスポンス　※SOAPの場合はXMLファイル

テストケースにJSON文字列を記述するとコード量が膨大になるので、外出ししてテストケース内で読み込む方針としました。
それぞれ以下の要領で作成します。

```json:case001req.json
{
  "user" : {
    "id" : "001",
    "name" : "さんぷる　太郎",
    "gender" : "1",
    "age" : 25 
  }
}
```

#### DBUnitのExcelデータシートの作成
テストデータ用のExcelデータシートを「セットアップデータ.xlsx」、
実行後の期待値のExcelデータシートを「期待値テーブルデータ.xlsx」というファイル名で今回は作成します。
データシートの作成方法はDBUnitの基本的な使い方の説明となるので省略します。

テストケースの作成
-
以下のテストケースではMockRestServiceServerで外部API(REST)をモック化し、MockMvcでテスト対象のAPIのURIにリクエストを送信し実行結果を検証しています。

```java:SampleControllerTest.java
@SpringBootTest // ブラックボックステストのため通常のアプリケーション起動時と同様にコンポーネントスキャンし、コンフィグレーションを自動検出する
@DbUnitConfiguration(dataSetLoader = XlsDataSetLoader.class) // 作成した「Excel用ローダークラス」を指定
@TestExecutionListeners({
  DependencyInjectionTestExecutionListener.class, // SpringのDIをテストで利用するための指定
  TransactionDbUnitTestExecutionListener.class, // DBUnitを利用するための指定
  MockitoTestExecutionListener.class // @MockBeanでモック化したクラスをDIさせるための指定
})
public class SampleControllerTest{

    private MockMvc mockMvc; // テスト対象のControllerにリクエストを送信するためのクラス
    private MockRestServiceServer restServer; // 外部APIをモック化するためのクラス	
    private String testCaseName; // テスト対象のメソッド名を格納する変数
    @Value("${externalApi.uri}")
    private String externalApiUri; // 外部APIのURI

    @BeforeEach
    public void setUp(TestInfo testInfo,
            @Autowired WebApplicationContext webApplicationContext,
            @Autowired RestTemplate restTemplate) {
        testCaseName = testInfo.getTestMethod().get().getName();  // テストケース名の取得
        restServer = MockRestServiceServer.bindTo(restTemplate).build();  // MockRestServiceServerのセットアップ
        mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); // MockMVCのセットアップ。デプロイ時とほぼ同じ状態でテストするためwebAppContextSetupを指定
    }
    
    @Test 
    @DisplayName("正常ケース") // JUnitの実行モニタに表示するテストケース名
    @DatabaseSetup("セットアップデータ.xlsx") // @DatabaseSetupにより、テスト開始時にDBUnitによりテストデータが登録される
    @ExpectedDatabase(value="期待値テーブルデータ.xlsx", assertionMode=DatabaseAssertionMode.NON_STRICT_UNORDERED) // @ExpectedDatabaseにより、実行後のテーブルがデータシートに記載したデータ通りであるかDBUnitにより検証される。
    @Transactional // @Transactionalを指定することで、テスト終了時に@DatabaseSetupでセットアップしたテストデータおよびテスト対象の機能で登録したデータがロールバックされる
    void case0010() {
        // リクエスト／レスポンスのJSONロード
        String reqJson = StreamUtils.copyToString(new ClassPathResource(testCaseName + "req.json").getInputStream(), StandardCharsets.UTF_8); // テスト対象のAPIに送信するリクエスト
        String resJson = StreamUtils.copyToString(new ClassPathResource(testCaseName + "res.json").getInputStream(), StandardCharsets.UTF_8); // テスト対象のAPIのレスポンス期待値
        String externalApiReq = StreamUtils.copyToString(new ClassPathResource(testCaseName + "externalApiReq.json").getInputStream(), StandardCharsets.UTF_8); // 外部API(REST)に送信するリクエスト期待値
        String externalApiRes = StreamUtils.copyToString(new ClassPathResource(testCaseName + "externalApiRes.json").getInputStream(), StandardCharsets.UTF_8); // 外部API(REST)のモックが返却する固定のレスポンス
        
        // 外部APIのモック定義
        restServer.expect(ExpectedCount.times(1), requestTo(externalApiUri)) // RestTemplateが実行されたタイミングで行われる検証。指定されたURIに一致すること
                .andExpect(MockRestRequestMatchers.method(HttpMethod.POST))  // 同様にHTTPメソッドの検証。POSTメソッドであること
                .andExpect(MockRestRequestMatchers.content().json(externalApiReq)) // 同様にリクエストボディの検証。期待値のJSONと一致すること
                .andRespond(withSuccess(externalApiRes, MediaType.APPLICATION_JSON)); // 検証がすべてOKの場合、引数に指定したレスポンスが返却される

        // 実行
        MvcResult result= this.mockMvc.perform(MockMvcRequestBuilders
                .post("/example") // postで送信するためpostメソッドを使用し、URIを指定
                .header("x-api-key", "QAWSEDRFTGYHUJIKOLP") // リクエストヘッダにセットする場合。キー、値の形式で設定
                .content(reqJson) // リクエストボディの設定
                .contentType(MediaType.APPLICATION_JSON)) // リクエストのContent-Typeの指定
                .andExpect(status().is(HttpStatus.OK.value())) // ここから実行結果の検証。HTTPステータスが200であること
                .andExpect(content().contentType(MediaType.APPLICATION_JSON)) // Content-Typeが"Application/json"であること
                .andReturn(); // 後続でレスポンスボディの検証をするために、andReturnメソッドでレスポンスを取得する

        // レスポンスのJSON検証
        String actual = result.getResponse().getContentAsString(StandardCharsets.UTF_8); // レスポンスボディをUTF-8で取得
        JSONAssert.assertEquals(resJson, actual, JSONCompareMode.STRICT); // 期待値のJSONと一致しているか比較
        // 定義したモックがすべて実行されたか検証
        restServer.verify(); 
    }
}
```

ソースコメントで記載しきれなかった部分について補足します。
`@DatabaseSetup`
テストケースのメソッドに対して付与していますが、クラスに付与することも可能です。
全部のテストケースで同じテストデータを使う場合はクラスに付与したほうが実行速度も上がります。
`@Transactional` 
こちらもクラスに付与することも可能です。
各テストケースでテストデータが干渉しない場合はクラスに付与してもよいかと思います。
` @ExpectedDatabase(... assertionMode=DatabaseAssertionMode.NON_STRICT_UNORDERED)`
assertionModeは比較の厳密度の指定です。以下3種類あります。

| DatabaseAssertionMode      |説明                                                                   |
|:---------------------------|:----------------------------------------------------------------------|
| DEFAULT (未指定時)         |全テーブルの全カラムの値が一致するか比較する　                              |
| NON_STRICT                 |データシートに無いテーブル、カラムは検証対象外とする                      |
| NON_STRICT_UNORDERED       |データシートに無いテーブル、カラムは検証対象外とする<br>行の順番も無視する |


` JSONAssert.assertEquals(resJson, actual, JSONCompareMode.STRICT); `
JSONAssertはJSON文字列の同等性を検証するので、空白やタブ、改行、項目の定義順は無視してくれます。
JSONCompareModeで配列の比較の厳密度を指定できます。

| JSONCompareMode    |説明                             |
|:-------------------|:--------------------------------|
| STRICT             | 配列の順番を厳密に比較する       |
| LENIENT            | 配列の順番を無視する             |
他にNON_EXTENSIBLE、STRICT_ORDERがありますが、
特定のノードのみ無視する、正規表現とマッチしていればOKとするなどJSONComparatorを拡張する場合に使用します。
<br>

######SOAPをモック化する場合のテストケース
SOAPの場合も要領は同じですが、SOAPクライアントの実装方法とあわせて別途記事にします。

さいごに
-
レイヤ単位のテストケースのサンプルは結構ネットに載っているんですが、
機能単位のサンプルはあまり載っていなかったので記録として記事を書きました。

ただJUnitでテストすること自体コストが掛かるので、
複雑な機能や変更が頻繁に入りそうな機能だけJUnitでしっかりテストするなど、ポイントで採用すれば効果はあるかと思いました。