<-- mermaid -->

インターン生がDBのテストをKotlinで書けるようにした話

はじめに

こんにちは!NewsPicksのSREユニットでインターンシップをしている西(@yukinissie)です!ニッシー☆というニックネームで活動している福岡の情報系大学生です。先日、データベース(以下DB)とデータのやり取りをしているソースコードについてKotlinを用いてテストできるように開発基盤を改善したので紹介します。前半は主にテスト用インメモリDBを開発環境に追加するまでの話、後半からはKotlinでテストを実装する話について書いています。

タスクについて

タスク要件としては以下のように割り当ててもらいました。

  • ユニットテストを推進するためにDBに対するテストの基盤となる開発環境を構築する。
  • インメモリDBを用いてテストする。
  • 特定のリポジトリを継承しているクラスをテスト対象とする。

実装までの流れ

最終的に以下のような順番で解決していくことになりました。

  1. 採用するに相応しいインメモリDBがどれかを調査して決定する
  2. インメモリDB向けにデータを初期化するプログラムを開発する
  3. テストコードを書く
  4. テストコードをリファクタリングする

それぞれ順番に紹介します。

1. 採用するに相応しいインメモリDBがどれかを調査して決定する

NewsPicksではCIの一部をGitHub Actions上で実行しています。DB(MySQL)とのデータのやり取りをするソースコードのテストの実行をローカルとCI上で実行したいこと、テストの独立性を保たせたいことを理由にインメモリDBでテストを実装することになりました。そこでいくつかのインメモリDBを案に上げてどれが相応しいかを調査して決定しました。以下2つが案に上がりました。

メリット・デメリットは表の通りになりました。

DB メリット デメリット
H2 Database Hibernate公式がh2のdialectを用意 後述の初期化用SQLの用意が困難
SQLite H2よりも高速。後述の初期化用SQLの用意が簡単 dialectは非公式がサポート。アーキテクチャごとにバイナリが必要

今回は実行速度や実装の容易性で優位であるSQLiteを採用しました。どちらも突出したメリットもデメリットも見つからなかったので判断するのが難しかったです。後述のインメモリDBを初期化するプログラムの開発を先に試すことでようやく決めることができました。

2. インメモリDB向けにデータを初期化するプログラムを開発する

NewsPicksではMySQL用のデータ定義言語(以下DDL)がリポジトリに用意されています。これを用いてローカルのMySQLを初期化しています。今回はテスト用のインメモリDB向けにDDLからSQLite用のSQLに変換してSQLiteのDBファイルを生成したいと考えました。下記図のgenerate_sqlite(仮名)コマンドを作ることにしました。

generate_sqliteを自作してDDLからSQLite用SQLに変換し、それからSQLiteのDBファイルを生成します。

そこでお世話になったのはmysql2sqliteというOSSです。これを用いることでMySQLのSQLからSQLiteのSQLに変換されます。ただ、現時点ではMySQLのTRIGGER構文で変換できない部分がmysql2sqliteにはあるので実装には注意が必要でした。(OSSコミットチャンスですね、、!)

github.com

作ったもの

DDLを元にカレントディレクトリにテスト用SQLiteのDBファイルを生成するgenerate_sqlite(仮名)コマンドを作りました。

用件は以下の通りになりました。

  • カレントディレクトリにSQLiteがあれば削除する
  • DDLからTRIGGERを定義している部分を削除する
  • TRIGGERを削除したDDLをmysql2sqliteを用いてSQLite用SQLに変換する
  • 変換したSQLからsqlite3コマンドでSQLiteのDBファイルを生成する

以上の要件から以下のような内容のコマンドを作りました。

#!/bin/bash

set -x

DIR="$(dirname "$0")"

DDL="$DIR"/ddl.sql
SQLITE=sqlite.db

if [[ -e "$SQLITE" ]]; then
  rm -f "$SQLITE"
fi

