はじめまして(前回のブログを読んでくださった方は改めまして)。プロダクト開発部アプリケーションエンジニアの中村と申します。
現在、「Reader Store(運営:株式会社ソニー・ミュージックエンタテインメント)」のシステム開発を主な業務として日々取り組んでいます。
今回はReader Storeの開発中に行ったDB関連のユニットテストの改善の話をしていきます。
現状のテスト環境と問題点
Reader Storeではこれまでユニットテストで使用するDBを以下のように構築していました。
- MySQLのコンテナを起動
- DDLおよびテストデータのDMLが定義されたSQLファイルを読み込む
上記の処理をテスト起動時に行い、各テストでDMLに定義されたデータを使用してテストが行われておりました。
この場合、いずれかのテストでデータの追加・更新・削除を行なった場合に他のテストに影響を及ぼす可能性があるため、テスタビリティがよくないという問題がありました。
また、INSERTやUPDATEのテストを行う場合、結果の確認方法としてテストコード内でSELECT文を実行して取得した結果をAssertionしておりました。
それにより、テストコード内でテスト対象外の処理を実行しているという問題や単純にテストコードが肥大化してしまうという問題がありました。
上記の問題を解決するために、今回はDatabase Riderというライブラリを導入することにしました。
Database Rider
Database Riderとは
Database Riderは、DBUnitをよりJUnitのように記述できるようにしてデータベースのテストを簡単に書けるようにすることを目的としたライブラリになります。
公式リポジトリ:https://github.com/database-rider/database-rider
Javaでは元々DB Unitというデータベースのテストを行うためのフレームワークがあります。
データベースで使用するデータの定義をcsv, xls, xmlのいずれかで定義し、テストコード内でそのファイルを指定することでデータベースにテスト用のデータをロードできます。
また、DML実行後の結果となるデータをファイルで定義して、それを実際のデータベースのデータとAssertionするといったこともできます。
しかし、それをするためにはコードを複数行書く必要がありました。
Database Riderでは、データのロードや結果のAssertionをアノテーションを使用して1行で済ませることができ、より使いやすいものになりました。
また、ファイルのフォーマットについて、上記に加えてjsonやyamlなどモダンなフォーマットもサポートしています。
Database Riderの使い方
Database Riderの使い方について簡単に説明させていただきます。
※今回はSpringBoot + JUnit5
を使用している前提となります。
依存関係の追加
使用しているMavenもしくはGradleの依存関係に以下を追加します。
testCompile "com.github.database-rider:rider-junit5:{バージョン}"
テストクラスの定義
Database Riderを使用してテストしたいクラスには@DBRider
および @DBUnit
を付与します。
@DBRider @DBUnit @SpringBootTest public class SampleRepositoryTest { ... }
テストメソッドの定義
Database Riderを使用してテストしたいメソッドには、テストの目的に応じてアノテーションを付与していきます。
SELECT系のテスト
SELECT系のクエリのテストでは、まずは検索したいデータをファイルで定義します。
例えばyaml形式で定義する場合には、以下のように定義します。
sample_table: - column_a: "aaa" ← 1レコード目 column_b: "bbb" - column_a: "ccc" ← 2レコード目 column_b: "ddd" ※他のテーブルも使いたい場合は下に同様に定義する
なお、データを定義したファイルは、xxx/test/resources/
配下に配置する必要があります。(左記ディレクトリ配下にディレクトリを追加することは可)
例えば、以下のようなディレクトリ構成でテストごとのデータファイルを管理するなどです。
xxx/test/resources/datasets/{クラス名}/{メソッド名}/{テストケース}/init.yml
データ定義ファイルの用意が完了したら、テストメソッドでそのファイルをロードするための定義をします。
データをロードするためには@DataSet
をメソッドに付与します。
@DataSet
の引数には、データ定義ファイルのパスを指定します。(xxx/test/resources/
までのパスは省略してそれより配下のパスを記述)
@Test @DisplayName("データ検索テスト") @DataSet({"/datasets/SampleRepository/getData/normal/init.yml"}) public void getData_normal() throws Exception { List<Sample> expected = List.of(new Sample("aaa", "bbb"), new Sample("ccc", "ddd")); List<Sample> actual = sampleRepository.findAll(); assertEquals(expected, actual); }
これにより、データベースに必要なデータをロードできます。
また、ロード時には対象のテーブルに元々入っていたデータはクリアされるため、別のテストのデータと混ざることはありません。
なお、@DataSet
の引数で指定するファイルパスは配列になっています。
例えばテストで複数のテーブルを使用する際に、各テストにおいて同じデータで良いテーブルとテストごとにデータを変えたいテーブルがあったとします。
そのような場合は、共通のテーブルデータを定義したファイルとテストごとのテーブルデータを定義したファイルを分けて複数のファイルをロードするということもできます。
@Test @DisplayName("データ検索テスト") @DataSet({"/datasets/SampleRepository/getData/common_init.yml", "/datasets/SampleRepository/getData/normal/init.yml"}) public void getData_normal() throws Exception { List<Sample> expected = List.of(new Sample("aaa", "bbb"), new Sample("ccc", "ddd")); List<Sample> actual = sampleRepository.findAll(); assertEquals(expected, actual); }
INSERT系のテスト
INSERT系のクエリのテストでは、実行結果と一致するデータをファイルで定義します。
ファイルはSELECTと同じように用意します。
データ定義ファイルの用意が完了したら、テストメソッドの定義をします。
INSERTでは実行結果のAssertionをするために@ExpectedDataSet
を使用します。
@ExpectedDataSet
の引数には、データ定義ファイルのパスを指定します。
テーブルにはよくレコードの追加日時や更新日時をカラムに持たせることがあります。
そのようなカラムはテスト実行ごとに値が変わるため、正しくAssertionできません。
そのような場合は、@ExpectedDataSet
の引数にignoreCols
でAssertionの対象外にしたいカラム名を指定します。
なお、別のテストのデータが残っていると正しくテスト結果の確認ができないため、@DataSet
の引数にcleanBefore = true
を指定してテーブルのデータをクリアできます。
ただし、cleanBefore = true
では全てのテーブルのデータがクリアされてしまうので、クリアしたくないテーブルがある場合はskipCleaningFor
でテーブル名を指定してください。
@Test @DisplayName("データ登録テスト") @DataSet(cleanBefore = true, skipCleaningFor = {"other_table"}) @ExpectedDataSet(value = "/datasets/SampleService/registerData/normal/expected.yml", ignoreCols = {"insert_at", "update_at"}) public void registerData_normal() throws Exception { sampleRepository.insert(new Sample("aaa", "bbb")); }
@ExpectedDataSet
はデフォルトでは全レコードを完全一致でチェックするため、@DataSet(cleanBefore = true)
で事前にデータをクリアするやり方を説明しました。
それとは別に、@ExpectedDataSet
の引数にcompareOperation = CompareOperation.CONTAINS
を指定する方法もあります。
この引数を指定することで、データをクリアせずとも必要なデータが存在することの確認だけテストできます。
このやり方であれば、他のテストでテーブルのデータがどうなっているのかを気にする必要はありません。(ただし、INSERTするデータの主キーが既存データと被らないように気をつけてください)
@Test @DisplayName("データ登録テスト") @ExpectedDataSet(value = "/datasets/SampleService/registerData/normal/expected.yml", compareOperation = CompareOperation.CONTAINS) public void registerData_normal() throws Exception { sampleRepository.insert(new Sample("aaa", "bbb")); }
UPDATE系のテスト
UPDATE系のクエリのテストでは、更新対象のデータと実行結果のデータをそれぞれファイルで定義します。
ファイルはこれまでと同じように用意します。
データ定義ファイルの用意が完了したら、テストメソッドの定義をします。
UPDATEではデータをロードするために@DataSet
を使用し、実行結果のAssertionをするために@ExpectedDataSet
を使用します。
@Test @DisplayName("データ更新テスト") @DataSet("/datasets/SampleService/updateData/normal/init.yml") @ExpectedDataSet("/datasets/SampleService/updateData/normal/expected.yml") public void updateData_normal() throws Exception { sampleRepository.update(new Sample("aaa", "ccc")); }
導入時に気をつけたところ・ハマったところ
今回Database Riderを導入するにあたり、既存のテストを一度に置き換えるのは難しいため、まずは新しく実装するテストだけ使用するようにしました。
しかし、既存のテストとデータベースを共有してしまうと、Database Riderによってテーブルがクリアされることにより、既存のテストが動かなってしまいます。
それを回避するために、今回はDatabase Riderを使用するテストのためにデータベースを分けるという対応にしました。
具体的なやり方ですが、テストで使用するデータベースは上で記載したとおりMySQLのコンテナを実行時に立ち上げています。
コンテナで使用するポートはテスト用のconfigファイルで定義しているため、新たなconfigファイルを作成して別のポート番号を指定するようにしました。
そして、Database Riderを使用するテストでProfileを新しく作成したconfigを向くように指定することで、テストごとに使用するデータベースを変えることが実現できました。
それによって、既存のテストには影響を与えずに導入できました。
しかし、Profileを分けたことにより別の問題が発生しました。
新しく作ったテストとControllerのテストをまとめて実行(gradlew test
)すると、エラーが起きるようになりました。
どうも使用しているMockライブラリが悪さをしているようなのですが、解決方法がわからなかったため、今回はテスト実行用のGradleタスクを作成し、パッケージごとに分けて実行できるようにしました。
Profileを分けただけでエラーになるとは思わず、新たなテストの課題が出来てしまったのは残念ですが、いずれ解決していきたいです。
まとめ
新たなライブラリを導入することで、データベースのテストをより書きやすくできました。
これでチームのテスト作成の効率化に繋がれば幸いです。
また、今後もテスト環境の改善を進めてテストを充実させて、サービスの品質も高めていきたいです。
今回ご説明した機能はDatabase Riderの基本的なものと一部のオプションになります。
他にも様々な機能がありますので、Database Riderを使ってみたいという方はご自身のテスト環境に合った機能を見つけてみてください。
この記事の内容が、Javaのテストを作成する方の何かしらの助けになれば幸いです。