16
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-11-26

はじめに

前回の記事では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のプロジェクトに、テストで使うH2DBUnitspring-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/...の方ではないことに注意してください。

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について
    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 を使うのでH2MySQL互換モードにするため、
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-modealwaysを指定することで、起動時に毎回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形式の方がデータの作成がしやすいです。
そこで@DbUnitConfigurationCsvDataSetLoader.javaを指定してCSVファイルを利用できるようにします。

ただしCsvDataSetLoader.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ファイルを準備します。
例えば、以下のパス指定の場合

@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テーブルに読み込むデータ
table-ordering.txt
customer
customer.csv
id,name,age,address
1,ルーク・スカイウォーカー,19,タトゥイーン
2,レイア・オーガナ,19,オルデラン
3,ハン・ソロ,32,コレリア
4,ダース・ベイダー,41,タトゥイーン

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

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

CRUDテストの検証用CSVファイルを作成

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

初期データ(init-data)

初期データは4件。どのテストメソッドも初期データはこれを読み込む想定。

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

登録後検証データ(after-create-data)

1件登録した後の検証用データ。5.ボバ・フェットが追加されている想定。

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

更新後検証データ(after-update-data)

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

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

削除後検証データ(after-delete-data)

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

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

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

3. テストの実行

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

$ gradle test

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

image.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の強力な機能が使えるので是非使いこなしたいところです。

16
27
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?