kagamihogeの日記

kagamihogeの日記です。

DbSetupというJavaのユニットテスト用のDBデータ作成ツールのUser Guidをテキトーに訳した

JavaでのDBのテストデータ作成はDbSetupが楽 - Qiita を見て DbSetup をちょっと試してみた。

それで、中々良さげだったのでユーザーガイド(http://dbsetup.ninja-squad.com/user-guide.html)をテキトーに翻訳してみた。

User Guide

Don't clean up, prepare!

伝統的なデータベーステストの方法は、空のデータベースから始め、各テストの前にデータベースを操作し、テストを実行し、テスト後にデータを削除します。

このアプローチは不十分です。

  • データベースはテスト前に空でなければならず、そうでない場合失敗します。
  • テストが失敗する場合、テスト後にデータベースの中身を検証することには意味があります。
  • 大半のテストはread-onlyで、同一のデータセットを何度も何度も追加&削除することはテストを遅くさせます。遅いテストは悪です(slow tests are bad)

我々が推奨するのは、各テストケースごとに固有の極めて小さな複数のデータセットを使うことです。もしデータ量を少なく出来るなら、テストは速くなります。

よって、我々は以下のようなやり方を推奨します。

  1. テスト前にデータベースをクリア。
  2. 極めて小さなデータセットを追加。
  3. テストを実行。
  4. テスト後のデータベースはそのまま。

上記の後に別のテストを実行すると、再びデータベースのクリアから開始され、特に問題無くテストは実行されます。つまり、各テストの開始は全テーブルのクリアが必須であり、ただし、ここで言う全テーブルとはそのテストが使うテーブルのことではありません。テストを独立に保つことで、任意の順番でテスト実行が可能となります*1

Getting started

Create the list of tables to clear before each test

ここで指定するリストの順序は重要です。ここで最初に指定するテーブルの必須条件はどこからも参照されていないことです。つまり、その最初に指定するテーブルは別のテーブルを参照します。たとえば、procutテーブルが外部キーでvendorを参照し、vendorは外部キーでcountryを参照する場合、まずproductテーブルからクリアを開始し、それからvendor, countryという順序が必須となります。

各テストの前にテーブルをクリアするので、対象テーブルの定義はすべてのテストで使用出来るようにグローバル定数にします。

import static com.ninja_squad.dbsetup.Operations.*;

public class CommonOperations {
    public static final Operation DELETE_ALL = 
        deleteAllFrom("PRODUCT", "VENDOR", "COUNTRY", "USER");
    ...
}

Optional: create a common data set

可能な限りテストケースごとに独立したデータセットを作成することを推奨します。複数のテストケースで共有するためにグローバルなデータセットを使うと必要以上にデータセットが大きくなりがちで、テストが遅くなる一因となります。それ以上に重要なこととして、特定のテストで使う特殊なコーナーケース用のデータセットを追加すると、既存のテスト全体を壊すリスクがあり、その既存のテストはテーブルに新しい行が加わることは予期していないでしょう。

とはいえ、すべてのテストケースでおおむね共通に必要となる小規模な参照データセットを作成しておくと便利です。これらのデータセットはすべてのテーブルが依存するもので、たとえば、countries, languages, usersの例では以下のようになります。

public static final Operation INSERT_REFERENCE_DATA =
        sequenceOf(
            insertInto("COUNTRY")
                .columns("ID", "ISO_CODE", "NAME")
                .values(1, "FRA", "France")
                .values(2, "USA", "United States")
                .build(),
            insertInto("USER")
                .columns("ID", "LOGIN", "NAME")
                .values(1L, "jbnizet", "Jean-Baptiste Nizet")
                .values(2L, "clacote", "Cyril Lacote")
                .build());

Create your test class

以下のサンプルはJUnitを使用しており、TestNGやその他のテストフレームワークでも基本的な考え方は同様です。JDBC DataSourceもしくは各テストごとに新規接続を作成するのにDriverManagerを使用します。

public class VendorRepositoryTest {

    private DataSource dataSource = ...;

    @Before
    public void prepare() throws Exception {
        Operation operation =
            sequenceOf(
                CommonOperations.DELETE_ALL,
                CommonOperations.INSERT_REFERENCE_DATA,
                insertInto("VENDOR")
                    .columns("ID", "CODE", "NAME", "COUNTRY_ID")
                    .values(1L, "AMA", "Amazon", 2)
                    .values(2L, "PMI", "PriceMinister", 1)
                    .build());
            DbSetup dbSetup = new DbSetup(new DataSourceDestination(dataSource), operation);
            // or without DataSource:
            // DbSetup dbSetup = new DbSetup(new DriverManagerDestination(TEST_DB_URL, TEST_DB_USER, TEST_DB_PASSWORD), operation);
            dbSetup.launch();
        }
    }
}

Add tests, and speedup the setup

