SpringBoot＋MyBatisのCRUDのテストをDBUnitで書いてみた

# はじめに
[前回の記事](https://qiita.com/kilvis/items/123a01d5d4fae641ad5b)ではDB操作を行うService層以降のクラスの実装を行いました。
今回はDBのCRUD操作を検証するためDBUnitを使ったテストケースを実装していきます。


# 開発環境
OS : macOS Catalina
IDE : IntelliJ Ultimate
Java : 11
Gradle : 6.6.1
SpringBoot : 2.3.4
JUnit : 5 (jupiter)
DBUnit : 2.7.0


# 1. 単体テスト用のデータベースの準備
前回の記事ではローカル開発用にDockerの`MySQL`をセットアップしました。
今回は単体テスト用に`H2`データベースを使います。

Java製でアプリに組み込んで使えて、外部接続の必要がないため単体テストを行うには最適のDBです。
MySQL固有の機能や関数は`H2`では使えませんが、CRUDが主なWebアプリ開発ではほとんど困ることはないと思います。

実際、アジャイルの開発現場では自動テストや継続的インテグレーションと非常に相性のいい`H2`を利用するケースが多いです。

## 1-1. 依存関係の追加
[前回作成したSpringBootのプロジェクト](https://qiita.com/kilvis/items/123a01d5d4fae641ad5b#1-2-springboot%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%81%AE%E4%BD%9C%E6%88%90)に、テストで使う`H2`と`DBUnit`、`spring-test-dbunit`をdependenciesに追加します。

`spring-test-dbunit`はSpringでDBUnitを使うなら必須といっても過言ではない便利なライブラリです。
簡単なアノテーションの記述で事前データの読み込みや事後検証などのDBUnitの機能が使えるようになります。

```build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.1.3'
    compile group: 'mysql', name: 'mysql-connector-java', version: '8.0.22'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
    testCompileOnly 'org.projectlombok:lombok'
    testAnnotationProcessor 'org.projectlombok:lombok'
    testRuntimeOnly 'com.h2database:h2' // 追加
    testCompile group: 'org.dbunit', name: 'dbunit', version: '2.7.0' // 追加
    testCompile group: 'com.github.springtestdbunit', name: 'spring-test-dbunit', version: '1.3.0' // 追加
}
```

## 1-2. テスト用のデータソースのを設定
テスト用に`H2`への接続設定を、`test/resources/application.yml`としてファイルを作成します。
**＊`main/resouces/...`の方ではないことに注意してください。**

```yaml:src/test/resouces/application.yml
spring:
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:mem:testdb;MODE=MySQL;DATABASE_TO_LOWER=TRUE  # H2DBをインメモリ、MySQL互換モードで利用
    username: sa
    password:
    initialization-mode: always  # 常に以下のschema,dataのSQLを使って初期化
    schema: classpath:schema.sql # test/resources/schema.sql
    data: classpath:data.sql     # test/resources/data.sql
    sql-script-encoding: utf-8
```

##### 補足：データベースURLについて
```yml
    url: jdbc:h2:mem:testdb;MODE=MySQL;DATABASE_TO_LOWER=TRUE  # H2DBをインメモリ、MySQL互換モードで利用
```
単体テストではデータの永続化をしないため、インメモリモード（＝DBシャットダウン時にデータも破棄）を利用するようURLで指定します。

|url指定|モード|概要|
|---|---|---|
|jdbc:h2:**mem:(DB名)**|インメモリ|DBのデータはメモリ上で保持するためシャットダウン時にデータが破棄される。この性質がJUnitと相性が良い。|
|jdbc:h2:**(dbファイルのパス)[:(DB名)]**|ファイル保存|パスの`(ファイル名).mv.db`に永続化したデータが保存される。例）jdbc:h2:./demodb|
[その他のデータベースURL指定についてはこちら](http://www.h2database.com/html/features.html#database_url)


また、本番では `MySQL` を使うので`H2`を**MySQL互換モード**にするため、
`url`に`;MODE=MySQL;DATABASE_TO_LOWER=TRUE`を追記します。
[互換モードについてはこちら](http://www.h2database.com/html/features.html#compatibility)

##### 補足：起動時にDDL、DMLを読み込み初期化する
```yml
    initialization-mode: always  # 常に以下のschema,dataのSQLを使って初期化
    schema: classpath:schema.sql # test/resources/schema.sql
    data: classpath:data.sql     # test/resources/data.sql
```
インメモリモードのDBは起動直後はテーブルも何もありません。
そのため`schema.sql`でDDLを、`data.sql`でDMLを指定し、`initialization-mode`に`always`を指定することで、起動時に毎回DDLとDMLを読み込みセットアップするようにします。
（DDLとDMLは[前回の記事で作成したもの](https://qiita.com/kilvis/items/123a01d5d4fae641ad5b#1-4-%E3%83%86%E3%83%BC%E3%83%96%E3%83%AB%E3%81%AE%E4%BD%9C%E6%88%90)を指定しています)

以上でJUnitテスト用の`H2`の接続設定は完了です。

最終的に、ローカル環境用とテスト用に作成した`application.yml`は以下のように配置します。

```
├── build.gradle
└── src
    ├── main
    |   ├── java
    |   └── resources
    |       └── application.yml  // 前回作ったローカル環境用のMySQLの設定
    └── test
        ├── java
        └── resources
            └── application.yml  // ＊今回作ったJUnitテスト用のH2DBの設定＊
```

# 2. Serviceのテスト
DBを絡めたService層のテストケースでは、主に以下の観点のテストを実施します。

- Serviceの各メソッドで期待通りのDBのデータを操作できること
- Serviceのメソッド単位でトランザクション制御（commit/rollback）ができていること

これらを確認するために、各テストメソッドの実行前にDBにテストデータの準備を行い、実行後にはデータが期待値通りの状態であるかを検証します。

## 2-1. DBUnitを使ったテストケースのサンプル
CRUD処理のテストケースのサンプルは以下の通りです。

DBUnitへ投入するテストデータや検証データに**CSVファイルを扱えるようにしている**点もポイントです。
デフォルトのXML形式よりCSV形式の方が圧倒的にデータ加工がし易いです。

```java:
package com.example.dbunitdemo.domain.service;

import com.example.dbunitdemo.domain.model.Customer;
import com.example.dbunitdemo.dataset.CsvDataSetLoader;
import com.github.springtestdbunit.TransactionDbUnitTestExecutionListener;
import com.github.springtestdbunit.annotation.DatabaseSetup;
import com.github.springtestdbunit.annotation.DbUnitConfiguration;
import com.github.springtestdbunit.annotation.ExpectedDatabase;
import com.github.springtestdbunit.assertion.DatabaseAssertionMode;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;

@Slf4j
@SpringBootTest
@DbUnitConfiguration(dataSetLoader = CsvDataSetLoader.class) // DBUnitでCSVファイルを使えるよう指定。＊CsvDataSetLoaderクラスは自作します（後述）
@TestExecutionListeners({
        DependencyInjectionTestExecutionListener.class, // このテストクラスでDIを使えるように指定
        TransactionDbUnitTestExecutionListener.class // @DatabaseSetupや＠ExpectedDatabaseなどを使えるように指定
})
@Transactional // @DatabaseSetupで投入するデータをテスト処理と同じトランザクション制御とする。（テスト後に投入データもロールバックできるように）
class CustomerServiceTest {

  @Autowired
  private CustomerService customerService;

  @Test
  @DatabaseSetup("/testdata/CustomerServiceTest/init-data") // テスト実行前に初期データを投入
  @ExpectedDatabase(value = "/testdata/CustomerServiceTest/init-data", assertionMode = DatabaseAssertionMode.NON_STRICT_UNORDERED) // テスト実行後のデータ検証（初期データのままであること）
  void findByPk() {
    // id=3のデータの期待値
    Customer expect = Customer.builder()
            .id(3L)
            .name("ハン・ソロ")
            .age(32)
            .address("コレリア")
            .build();
    // id=3の検索結果
    Customer actual = customerService.findByPk(3L);
    // 検証：期待値と一致していること
    Assertions.assertEquals(expect, actual);
  }

  @Test
  @DatabaseSetup("/testdata/CustomerServiceTest/init-data")
  @ExpectedDatabase(value = "/testdata/CustomerServiceTest/init-data", assertionMode = DatabaseAssertionMode.NON_STRICT_UNORDERED)
  void findAll() {
    // 検索結果
    List<Customer> customers = customerService.findAll();
    log.info("actual customers = {}", customers);
    // 検証：全４件であること（本当なら各レコードの中身も検証した方がよい）
    Assertions.assertEquals(4, customers.size());
  }

  @Test
  @DatabaseSetup("/testdata/CustomerServiceTest/init-data")
  @ExpectedDatabase(value = "/testdata/CustomerServiceTest/after-create-data", assertionMode = DatabaseAssertionMode.NON_STRICT_UNORDERED) // テスト実行後に１件データが追加されていること
  void create() {
    // 登録するデータを準備
    Customer newCustomer = Customer.builder()
            .name("ボバ・フェット")
            .age(32)
            .address("カミーノ")
            .build();
    // 登録実行
    int createdCount = customerService.create(newCustomer);
    // 検証：1件の追加に成功していること
    Assertions.assertEquals(1, createdCount);
    // 検証：登録に使ったオブジェクトに採番されたid=5が設定されていること
    Assertions.assertEquals(5, newCustomer.getId());
  }

  @Test
  @DatabaseSetup("/testdata/CustomerServiceTest/init-data")
  @ExpectedDatabase(value = "/testdata/CustomerServiceTest/after-update-data", assertionMode = DatabaseAssertionMode.NON_STRICT_UNORDERED) // テスト実行後に1件データが更新されていること
  void update() {
    // 更新するデータを準備
    Customer updateCustomer = Customer.builder()
            .id(4L)
            .name("アナキン・スカイウォーカー")
            .age(41)
            .address("タトゥイーン")
            .build();
    // 更新実行
    int updatedCount = customerService.update(updateCustomer);
    // 検証：1件の更新に成功していること
    Assertions.assertEquals(1, updatedCount);
  }

  @Test
  @DatabaseSetup("/testdata/CustomerServiceTest/init-data")
  @ExpectedDatabase(value = "/testdata/CustomerServiceTest/after-delete-data", assertionMode = DatabaseAssertionMode.NON_STRICT_UNORDERED) // テスト実行後に1件データが削除されていること
  void delete() {
    // id=1のレコードを削除
    int deletedCount = customerService.delete(1L);
    // 検証：1件の削除に成功していること
    Assertions.assertEquals(1, deletedCount);
  }
}
```

クラスに指定されているアノテーションはSpringBootで`JUnit5`+`DBUnit`+`spring-test-dbunit`を使う場合のお決まりの記述として覚えてしまいましょう。（`@SpringBootTest`、`@DbUnitConfiguration`、`@TestExecutionListeners`、`@Transactional`）

その中の`@DbUnitConfiguration(dataSetLoader = CsvDataSetLoader.java)`でCSVファイルを読み込めるようにしていますが、`CsvDataSetLoader.java`は自前で実装する必要があるため、次の2-2で説明します。

そして各テストメソッドに指定されている`@DatabaseSetup`と`@ExpectedDatabase`が、事前データの投入と事後データの検証を行うデータ定義ファイル（今回はCSVファイル）を指定するアノテーションです。

## 2-2. CSVファイルを利用するため`CsvDataSetLoader`を作成する
デフォルトではXMLファイル形式で事前データや事後検証用のデータを記述しますが、CSV形式の方がデータの作成がしやすいです。
そこで`@DbUnitConfiguration`に `CsvDataSetLoader.java`を指定して**CSVファイルを利用できるように**します。

ただし`CsvDataSetLoader.java`は**以下のクラスを自前で作る**必要があります。~~（最初から用意されていればいいのに）~~

```java:CsvDataSetLoader.java
package com.example.dbunitdemo.dataset;

import com.github.springtestdbunit.dataset.AbstractDataSetLoader;
import org.dbunit.dataset.IDataSet;
import org.dbunit.dataset.csv.CsvDataSet;
import org.springframework.core.io.Resource;

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

## 2-3. CSVファイルを作成する
各テストメソッドの初期データや事後の検証用に読み込むCSVファイルを準備します。
例えば、以下のパス指定の場合

```java
@DatabaseSetup("/testdata/CustomerServiceTest/init-data")
```

`src/test/resources`配下の`/testdata/CustomerServiceTest/init-data`ディレクトリ内に

- table-ordering.txt
- [テーブル名].csv (複数配置可)

を配置して読み込ませます。

```
src
└── test
    ├── java
    └── resources
        └── testdata
            └── CustomerServiceTest
                └── init-data
                    ├── table-ordering.txt // テーブルの読み込み順
                    └── customer.csv       // customerテーブルに読み込むデータ
```

```text:table-ordering.txt
customer
```

```csv:customer.csv
id,name,age,address
1,ルーク・スカイウォーカー,19,タトゥイーン
2,レイア・オーガナ,19,オルデラン
3,ハン・ソロ,32,コレリア
4,ダース・ベイダー,41,タトゥイーン
```

`table-ordering.txt`には読み込むテーブルの順序を記述しますが、今回はテスト対象のテーブルが`customer`１つしかないので冗長に見えるかもしれません。
しかし複数のテーブルに初期データを準備する場合、**特に外部キー(FOREIGN KEY)制約のあるテーブルが対象の場合**は、子テーブルより先に親テーブルにデータを投入しないとエラーになります。

そのためテーブルのデータ投入の順番を`table-ordering.txt`で指定する必要があります。
~~まぁ、CSVファイルが１件なら要らないじゃん、というのはありますが・・~~


### CRUDテストの検証用CSVファイルを作成
CSVファイルの読み込み方がわかったところで、今回のCRUDテストに必要なCSVデータをみてみます。

#### 初期データ（init-data）
初期データは４件。どのテストメソッドも初期データはこれを読み込む想定。

```csv:customer.csv
id,name,age,address
1,ルーク・スカイウォーカー,19,タトゥイーン
2,レイア・オーガナ,19,オルデラン
3,ハン・ソロ,32,コレリア
4,ダース・ベイダー,41,タトゥイーン
```

#### 登録後検証データ（after-create-data）
１件登録した後の検証用データ。5.ボバ・フェットが追加されている想定。

```csv:customer.csv
id,name,age,address
1,ルーク・スカイウォーカー,19,タトゥイーン
2,レイア・オーガナ,19,オルデラン
3,ハン・ソロ,32,コレリア
4,ダース・ベイダー,41,タトゥイーン
5,ボバ・フェット,32,カミーノ
```

#### 更新後検証データ（after-update-data）
１件更新した後の検証用データ。4.ダース・ベイダーがアナキン・スカイウォーカーに更新されている想定。

```csv:customer.csv
id,name,age,address
1,ルーク・スカイウォーカー,19,タトゥイーン
2,レイア・オーガナ,19,オルデラン
3,ハン・ソロ,32,コレリア
4,アナキン・スカイウォーカー,41,タトゥイーン
```

#### 削除後検証データ（after-delete-data）
１件削除した後の検証用データ。1.ルーク・スカイウォーカーが削除されている想定。

```csv:customer.csv
id,name,age,address
2,レイア・オーガナ,19,オルデラン
3,ハン・ソロ,32,コレリア
4,ダース・ベイダー,41,タトゥイーン
```

以上でテストデータの作成が完了です。

# 3. テストの実行
いよいよテストを実行してみます。
プロジェクトルートで以下のコマンドを実行するか、IDEから `CustomerServiceTest` クラスのテストを実行します。

```terminal
$ gradle test
```
タスクが完了したら `build/reports/tests/test/classes/...html`として出力されているので確認してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/430093/9c795d06-4047-2f06-24da-a107f7ba82c8.png)

見事にオールグリーンのテスト結果となっています！！

### 最終的なファイル構成
参考までに今回作成したファイル群は以下の通りです。
CSVファイルのの読み込みにうまくいかないなどの場合は、以下の構成になっているか確認してみてください。

```
src
├── main // 前回作成
└── test
    ├── java
    │   └── com
    │       └── example
    │           └── dbunitdemo
    │               ├── DbunitDemoApplicationTests.java
    │               ├── dataset
    │               │   └── CsvDataSetLoader.java
    │               └── domain
    │                   └── service
    │                       └── CustomerServiceTest.java
    └── resources
        └── testdata
            └── CustomerServiceTest
                ├── after-create-data      // 登録後検証データ
                │   ├── table-ordering.txt
                │   └── customer.csv
                ├── after-delete-data      // 削除後検証データ
                │   ├── table-ordering.txt
                │   └── customer.csv
                ├── after-update-data      // 更新後検証データ
                │   ├── table-ordering.txt
                │   └── customer.csv
                └── init-data              // 初期データ
                    ├── table-ordering.txt
                    └── customer.csv
```


# あとがき
以前の記事から合わせると、ようやくJUnit5でController、Service（Repository）の実装サンプルができました。
特にDBUnitとspring-test-dbunitの組み合わせは手軽にDBUnitの強力な機能が使えるので是非使いこなしたいところです。