"$DIR"/delete_trigger "$DDL" | "$DIR"/mysql2sqlite - | sqlite3 "$SQLITE"

スクリプト内の外部ファイルを正しく参照するためにDIR="$(dirname "$0")"を用いています。どこでも同じようにコマンドを実行するために必要な変数です。ペアプロをしながら書いたもので、とても勉強になりました。ファイルの有無による条件分岐の書き方をBashでどう書くかについても学びました。

上記コマンドで重要なのは最後の1行です。

"$DIR"/delete_trigger "$DDL" | "$DIR"/mysql2sqlite - | sqlite3 "$SQLITE"

ここでDDLからTRIGGERの定義を削除して、mysql2sqliteでSQLite用SQLへ変換。そしてsqlite3コマンドでSQLiteのDBファイルを生成しています。sqlite3コマンドはsqlite.orgが提供しているSQLiteを管理するコマンドラインツールです。

mysql2sqliteで変換する際にTRIGGERの定義を変換できない場合がありました。今回はテーブル定義のみ必要なのでDDLからTRIGGERを取り除くdelete_trigger(仮名)コマンドをAWKで作りました。mysql2sqliteはAWKで書かれていて、TRIGGERの処理の部分を参考に実装しました。

#!/usr/bin/awk -f

# Reference implementation from https://github.com/dumblob/mysql2sqlite/blob/c6ab632eb3ad5798c85f643bd7ecf76ea2d3c63e/mysql2sqlite#L80-L90

/^\/\*.*(CREATE.*TRIGGER|create.*trigger)/ { inTrigger = 1; next }
/(END|end) \*\/;;/ { inTrigger = 0; next }
inTrigger == 0 { print; next }

mysql2sqliteは引数にファイルを渡して実行することでSQLite用SQLを生成しますが、-(ハイフン、ダッシュ)を渡すことで標準入力を受け入れてくれます。

# 以下のように実行できるが、
mysql2sqlite mysql.sql
# 以下のようにも実行できる。
cat < ddl.sql | mysql2sqlite -

このように書くことでmysql2sqliteにDDLを渡す前に変換する処理(delete_trigger(仮名)のような処理)を追加しても、左から右へと処理が流れるのでプログラムの見通しを良くすることができます。

some_command ddl.sql | mysql2sqlite - | sqlite3 sqlte.db

どうやらUNIXの世界では常識だそうで、引数に-(ハイフン、ダッシュ)を渡して標準入力からファイルを入力できるコマンドは珍しくないそうです。例えばcatコマンドのmanページには以下の通りに書かれています。

If file is a single dash (‘-’) or absent, cat reads from the standard input.

manページを読むって大事だなと思い、学び直しをしているところです(笑)

これを機にペアプロいただいた方から紹介された書籍を読みつつLinuxプログラミングの基本も学んでいるところです。

www.sbcr.jp

3. テストコードを書く

今回はテストフレームワークにKotestを用いました。これを使うことでKotlinネイティブにテストを書くことができるようになります。

class SampleTest : FuncSpec({
    beforeTest {
        // テスト前に実行したい処理
    }
    
    afterTest {
        // テスト後に実行したい処理
    }
    
    test("テストケース名") {
        // テストしたい処理
    }
})

kotest.io

NewsPicksではORMにHibernate ORMを用いています。DBとの接続にはSessionを作る必要があり、SessionFactoryから作ります。それに従ったテストコードを以下のように書いてみました。

