Database Rider で Kotlin(Spring)の DB テストを簡潔に書く
概要
本記事では、Java のライブラリである Database Rider について紹介します。
Database Rider を用いることで、DB テストを直感的かつ簡潔に記述可能です。
本記事では、Database Rider を簡単に紹介した後、Kotlin で導入し実践します。
Database Rider とは
Database Rider とは、Java や Kotlin で DB テストを簡単に実施するためのライブラリです。
Database Rider を使えば以下の観点から DB テストが非常に容易になります。
メリット | 内容 |
---|---|
アノテーションを利用した直感的な記述 | JUnit を拡張するように記述可能 |
テストデータ(yml、csv、xml、etc)の import と export 機能 |
@DataSet と@ExpectedDataSet でファイルの読み込み書き出しが容易 |
DB 内におけるテーブルの直接比較 |
@ExpectedDataSet でアプリケーション側ではなく、DB そのもので比較可能 |
次項から Database Rider 導入しつつ、メリットを確認します。
導入方法
導入時には gradle、dbunit.yml の設定が必要です。
また、テストコードに記述するアノテーションについても紹介します。
gradle
build.gradle.kts に Database Rider(database-rider
)と、依存ライブラリを記述します。
本記事では、spring を利用し、JUnit5 の拡張を利用するので、rider-core
以外に 2 つのライブラリ(rider-spring
、rider-junit
)を記述しました。
dependencies {
/**
* 略
*/
implementation("com.github.database-rider:rider-core:1.34.0")
implementation("com.github.database-rider:rider-spring:1.34.0")
testImplementation("com.github.database-rider:rider-junit5:1.34.0")
}
dbunit.yml
Database Rider の設定は、src/test/resources/dbunit.yml
から読み込まれます。
以下(公式のサンプルコード)のように記述します。
cacheConnection: true
cacheTableNames: true
leakHunter: false
mergeDataSets: false
mergingStrategy: METHOD
caseInsensitiveStrategy: UPPERCASE
raiseExceptionOnCleanUp: false
expectedDbType: UNKNOWN
disableSequenceFiltering: false
disablePKCheckFor: [""]
alwaysCleanBefore: false
alwaysCleanAfter: false
properties:
batchedStatements: false
qualifiedTableNames: false
schema: null
caseSensitiveTableNames: false
batchSize: 100
fetchSize: 100
allowEmptyFields: false
escapePattern:
datatypeFactory:
!!com.github.database.rider.core.configuration.DBUnitConfigTest$MockDataTypeFactory {
,
}
tableType: ["TABLE"]
connectionConfig:
driver: ""
url: ""
user: ""
password: ""
それぞれのキーの意味は以下です(長くなったので、折りたたみ式で記述)。一部調査中の項目があります。
Java ではDBUnit.javaで定義されています。
dbunit.yml の key value 一覧。
key | mean | default |
---|---|---|
cacheConnection | データベース接続はテスト間で再利用される | true |
cacheTableNames | テーブル名をキャッシュし、接続メタデータを不必要にクエリしないようにする | true |
leakHunter | 接続リーク検出を有効にする。リーク(テスト実行後にオープンな JDBC 接続が増えること)が見つかった場合、例外がスローされ、テストは失敗する | false |
mergeDataSets | データセットのマージが可能になり、テストクラスで宣言された @DataSet は test/method のものとマージされる | false |
mergingStrategy | データセットをマージするための戦略。もし METHOD ならばメソッドレベルのデータセットが最初にロードされるが、もし CLASS ならばクラスレベルのデータセットが最初にロードされる | false |
caseInsensitiveStrategy | caseSensitiveTableNames が false のときのみ適用される。有効な値は UPPERCASE と LOWERCASE | UPPERCASE |
raiseExceptionOnCleanUp | 有効にすると、cleanBefore または cleanAfter に失敗したときに例外が発生します。無効な場合は、警告メッセージのみが記録される | false |
expectedDbType | 初期化の過程で、実際のデータベース型が期待されるデータベース型と異なる場合、期待されるデータベース型が UNKNOWN でない限り、例外が投げられる | UNKNOWN |
disableSequenceFiltering | dbunit レベルで sequenceFiltering を無効にし, @DataSet(useSequenceFiltering) のデフォルト動作である true をオーバーライドする | |
disablePKCheckFor | データベースのシード時に主キーチェックを無効にするためのテーブル名のリスト | |
alwaysCleanBefore | dbunit レベルで cleanBefore を常に有効にし、@DataSet(cleanBefore) のデフォルト動作である false をオーバーライドされる | false |
alwaysCleanAfter | dbunit レベルで cleanAfter を常に有効にし、@DataSet(cleanAfter) のデフォルト動作である false をオーバーライドされる | false |
properties.batchedStatements | JDBC バッチドステートメントを使用できるようにする | false |
properties.qualifiedTableNames | 複数のスキーマのサポートを有効または無効にする。有効にすると、Dbunit はスキーマで完全修飾された名前を持つテーブルに、次のフォーマットでアクセスする | false |
properties.schema | 調査中 | null |
properties.caseSensitiveTableNames | 大文字と小文字を区別するテーブル名の有効・無効を設定する。有効にすると、Dbunit はすべてのテーブル名で大文字と小文字を区別して処理する | false |
properties.batchSize | JDBC 一括更新のサイズを指定する | 100 |
properties.fetchSize | 結果セットテーブルにデータをロードするためのステートメントフェッチサイズを指定する | 100 |
properties.allowEmptyFields | 空文字列('')で INSERT/UPDATE を呼び出せるようにする | false |
properties.escapePattern | スキーマ名、テーブル名、カラム名のエスケープを許可する | {} |
properties.datatypeFactory | 調査中 | |
properties.tableType | 調査中 | |
connectionConfig | 接続設定(driver、url、user、password を指定する) | JDBC 接続設定 |
サンプルコード
本記事では作成したサンプルコードを元に、CRUD 操作における Database Rider の使い方を学びます。
サンプルコードのリンクは以下です。
設定
サンプルコードを動作するために、データベース(docker compose)の用意と dbunit.yml の設定を行ました。
詳細については省略します。以下を開いて参照してください。
DB
---
version: '3.8'
services:
#
# PostgreSQL
#
sample-pg:
image: postgres:14-bullseye
container_name: sample-pg
ports:
- 5432:5432
environment:
POSTGRES_USER: sample-user
POSTGRES_PASSWORD: sample-pass
POSTGRES_DB: sample-db
POSTGRES_INIT_DB_ARGS: --encoding=UTF-8
volumes:
- type: bind
source: ${PWD}/sql/
target: /docker-entrypoint-initdb.d/
DROP TABLE IF EXISTS customer;
CREATE TABLE IF NOT EXISTS customer (
id SERIAL,
first_name VARCHAR(255),
last_name VARCHAR(255)
)
;
INSERT INTO
customer (
first_name
, last_name
)
VALUES
( 'Alice', 'Sample1' )
, ( 'Bob', 'Sample2' )
;
dbunit.yml
#
# dbunit.yml
#
# 参考
# - 大元
# - https://database-rider.github.io/database-rider/
# - 1.32.3版のドキュメント
# - https://database-rider.github.io/database-rider/1.32.3/documentation.html#_dbunit_configuration
# - サンプル
# - https://github.com/database-rider/database-rider/blob/master/rider-core/src/test/resources/config/sample-dbunit.yml
# - https://github.com/database-rider/database-rider#332-dbunit-configuration:
# - 説明付き
#
# 注: @DBUnitの方が優先される
#
#
# cacheConnection
# true: テスト間でコネクションが再利用される
# default: true
#
cacheConnection: true
#
# cacheTableNames
# true: テーブル名をキャッシュし、不必要なメタデータ接続を回避
# default: true
#
cacheTableNames: true
#
# leakHunter
# true: 接続リーク検出を有効化(テスト後にオープン状態のJDBC接続があれば検知)
# default: false
#
leakHunter: false
#
# alwaysCleanBefore
# true: @DataSet(cleanBefore)のデフォルトをtrueにする
# default: false
#
alwaysCleanBefore: true
#
# alwaysCleanAfter
# true: @DataSet(cleanAfter)のデフォルトをtrueにする
# default: false
#
alwaysCleanAfter: false
#
# raiseExceptionOnCleanUp
# true: cleanBefore/cleanAfterでcleanしようとしたら、例外を投げる
# default: false
#
raiseExceptionOnCleanUp: true
properties:
#
# properties.schema
# default: null
schema: public
#
# properties.batchedStatements
#
# true:JDBCバッチステートメントを使用できる
# default: false
#
batchedStatements: false
#
# properties.qualifiedTableNames
# true:DBUnitはSCHEMA.TABLEで完全修飾された名前のテーブルにアクセスする
# false:複数のスキーマサポートを無効化
# default: false
#
qualifiedTableNames: false
#
# properties.caseSensitiveTableNames
# true:テーブル名の大文字・小文字を区別する
# default: false
#
caseSensitiveTableNames: true
#
# properties.batchSize
# 数値:JDBC一括更新のサイズ指定
# default: 100
#
batchSize: 100
#
# properties.fetchSize
# 数値:結果セットテーブルにデータをロードするためのfetchサイズの指定
# default: 100
#
fetchSize: 100
#
# properties.allowEmptyFields
# true:空文字でINSERT/UPDATEをcallできるようにする
# default: false
#
allowEmptyFields: true
#
# properties.escapePattern
# default: {}
#
#escapePattern:
#
# connectionConfig
# JDBC接続設定
# Entity Managerが接続をしてくれるなら別
# default: 全て""
#
connectionConfig:
driver: "org.postgresql.Driver"
url: "jdbc:postgresql://127.0.0.1:5432/sample-db"
user: "sample-user"
password: "sample-pass"
アノテーション
準備ができたら、DatabseRider が利用可能です。
Database Rider の基本的な使い方は JUnit のアノテーションと同じです。
本記事では、以下のように利用します。
@Test
@DBRider
@DataSet("path/to/input/data.{yml,csv,cml}") // 入力データのファイルを指定
@ExpectedDataSet(
value = ["path/to/expected/data.{yml,csv,cml}"], // 期待値のデータのファイルを指定
orderBy = ["id"],
ignoreCols = ["createdAt", "updatedAt"], // メタデータを無視する
)
// NOTE: 一度データを書き出したら、コメントアウトする
@ExportDataSet(
format = DataSetFormat.YML, // テスト完了後にデータを書き出す形式を指定
outputName = "path/to/export/data.{yml,csv,cml}", // 書き出すファイル先を指定
includeTables = ["customer"] // 含めるカラムを指定
)
fun `テスト`() {
// テストを記述
}
それぞれのアノテーションの意味は以下です。
アノテーション | 意味 | 補足 |
---|---|---|
@Test | JUnit のアノテーション | サンプルの場合はなくても動作する。ネストしたときに必要 |
@DBRider | Database Rider のアノテーション。Databaser Rider の処理が実行される | なし |
@ExpectedDataSet | DB テスト結果の期待値を読み込むアノテーション | オプション引数に 「orderBy:順序の保証」「ignoreCols:カラムの無視」がある |
@ExportDataSet | テスト実行後に DB 内のデータをファイルに書き出す | オプション引数に「format:データ形式」「includeTables:対象テーブル」がある |
このアノテーションを用いてサンプルコードの CRUD 処理のテストを実装していきます。
CRUD 処理
以下では、CRUD における Database Rider の使い方を記述します。
それぞれの項目において、プロダクトコード(折り畳んで表示)に対する、Database Rider を用いたテストを表示しながら解説を進めます。
本記事で紹介する、Database Rider の使用手順は以下になります。
-
@DBRider
、@DataSet
アノテーションを記述する -
@ExportDataSet
を記述して、テストを実行する - 書き出されたファイルを確認して問題なかったら
@ExportDataSet
をコメントアウトする(変更があったとき、再利用するため削除しない) -
@ExpectedDataSet
でファイルを指定して、テストを再実行する
以降の解説で使用する、common.yml は以下のファイルを指しています。
common.yml
customer:
- id: 1
first_name: "Alice"
last_name: "Sample1"
- id: 2
first_name: "Bob"
last_name: "Sample2"
Create
Create の場合のソースコードです。
@DataSet
で DB を読み込み、Create 処理を実行します。
最終的な結果は、@ExpectedDataSet
で読み込んだファイルと比較します。
@ExpectedDataSet
はファイルの読み込みだけでなく、ソート(orderBy
)と無視するカラム(ignoreCols
)を指定できます。たとえば順序を保証するためにorderBy
を使用して、id
はメタデータなのでignoreCols
を使用しました。
@ExportDataSet
は DB をファイルに書き出します。形式の指定(format
)や比較対象テーブルの指定(includeTables
)も可能です。
create
interface InsertCommand {
fun perform(firstName: String, lastName: String)
}
class InsertCommandImpl(private val namedParameterJdbcTemplate: NamedParameterJdbcTemplate) : InsertCommand {
override fun perform(firstName: String, lastName: String) {
val insertCustomerSql = """
INSERT INTO
customer (
first_name
, last_name
)
VALUES
(
:first_name
, :last_name
)
;
""".trimIndent()
val insertCustomerSqlParams = MapSqlParameterSource()
.addValue("first_name", firstName)
.addValue("last_name", lastName)
namedParameterJdbcTemplate.update(insertCustomerSql, insertCustomerSqlParams)
}
}
class InsertCommandTest {
@Test
@DBRider
@DataSet("datasets/yml/given/common.yml")
@ExpectedDataSet(
value = ["datasets/yml/then/insert-success.yml"],
ignoreCols = ["id"],
orderBy = ["id"],
)
// NOTE: @ExportDataSetはgivenの@DataSetが変更用に残しておく
// @ExportDataSet(
// format = DataSetFormat.YML,
// outputName = "src/test/resources/datasets/yml/then/insert-success.yml",
// includeTables = ["customer"]
// )
fun `Create`() {
val insertCommand = InsertCommandImpl(DbConnection.namedParameterJdbcTemplate)
insertCommand.perform("firstName", "lastName")
}
}
@ExportDataSet
で書き出されたファイルは以下になります。
customer:
- id: 1
first_name: "Alice"
last_name: "Sample1"
- id: 2
first_name: "Bob"
last_name: "Sample2"
- id: 12
first_name: "firstName"
last_name: "lastName"
Read
Read の場合のソースコードです。
基本的には、Create の場合と同じです。
@ExpectedDataSet
を指定することで、Read 処理で変更が発生していないことを確認します。
read
data class Customer(
val id: Int,
val firstName: String,
val lastName: String,
)
class CustomerRowMapper : RowMapper<Customer> {
override fun mapRow(rs: ResultSet, rowNum: Int): Customer? {
return Customer(
rs.getInt("id"),
rs.getString("first_name"),
rs.getString("last_name"),
)
}
}
interface SelectAllQuery {
fun perform(): List<Customer>
}
class SelectAllQueryImpl(private val namedParameterJdbcTemplate: NamedParameterJdbcTemplate) : SelectAllQuery {
override fun perform(): List<Customer> {
val selectAllCustomerSql = """
SELECT
id
, first_name
, last_name
FROM
customer
;
""".trimIndent()
val selectAllQuerySqlParams = MapSqlParameterSource()
val customersMap = namedParameterJdbcTemplate.queryForList(selectAllCustomerSql, selectAllQuerySqlParams)
return customersMap.map {
Customer(
it["id"].toString().toInt(),
it["first_name"].toString(),
it["last_name"].toString(),
)
}
}
}
class SelectAllQueryImplTest {
@Test
@DBRider
@DataSet("datasets/yml/given/common.yml")
@ExpectedDataSet(
value = ["datasets/yml/given/common.yml"],
ignoreCols = ["id"],
orderBy = ["id"]
)
fun `Read`() {
/**
* given:
*/
val selectAllQuery = SelectAllQueryImpl(DbConnection.namedParameterJdbcTemplate)
/**
* when:
*/
val actual = selectAllQuery.perform()
/**
* then:
*/
val expected = listOf(
Customer(
1,
"Alice",
"Sample1"
),
Customer(
2,
"Bob",
"Sample2",
),
)
assertThat(actual == expected)
}
}
Update
Update は、Create と同様なので詳細な説明を省略します。
update
interface UpdateCommand {
fun perform(lastName: String)
}
class UpdateCommandImpl(val namedParameterJdbcTemplate: NamedParameterJdbcTemplate) : UpdateCommand {
override fun perform(lastName: String) {
val updateCustomerSql = """
UPDATE
customer
SET
first_name = 'EVE'
, last_name = 'Sample4'
WHERE
last_name = :last_name
""".trimIndent()
val updateCustomerSqlParams = MapSqlParameterSource()
.addValue("last_name", lastName)
namedParameterJdbcTemplate.update(updateCustomerSql, updateCustomerSqlParams)
}
}
class UpdateCommandTest {
@Test
@DBRider
@DataSet("datasets/yml/given/common.yml")
@ExpectedDataSet(
value = ["datasets/yml/then/update-success.yml"],
orderBy = ["id"]
)
// NOTE: @ExportDataSetはgivenの@DataSetが変更用に残しておく
// @ExportDataSet(
// format = DataSetFormat.YML,
// outputName = "src/test/resources/datasets/yml/then/update-success.yml",
// includeTables = ["customer"]
// )
fun `正常系`() {
val updateCommand = UpdateCommandImpl(DbConnection.namedParameterJdbcTemplate)
updateCommand.perform("Sample2")
}
}
Delete
Delete も、Create と同様なので詳細な説明を省略します。
delete
interface DeleteCommand {
fun perform(firstName: String)
}
class DeleteCommandImpl(val namedParameterJdbcTemplate: NamedParameterJdbcTemplate) : DeleteCommand {
override fun perform(firstName: String) {
val deleteCustomerSql = """
DELETE FROM
customer
WHERE
first_name = :first_name
""".trimIndent()
val deleteCustomerSqlParams = MapSqlParameterSource()
.addValue("first_name", firstName)
namedParameterJdbcTemplate.update(deleteCustomerSql, deleteCustomerSqlParams)
}
}
class DeleteCommandTest {
@Test
@DBRider
@DataSet("datasets/yml/given/common.yml")
@ExpectedDataSet(
value = ["datasets/yml/then/delete-success.yml"],
orderBy = ["id"]
)
// NOTE: @ExportDataSetはgivenの@DataSetが変更用に残しておく
// @ExportDataSet(
// format = DataSetFormat.YML,
// outputName = "src/test/resources/datasets/yml/then/delete-success.yml",
// includeTables = ["customer"]
// )
fun `正常系`() {
val deleteCommand = DeleteCommandImpl(DbConnection.namedParameterJdbcTemplate)
deleteCommand.perform("Bob")
}
}
以上で、CRUD 処理のテストの説明は終わります。
サンプルコード自体はテスト対象が簡単なソースコードだったため、テスコードも簡単なコードでした。
多少複雑なソースコードがテスト対象でも、やることはかわないです。
dbunit.yml に留意しながら、実装してみてください。
まとめ
Database Rider を利用した Kotlin の DB テストの記述方法について紹介しました。
サンプルコードを確認しながら、導入するメリットと敷居の低さを実感できました。
これを応用して、MockMVC と組み合わせることで強力な API テストも実装できます。これについてはほかの記事で紹介します。
利便性が高く、もっと普及してほしいライブラリですので、ぜひ利用してみてください。
参考
Discussion