いま、findByCode(), findByName(), findByCriteria(), createVendor()メソッドのテストを追加したい、とします。全テストのうち、createVendor()に対するテストのみデータベース更新を伴います。それ以外のテストはread-onlyなので、そのread-onlyのテスト実行後にはセットアップを実行する必要はありません。再セットアップを不要にするには、DbSetupTrackerを導入し、以下のようにread-onlyテストにマークをつけます。

   // trackerがstaticなのは、JUnitは各テストメソッドごとに別のTestインスタンス
   // を使うためです。
    private static DbSetupTracker dbSetupTracker = new DbSetupTracker();
    
    @Before
    public void prepare() throws Exception {
        // 上記の例と同じ
        Operation operation =
            sequenceOf(
                CommonOperations.DELETE_ALL,
                CommonOperations.INSERT_REFERENCE_DATA,
                insertInto("VENDOR")
                    .columns("ID", "CODE", "NAME", "COUNTRY_ID")
                    .values(1L, "AMA", "Amazon", 2)
                    .values(2L, "PMI", "Price Minister", 1)
                    .build());
                    
        // 上記の例と同じ
        DbSetup dbSetup = new DbSetup(new DataSourceDestination(dataSource), operation);
        
        // tracerを使用してDbSetupを実行する。
        dbSetupTracker.launchIfNecessary(dbSetup);
    }
    
    @Test
    public void testFindByCode() {
        dbSetupTracker.skipNextLaunch();
        ...
    }
    
    @Test
    public void testFindByName() {
        dbSetupTracker.skipNextLaunch();
        ...
    }
    
    @Test
    public void testFindByCriteria() {
        dbSetupTracker.skipNextLaunch();
        ...
    }
    
    @Test
    public void testCreateVendor() {
        // このテストはデータベースに書き込みを行うので、
        // dbSetupTracker.skipNextLaunch();を呼び出してはいけない。

        ...
    }

注意点として、read-onlyなテストではdbSetupTracker.skipNextLaunch();を呼ぶようにして下さい。このメソッドそのものは何もしません。その結果セットアップが不要になりますが、テストはすべて通過します。そういうわけで、我々は上記のようなアプローチを選択しました。

Data formats

DbSetupはインサート対象カラム型の検出にparameter metadataを使います。渡せる値の型はカラム型に依存します。サポートされている型とフォーマットのすべてのリストはDefaultBinderConfigurationBindersクラスのjavadocを参照してください。以下は最も重要なものです。

カラム型 サポートする型とフォーマット
VARCHAR (とその類似型) String, Enum, 任意のObject。enumを渡す場合そのnameがインサートされます。Objectを渡す場合toString()値がインサートされます。
DATE java.sql.Date, java.util.Date, java.util.Calendar, String. Stringの期待フォーマットはjava.sql.Date.valueOf()のフォーマットでyyyy-MM-ddです。
TIMESTAMP java.sql.Timestamp, java.util.Date, java.util.Calendar, String. Stringの期待フォーマットはjava.sql.Timestamp.valueOf()のフォーマットでyyyy-MM-dd hh:mm:ss[.f...]もしくはjava.sql.Date.valueOf()yyyy-MM-ddです。

カラムにNULLをインサートしたい場合は、null(Stringの"null"ではない)を渡してください。

型やフォーマットを追加するには、BinderConfigurationのインスタンスを生成し、DbSetupのコンストラクタに渡します。

MySQLOracleデータベースなどのいくつかのデータベースはparameter metadataからカラム型を知ることが出来ません。この場合、version 1.3.0以降では、SQLExceptionをスローするのではなくデフォルトのBinderConfigurationを返します。このバインダーは、標準JDBC型(PreparedStatement.setObject()を使用するバインド), java.util.Date, java.util.Calendar(JDBC Timestampとしてバインド), enum(nameをStringとしてバインド), のみサポートします。

Value generators

データベースのテーブルにはユニーク値を持つ必須の型がありますが、テストにはあまり関係が無い場合があります。また、データセットの各行ごとに値を指定することなく定数値や連続値でカラムを処理したい場合もあります。こうした場合にはValueGeneratorを使います。いくつかの実装が利用可能で、連続した数値・文字列・日付を生成し、開始値とインクリメント値をカスタマイズ可能です。以下の例はテーブルのIDカラムを開始値1000およびインクリメント値10をジェネレータで処理しています。

    Operation insertVendors = 
        insertInto("PARAMETER")
            .withGeneratedValue("ID", ValueGenerators.sequence().startingAt(1000L).incrementingBy(10))
            .columns("CODE", "LABEL")
            .values("AMA", "AMAZON")
            .values("PMI", "Price Minister")
            .values("EBA", "EBay")
            .build();

Repeating values

複数回同じ行をインサートする場合にも有用です。以下の例はタグを100個インサートし、IDとnameを自動生成し、descriptionには適当な値("fake description")を使います。

    Operation insertTags = 
        insertInto("TAG")
            .withGeneratedValue("ID", ValueGenerators.sequence().startingAt(1L))
            .withGeneratedValue("NAME", ValueGenerators.stringSequence("tag-").startingAt(1L))
            .columns("DESCRIPTION")
            .repeatingValues("fake description").times(100)
            .build();