class SampleDaoTest : FuncSpec({
    // HibernateのSessionFactoryを作る際に用いる設定
    val configuration = Configuration()
        .setProperty("hibernate.connection.driver_class", "org.sqlite.JDBC")
        .setProperty("hibernate.connection.url", "jdbc:sqlite:")
        .setProperty("hibernate.dialect", "com.enigmabridge.hibernate.dialect.SQLiteDialect")
        .setProperty("hibernate.show_sql", "true")
        .setProperty("hibernate.current_session_context_class", "thread")
    configuration.addAnnotatedClass(Sample::class.java)
    val sessionFactory = configuration.buildSessionFactory(
        StandardServiceRegistryBuilder().applySettings(configuration.properties).build()
    )
    // テスト対象のDAOクラス
    val sampleDao = SampleDao(sessionFactory)
    
    beforeTest {
        // SQLiteの復元
        var transaction = sampleDao.session.beginTransaction()
        sampleDao.session.doWork { connection ->
            connection.createStatement().executeUpdate("restore from sqlite.db")
        }
        transaction.commit()
        
        // ダミーデータ
        val dummySamples = arrayOf(
            Sampe(null, 1, 'active'),
            Sampe(null, 2, 'active'),
            Sampe(null, 3, 'disactive'),
        )
        
        // ダミーデータの保存
        transaction = sampleDao.session.beginTransaction()
        for (s in dummySamples) {
            sampleDao.session.save(s)
        }
        transaction.commit()
    }
    
    afterTest {
        sampleDao.session.close()
    }
    
    test("activeなSampleが返却されるか") {
        val transaction = sampleDao.session.beginTransaction()
        val samples = sampleDao.session.findAllActiveSamples()
        transaction.commit()
        
        samples.map { it.status }.all { it == 'active' } shouldBe true
    }

    // 任意の数だけtestを記述できます。
})

HibernateのSessionFactoryを用意することで任意のDBにアクセスする準備が整うことを理解した上で上記のコードを書くことができました。transactionで挟まないと実行できないことがある点もつまづきポイントでした。

4. テストコードをリファクタリングする

成功するテストコードをようやく実装することができたので、これからは自分を含めた開発者がテストをスムーズに開発できるようにするためにリファクタリングを行います。

4つ紹介します。

  1. SessionFactoryを切り出す
  2. restoreを抽象化して切り出す
  3. transactionの処理をスッキリ書く
  4. contextを用いてテストケースをグループ化する

4-1. SessionFactoryを切り出す

SessionFactoryを作るために定義しているConfigurationはテストごとに変わることはないのに毎回間違いなく書かなくてはいけないのはとても面倒なことだと思います。そこでbuildSessionFactoryメソッドを作って切り出すことにしましました。

fun buildSessionFactory(vararg entityClass: Class<*>): SessionFactory {
    val configuration = Configuration()
        .setProperty("hibernate.connection.driver_class", "org.sqlite.JDBC")
        .setProperty("hibernate.connection.url", "jdbc:sqlite:")
        .setProperty("hibernate.dialect", "com.enigmabridge.hibernate.dialect.SQLiteDialect")
        .setProperty("hibernate.show_sql", "true")
        .setProperty("hibernate.current_session_context_class", "thread")
    for (c in entityClass) {
        configuration.addAnnotatedClass(c)
    }
    return configuration.buildSessionFactory(
        StandardServiceRegistryBuilder().applySettings(configuration.properties).build()
    )
}

このメソッドを作ることでSessionFactoryを定義する部分がテストコード上では簡潔に書けるようになりました。

val sampleDao = SampleDao(buildSessionFactory(Sample::class.java))

buildSessionFactoryの引数にはテスト中にクエリするテーブルに対応したEntityクラスを引数に渡すだけで必要なテーブルのみインメモリのSQLiteとやりとりができるようになります。

4-2. restoreを抽象化して切り出す

SQLiteを復元しているコードについても毎回書く必要がありますが、テストコード上では1行ぐらいで用意できたらいいなと感じたので以下のようなメソッドを用意しました。

fun Session.restore() {
    val transaction = this.beginTransaction()
    this.doWork { connection ->
        connection.createStatement().executeUpdate("restore from sqlite.db")
    }
    transaction.commit()
}

このメソッドを作ることでSQLiteを復元する部分がテストコード上では1行で書けるようになりました。

sampleDao.session.restore()

4-3. transactionの処理をスッキリ書く

