🫖

Database Rider で Kotlin(Spring)の DB テストを簡潔に書く

2022/10/14に公開

概要

本記事では、Java のライブラリである Database Rider について紹介します。
Database Rider を用いることで、DB テストを直感的かつ簡潔に記述可能です。
本記事では、Database Rider を簡単に紹介した後、Kotlin で導入し実践します。

Database Rider とは

Database Rider とは、Java や Kotlin で DB テストを簡単に実施するためのライブラリです。

https://github.com/database-rider/database-rider

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-springrider-junit)を記述しました。

build.gradle.kts
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 の使い方を学びます。
サンプルコードのリンクは以下です。

https://github.com/Msksgm/kotlin-database-rider-sample

設定

サンプルコードを動作するために、データベース(docker compose)の用意と dbunit.yml の設定を行ました。
詳細については省略します。以下を開いて参照してください。

DB
docker-compose.yml
---

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/

sql/001-customer.sql
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
src/test/resources/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 のアノテーションと同じです。
本記事では、以下のように利用します。

Database Rider のアノテーションのサンプル
@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
src/test/resources/datasets/yml/given/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)
    }
}
src/test/kotlin/com/example/kotlindatabaseridersample/InsertCommandTest.kt
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(),
            )
        }
    }
}

src/test/kotlin/com/example/kotlindatabaseridersample/SelectAllQueryImplTest.kt
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)
    }
}

src/test/kotlin/com/example/kotlindatabaseridersample/UpdateCommandTest.kt
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 テストも実装できます。これについてはほかの記事で紹介します。
利便性が高く、もっと普及してほしいライブラリですので、ぜひ利用してみてください。

参考

https://github.com/database-rider/database-rider

https://database-rider.github.io/database-rider/latest/documentation.html

https://database-rider.github.io/getting-started/

https://github.com/database-rider/database-rider/blob/master/rider-core/src/main/java/com/github/database/rider/core/api/configuration/DBUnit.java

https://github.com/database-rider/database-rider/blob/master/rider-core/src/main/java/com/github/database/rider/core/configuration/DBUnitConfig.java

Discussion