Specifying column names in rows

テーブルのカラム数が多い場合、テーブルに値をインサートするためのコードは可読性が悪くなりがちです。20カラムともなると、何番目がどのカラムを指しているのか判然としなくなり、必要な値が20カラム分各行に指定されているかどうかのチェックも困難になります。この問題に対する解決策として、追加する値ごとにカラム名を指定します。これによりコードは冗長になりますが、冗長性と可読性の両立は難しいところがあります。

以下の例はテーブルに対する行追加の別の方法を示しています。

    Operations.insertInto("VENDOR")
              .columns("ID", "CODE", "NAME", "COUNTRY_ID") // optional
              .row().column("ID", 1L)
                    .column("CODE", "AMA")
                    .column("NAME", "Amazon")
                    .column("COUNTRY_ID", 2)
                    .end()
              .row().column("ID", 2L)
                    .column("CODE", "PMI")
                    .column("NAME", "Price Minister")
                    .column("COUNTRY_ID", 1)
                    .end()
              .build());

この方法の場合、columns()は省略可能です。省略する場合、追加されるすべての行のカラムは最初の行のものが使われます。columns()columns()が未指定の場合は最初の行のカラム)に未指定のカラムをインサートしようとした場合、例外がスローされます。インタート対象カラムが未指定の場合はnullになります。

なお、単一のインサートに二種類の行定義を混在して設定可能で、また、ジェネレータも指定可能です。同一行を繰り返すには以下のように末尾を変更します。

.end()

の代わりに、

.times(N)

にします。

Cyclic dependencies and SQL support

稀に、双方向依存を持つデータセットが必要な場合があります。たとえば、productテーブルにvendorテーブルへの外部キーがあり、vendorテーブルにfeatured product(目玉商品)の外部キーを持たせる、などです。こうした双方向依存は厄介で、なぜなら、productを削除する前にvendorをすべて削除することは出来ず、その逆もまた同様だからです。同じく、vendorのインサート前にproductのインサートも出来ませんが、productの前にvendorをインサートすることも出来ません。この場合、二つの解決策があります。

  1. どちらかの外部キー制約を非有効化(もしくは削除)し、データをインサートし、それから外部キー制約を再有効化(もしくは再作成)する。
  2. vendorをインサートしてからproductをインサートし、それからvendorのfeatured productを更新する。

上記の両ケースとも、以下のようなSQLを使うことになります。

    Operation insertVendorsAndProducts = 
        sequenceOf(
            insertInto("VENDOR")
                .columns("ID", "CODE", "NAME")
                .values(1L, "AMA", "AMAZON")
                .build(),
            insertInto("PRODUCT")
                .columns("ID", "NAME", "VENDOR_ID")
                .values(1L, "Kindle", "1L")
                .build(),
            sql("update VENDOR set FEATURED_PRODUCT_ID = 1 where ID = 1"));

A note on metadata, MySQL and Oracle

MySQLもしくはOracleでDbSetupをversion 1.3.0より前のもので使おうとする場合、おそらく以下の例外が発生すると思われます。

java.sql.SQLException: Parameter metadata not available for the given statement.
java.sql.SQLException: Unsupported feature.

DbSetupはパラメータのバインド方法を知るためにインサート対象のカラム型を調べます。たとえば、引数としてenumを渡してカラム型がVARCHARの場合、enumのnameが格納されます。しかし、カラム型がNUMBERの場合、enumのordinalが格納されます。ほとんどのケースで動作しますが、MySQLOracleはこの機能をサポートしていません。

MySQLはゴミ*2はその点特殊です。MySQLはparameter metadataをサポートしていません(もしくは、少なくとも私にはサポートしているように見えない)。URLに?useServerPrepStmts=trueを追加することで有効になりますが、実際のカラム型に関わらずVARBINARYが返ってきます。URLに?generateSimpleParameterMetadata=trueを追加することで有効になりますが、実際のカラム型に関わらずVARBINARYが返ってきます。

DbSetupの以前のバージョンでは、上記の例外を避けるのにuseMetadata(false)を呼ぶ必要がありました。1.3.0以降のDbSetupは機能を落とすことで上手く対応しています。デフォルトのバインダー設定は例外をキャッチしてデフォルトのバインダーを返します。このデフォルトのバインダーは渡された値をプリペアードステートメントsetObject()に渡します。ただし、Timestampに変換されるDateおよびCalendarインスタンスと、nameに変換されるenumは除きます。よって、enumのサンプルの場合、ordinalをバインドするには、ただ単にenumを渡してDbSetupにバインド方法を任せるのではなく、明示的にenumのordinalを渡す必要があります。

*1:You want your tests to be independent, and able to run in whatever order.が原文。この訳であってるのだろうか…

*2:原文はsucksに打ち消し線がついている