はじめに
前回の記事では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のプロジェクトに、テストで使うH2
とDBUnit
、spring-test-dbunit
をdependenciesに追加します。
spring-test-dbunit
はSpringでDBUnitを使うなら必須といっても過言ではない便利なライブラリです。
簡単なアノテーションの記述で事前データの読み込みや事後検証などのDBUnitの機能が使えるようになります。
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/...
の方ではないことに注意してください。
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について
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指定についてはこちら |
また、本番では MySQL
を使うのでH2
をMySQL互換モードにするため、
url
に;MODE=MySQL;DATABASE_TO_LOWER=TRUE
を追記します。
互換モードについてはこちら
補足:起動時にDDL、DMLを読み込み初期化する
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は前回の記事で作成したものを指定しています)
以上で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形式の方が圧倒的にデータ加工がし易いです。
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);
// 検証:全4件であること(本当なら各レコードの中身も検証した方がよい)
Assertions.assertEquals(4, customers.size());
}
@Test
@DatabaseSetup("/testdata/CustomerServiceTest/init-data")
@ExpectedDatabase(value = "/testdata/CustomerServiceTest/after-create-data", assertionMode = DatabaseAssertionMode.NON_STRICT_UNORDERED) // テスト実行後に1件データが追加されていること
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
は以下のクラスを自前で作る必要があります。(最初から用意されていればいいのに)
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ファイルを準備します。
例えば、以下のパス指定の場合
@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テーブルに読み込むデータ
customer
id,name,age,address
1,ルーク・スカイウォーカー,19,タトゥイーン
2,レイア・オーガナ,19,オルデラン
3,ハン・ソロ,32,コレリア
4,ダース・ベイダー,41,タトゥイーン
table-ordering.txt
には読み込むテーブルの順序を記述しますが、今回はテスト対象のテーブルがcustomer
1つしかないので冗長に見えるかもしれません。
しかし複数のテーブルに初期データを準備する場合、特に外部キー(FOREIGN KEY)制約のあるテーブルが対象の場合は、子テーブルより先に親テーブルにデータを投入しないとエラーになります。
そのためテーブルのデータ投入の順番をtable-ordering.txt
で指定する必要があります。
まぁ、CSVファイルが1件なら要らないじゃん、というのはありますが・・
CRUDテストの検証用CSVファイルを作成
CSVファイルの読み込み方がわかったところで、今回のCRUDテストに必要なCSVデータをみてみます。
初期データ(init-data)
初期データは4件。どのテストメソッドも初期データはこれを読み込む想定。
id,name,age,address
1,ルーク・スカイウォーカー,19,タトゥイーン
2,レイア・オーガナ,19,オルデラン
3,ハン・ソロ,32,コレリア
4,ダース・ベイダー,41,タトゥイーン
登録後検証データ(after-create-data)
1件登録した後の検証用データ。5.ボバ・フェットが追加されている想定。
id,name,age,address
1,ルーク・スカイウォーカー,19,タトゥイーン
2,レイア・オーガナ,19,オルデラン
3,ハン・ソロ,32,コレリア
4,ダース・ベイダー,41,タトゥイーン
5,ボバ・フェット,32,カミーノ
更新後検証データ(after-update-data)
1件更新した後の検証用データ。4.ダース・ベイダーがアナキン・スカイウォーカーに更新されている想定。
id,name,age,address
1,ルーク・スカイウォーカー,19,タトゥイーン
2,レイア・オーガナ,19,オルデラン
3,ハン・ソロ,32,コレリア
4,アナキン・スカイウォーカー,41,タトゥイーン
削除後検証データ(after-delete-data)
1件削除した後の検証用データ。1.ルーク・スカイウォーカーが削除されている想定。
id,name,age,address
2,レイア・オーガナ,19,オルデラン
3,ハン・ソロ,32,コレリア
4,ダース・ベイダー,41,タトゥイーン
以上でテストデータの作成が完了です。
3. テストの実行
いよいよテストを実行してみます。
プロジェクトルートで以下のコマンドを実行するか、IDEから CustomerServiceTest
クラスのテストを実行します。
$ gradle test
タスクが完了したら build/reports/tests/test/classes/...html
として出力されているので確認してみます。
見事にオールグリーンのテスト結果となっています!!
最終的なファイル構成
参考までに今回作成したファイル群は以下の通りです。
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の強力な機能が使えるので是非使いこなしたいところです。