ここでもう一つ、レビューをしてくださった方から便利なメソッドを追加してくださいました。これを用いるとクエリのためにtransactionで挟んでいた部分がロールバックの処理を含めて綺麗に書けます。

fun <T> Session.withTransaction(action: () -> T): T {
    val transaction = this.beginTransaction()
    try {
        val t = action()
        transaction.commit()
        return t
    } catch (e: Throwable) {
        transaction.rollback()
        throw e
    }
}

例えば以下のように書くことができます。

sampleDao.session.withTransaction {
    for (s in samples) {
        sampleDao.session.save(s)
    }
}

4-4. contextを用いてテストケースをグループ化する

テストするメソッドに対して複数のパターンを試したいというケースはよくあると思います。Kotestではcontextを用いるとテストをグループ分けすることができます。

例えば以下はNGな例とします。

test('findSamples()に引数を渡さない時') { /* テストコード */ }
test('findSamples()のoffsetがnull、limitが0のとき') { /* テストコード */ }
test('findSamples()のoffsetが1、limitが0のとき') { /* テストコード */ }
test('findSamples()のoffsetがnull、limitが1のとき') { /* テストコード */ }
test('findSamples()のoffsetが1、limitが1のとき') { /* テストコード */ }

上記の例は並べて書かれているのでまとまりとして見えますが、実際には複数行書かれるので関連したテストケースであることが後から見ると把握しづらいです。また、findSamples()のテストケースのみを実行したい場合などは5つ全てを手動で一つずつ実行しなければならない点で良くないです。

contextを使うと以下のようになります。

context('findSamples()') {
    test('引数を渡さない時') { /* テストコード */ }
    test('offsetがnull、limitが0のとき') { /* テストコード */ }
    test('offsetが1、limitが0のとき') { /* テストコード */ }
    test('offsetがnull、limitが1のとき') { /* テストコード */ }
    test('offsetが1、limitが1のとき') { /* テストコード */ }
}

contextのブロックにより関連したテストケースがどれかを把握できます。また、context単位でテストを実行できるので例えばfindSamples()のテストケースのみを実行することができて良いです。

最終的には以下のようなテストコードに落ち着きました。

class SampleDaoTest : FuncSpec({
    
    // テスト対象のDAOクラス
    val sampleDao = SampleDao(buildSessionFactory(Sample::class.java))
    
    beforeTest {
        // SQLiteの復元
        sampleDao.session.restore()
        
        // ダミーデータ
        val dummySamples = arrayOf(
            Sampe(null, 1, 'active'),
            Sampe(null, 2, 'active'),
            Sampe(null, 3, 'disactive'),
        )
        
        // ダミーデータの保存
        sampleDao.session.withTransaction {
            for (s in dummySamples) {
                sampleDao.session.save(s)
            }
        }
    }
    
    afterTest {
        sampleDao.session.close()
    }
    
    context('findAllActiveSamples()') {
        test("activeなSampleが返却されるか") {
            val samples = sampleDao.session.withTransaction {
                sampleDao.session.findAllActiveSamples()
            }
            
            samples.map { it.status }.all { it == 'active' } shouldBe true
        }
    }

    // 任意の数だけtestやcontextを記述できます。
})

おわりに

DBのテストをKotlinで実装するまでの軌跡について紹介しました。CI上でも独立性を保ったままテストを動かすにはどうするか、既存のDDLからSQLiteを生成するためにシェルやAWKでどう解決するか、Kotlinで実際にテストを書くにはどのように表現すると見やすいか等さまざまなことについて考えさせられ、実装まですることができました。テストを推進することはソフトウェアの信頼性を高めるとともに開発者の心理的安全性を上げ、大胆なコード改修や機能追加、レガシーからの脱却などプロダクトや働く人自身にポジティブな影響を与えると考えています。インターンシップの身でここまでさせてもらえたのは嬉しいことですし、学びがありとても刺激的で楽しいものとなりました!!

Page top