CLOVER🍀

That was when it all began.

DbUnitを試してみる

これは、なにをしたくて書いたもの?

Javaのテストまわりの情報を見ていて、データベースの事情って今はどうなんだろう?と思いまして。

見ていて、DbUnitから変わっていなさそうだったのと、そもそもDbUnitを使ったことがなかったのでちょっと試してみることにしました。

DbUnitと周辺事情

DbUnitJUnitのExtensionとされていて、データベースドリブンなプロジェクトをターゲットにしています。

DbUnit is a JUnit extension (also usable with Ant) targeted at database-driven projects that, among other things, puts your database into a known state between test runs.

About DbUnit

データのエクスポート・インポートの機能を持ち、データベースが持つ値でのアサーションも行えます。

DbUnit has the ability to export and import your database data to and from XML datasets. Since version 2.0, DbUnit can also work with very large datasets when used in streaming mode. DbUnit can also help you to verify that your database data match an expected set of values.

Spring Frameworkのテストに関するドキュメント内でも、データベースに関するテストのリソースで参照されているのは、DbUnitのみです。

DbUnit: JUnit extension (also usable with Ant and Maven) that is targeted at database-driven projects and, among other things, puts your database into a known state between test runs.

Testing / Further Resources

なので、まずはDbUnitを見てみるのが妥当なのかな、とは思うのですが。

DbUnitのサイトを見てみると、最終リリースが2012年の2.4.9になっていてSourceForgeにあるプロジェクトも更新されていません。

dbUnit download | SourceForge.net

が、よく見るとMaven Centralからは新しいバージョンがダウンロードできると書かれているので、見てみると現時点の最新のバージョンは
2.7.3です。

https://search.maven.org/artifact/org.dbunit/dbunit/2.7.3/jar

現在、ソースコードはこちらでバージョン管理されているようです。

dbUnit / dbunit / [716ef9]

なので、メンテナンスは継続されていそうです。

その他、DbUnitを取り巻くOSSとして、Spring Test向けのSpring Test DBUnitというものがありますが、こちらは更新が止まって
久しそう…。

Spring Test DBUnit – Introduction

Database Riderというものもあるので、今はこちらを見るのがよいのかもしれません。

Database Rider

DbUnitについて

DbUnitに影響を与えた、データベースのテストに関する話はこちら。

Database Testing

いわゆるGetting Started。

Getting Started

ステップとしては、以下のようです。

  • データファイルの作成
  • データベースの接続情報等のセットアップを行い、テストを実施

DbUnitDBTestCaseクラスを提供しているようですが、JUnit 5時代のものではないのでこちらは使わないでしょうね。

実際、DbUnitの依存関係にはJUnit 5は含まれていません。

https://sourceforge.net/p/dbunit/code.git/ci/dbunit-2.7.3/tree/pom.xml

アサーションのサポートとしては、以下あたりができそうです。

  • クエリーを元にしたスナップショット(ITable)の取得
  • スナップショットの比較の際に、カラムのフィルタリング
  • スナップショットのソート

また、データファイルからのデータロードも可能なようです。

DbUnitを構成する主なコンポーネントは、こちらに書かれています。

Core Components

  • IDatabaseConnectionインターフェース … データベースへの接続を表す
  • IDataSetインターフェース … テーブルのコレクションを表す
  • DatabaseOperationクラス … 各テストの前後でデータベースに対する操作を抽象化する

IDataSetインターフェースの実装として、XML、クエリー、Excel、プログラムで組み立てるもの等いろいろあるようですが、
ドキュメントに記載されているものは古そうなので、ソースコードを見た方がいいんでしょうね。

https://sourceforge.net/p/dbunit/code.git/ci/dbunit-2.7.3/tree/src/main/java/org/dbunit/dataset/

CSVファイルでも扱えそうです。

DatabaseOperationクラスは、各テストの前後でデータベースに対する操作を実行しますが、以下のようなことを行えます。

  • UPDATE
  • INSERT
  • DELETE
  • DELETE_ALL
  • TRUNCATE
  • REFRESH
  • CLEAN_INSERT
  • NONE

直感的にわからなそうなのはREFRESHCLEAN_INSERTでしょうか。

REFRESHは対象となるデータセットをデータベースに反映するもので、データセットに含まれる範囲のデータのみを対象とし、
それ以外のデータには影響を与えません。内部的には、updateして更新されなければinsertしようとします。
CLEANDELETE_ALLの後にINSERTを実行するものです。

その他、複数の操作をひとつにまとめるCompositeOperationクラス、トランザクション内で操作を実行するTransactionOperationクラスなども
あります。

とまあ、ドキュメントを読むのはこれくらいにして、実際に使っていってみましょう。

お題

今回、お題は以下にします。

  • DbUnitを使ったテストを書く
  • ファイルで用意するデータセットには、XMLを使用する
  • データベースアクセスにはDoma 2を使用する
  • テスティングフレームワークには、JUnit 5を使用する
  • データベースにはMySQL 8.0を使用する

データセットで使うファイルはCSVにしようかなと思っていたのですが、CSVでのデータセットはなかなか独特みたいなので、今回はXML
しました。

環境

今回の環境は、こちら。

$ java --version
openjdk 17.0.2 2022-01-18
OpenJDK Runtime Environment (build 17.0.2+8-Ubuntu-120.04)
OpenJDK 64-Bit Server VM (build 17.0.2+8-Ubuntu-120.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.8.5 (3599d3414f046de2324203b78ddcf9b5e4388aa0)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.2, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-107-generic", arch: "amd64", family: "unix"

MySQLは172.17.0.2で動作しているものとします。バージョンは、こちら。

$ mysql --version
mysql  Ver 8.0.28 for Linux on x86_64 (MySQL Community Server - GPL)

準備

まずは、プロジェクトを作成していきます。

今回のMaven依存関係等はこちら。

    <dependencies>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.28</version>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.seasar.doma</groupId>
            <artifactId>doma-core</artifactId>
            <version>2.51.0</version>
        </dependency>

        <dependency>
            <groupId>org.seasar.doma</groupId>
            <artifactId>doma-processor</artifactId>
            <version>2.51.0</version>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.8.2</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.dbunit</groupId>
            <artifactId>dbunit</artifactId>
            <version>2.7.3</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.22.2</version>
            </plugin>
        </plugins>
    </build>

DbUnitは2.7.3ですね。

続いて、データベースアクセスを行うプログラムをDoma 2を使って作成します。

エンティティクラス。

src/main/java/org/littlewings/dbunit/Book.java

package org.littlewings.dbunit;

import java.time.LocalDate;

import org.seasar.doma.Entity;
import org.seasar.doma.Id;

@Entity
public class Book {
    @Id
    String isbn;
    String title;
    Integer price;
    LocalDate publishDate;
    String category;

    public static Book create(String isbn, String title, Integer price, LocalDate publishDate, String category) {
        Book book = new Book();
        book.setIsbn(isbn);
        book.setTitle(title);
        book.setPrice(price);
        book.setCategory(category);

        book.setPublishDate(publishDate);

        return book;
    }

    // getter/setterは省略
}

Dao。

src/main/java/org/littlewings/dbunit/BookDao.java

package org.littlewings.dbunit;

import org.seasar.doma.Dao;
import org.seasar.doma.Insert;
import org.seasar.doma.Script;
import org.seasar.doma.Select;
import org.seasar.doma.Sql;

@Dao
public interface BookDao {
    @Sql("""
 drop table if exists book;
 create table book(
   isbn varchar(14),
   title varchar(100),
   price int,
   publish_date date,
   category varchar(20),
   primary key(isbn)
 )
 """)
    @Script
    void createTableIfExistsRecreate();

    @Sql("truncate table book")
    @Script
    void truncate();

    @Insert
    int insert(Book book);

    @Sql("select /*%expand*/* from book where isbn = /* isbn */'dummy'")
    @Select
    Book selectByIsbn(String isbn);
}

Config。

src/main/java/org/littlewings/dbunit/DomaConfig.java

package org.littlewings.dbunit;

import javax.sql.DataSource;

import org.seasar.doma.jdbc.Config;
import org.seasar.doma.jdbc.Naming;
import org.seasar.doma.jdbc.dialect.Dialect;
import org.seasar.doma.jdbc.dialect.MysqlDialect;
import org.seasar.doma.jdbc.tx.LocalTransactionDataSource;
import org.seasar.doma.jdbc.tx.LocalTransactionManager;
import org.seasar.doma.jdbc.tx.TransactionManager;

public class DomaConfig implements Config {
    private static final DomaConfig CONFIG = new DomaConfig();

    Dialect dialect;

    LocalTransactionDataSource dataSource;

    TransactionManager transactionManager;

    public static DomaConfig singleton() {
        return CONFIG;
    }

    private DomaConfig() {
        dialect = new MysqlDialect();
        dataSource = new LocalTransactionDataSource(
                "jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&characterSetResults=utf-8&connectionCollation=utf8mb4_0900_bin",
                "kazuhira",
                "password"
        );
        transactionManager = new LocalTransactionManager(dataSource.getLocalTransaction(getJdbcLogger()));
    }

    @Override
    public DataSource getDataSource() {
        return dataSource;
    }

    @Override
    public Dialect getDialect() {
        return dialect;
    }

    @Override
    public TransactionManager getTransactionManager() {
        return transactionManager;
    }

    @Override
    public Naming getNaming() {
        return Naming.SNAKE_LOWER_CASE;
    }
}

ここまでで、準備は完了です。あとはこれらを使ってテストコードを書いていきます。

DbUnitを使ってみる

では、DbUnitを使っていきましょう。まずは、テストコードの雛形を用意。

src/test/java/org/littlewings/dbunit/DbUnitGettingStartedTest.java

package org.littlewings.dbunit;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.time.LocalDate;
import java.util.List;

import org.dbunit.Assertion;
import org.dbunit.DatabaseUnitException;
import org.dbunit.database.DatabaseConnection;
import org.dbunit.dataset.IDataSet;
import org.dbunit.dataset.ITable;
import org.dbunit.dataset.SortedTable;
import org.dbunit.dataset.xml.FlatXmlDataSetBuilder;
import org.dbunit.ext.mysql.MySqlConnection;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

public class DbUnitGettingStartedTest {
    @BeforeAll
    public static void setUpAll() {
        BookDao bookDao = new BookDaoImpl(DomaConfig.singleton());
        DomaConfig
                .singleton()
                .getTransactionManager()
                .required(() -> bookDao.createTableIfExistsRecreate());
    }

    @BeforeEach
    public void setUp() {
        BookDao bookDao = new BookDaoImpl(DomaConfig.singleton());
        DomaConfig
                .singleton()
                .getTransactionManager()
                .required(() -> bookDao.truncate());
    }

    // ここにテストを書く!
}

テスト前に1度だけテーブルをdrop & createして、各テストごとにテーブルをtruncateします。

最初はDbUnitを使ってデータベースに接続してみましょう。こんな感じで、JDBCConnectionからDatabaseConnectionを取得します。

    @Test
    public void createDbUnitConnection() throws DatabaseUnitException, SQLException {
        DatabaseConnection databaseConnection = null;
        try (Connection conn = DriverManager
                .getConnection(
                        "jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&characterSetResults=utf-8&connectionCollation=utf8mb4_0900_bin",
                        "kazuhira",
                        "password");) {
            databaseConnection = new MySqlConnection(conn, null);
        } finally {
            if (databaseConnection != null) {
                databaseConnection.close();
            }
        }
    }

今回はMySqlConnectionクラスを使っていますが、汎用的なDatabaseConnectionクラスをnewしても良いですし、他のデータベースの場合は
それぞれに応じたクラスを使ってもよいでしょう。

https://sourceforge.net/p/dbunit/code.git/ci/dbunit-2.7.3/tree/src/main/java/org/dbunit/ext/

見たところ、汎用的なDatabaseConnectionクラスとの差はそれぞれのデータベースを考慮したデータ型やメタデータを扱うクラスを適用して
くれるところみたいですね。

次に、アサーションを行ってみます。

    @Test
    public void firstAssertion() throws DatabaseUnitException, SQLException {
        DatabaseConnection databaseConnection = null;
        try (Connection conn = DriverManager
                .getConnection(
                        "jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&characterSetResults=utf-8&connectionCollation=utf8mb4_0900_bin",
                        "kazuhira",
                        "password");) {
            databaseConnection = new MySqlConnection(conn, null);

            DomaConfig
                    .singleton()
                    .getTransactionManager()
                    .required(() -> {
                        BookDao bookDao = new BookDaoImpl(DomaConfig.singleton());

                        List<Book> books = List.of(
                                Book.create("978-4295008477", "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]", 2860, LocalDate.of(2020, 3, 13), "java"),
                                Book.create("978-4621303252", "Effective Java 第3版", 4400, LocalDate.of(2018, 10, 30), "java"),
                                Book.create("978-4774189093", "Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278, LocalDate.of(2017, 4, 18), "java"),
                                Book.create("978-4798161488", "MySQL徹底入門 第4版 MySQL 8.0対応", 4180, LocalDate.of(2020, 7, 6), "mysql"),
                                Book.create("978-4798147406", "詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド", 3960, LocalDate.of(2016, 8, 26), "mysql")
                        );

                        books.forEach(bookDao::insert);
                    });

            IDataSet actualDataSet = databaseConnection.createDataSet();
            ITable actualTable = actualDataSet.getTable("book");

            IDataSet expectedDataSet =
                    new FlatXmlDataSetBuilder()
                            .build(getClass().getClassLoader().getResourceAsStream("DbUnitGettingStartedTest.firstAssertion.xml"));
            ITable expectedTable = expectedDataSet.getTable("book");

            // assert dataset
            Assertion.assertEquals(expectedDataSet, actualDataSet);
            // assert table
            Assertion.assertEquals(expectedTable, actualTable);
        } finally {
            if (databaseConnection != null) {
                databaseConnection.close();
            }
        }
    }

テーブルにデータを登録。

            DomaConfig
                    .singleton()
                    .getTransactionManager()
                    .required(() -> {
                        BookDao bookDao = new BookDaoImpl(DomaConfig.singleton());

                        List<Book> books = List.of(
                                Book.create("978-4295008477", "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]", 2860, LocalDate.of(2020, 3, 13), "java"),
                                Book.create("978-4621303252", "Effective Java 第3版", 4400, LocalDate.of(2018, 10, 30), "java"),
                                Book.create("978-4774189093", "Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278, LocalDate.of(2017, 4, 18), "java"),
                                Book.create("978-4798161488", "MySQL徹底入門 第4版 MySQL 8.0対応", 4180, LocalDate.of(2020, 7, 6), "mysql"),
                                Book.create("978-4798147406", "詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド", 3960, LocalDate.of(2016, 8, 26), "mysql")
                        );

                        books.forEach(bookDao::insert);
                    });

この後、DatabaseConnectionクラスのインスタンスからデータセットおよびテーブルのデータを取得します。

            IDataSet actualDataSet = databaseConnection.createDataSet();
            ITable actualTable = actualDataSet.getTable("book");

そして、確認を行うデータセットXMLファイルから読み込みます。こちらもデータセットと、データセットからテーブルに対応する
データとして扱えます。

            IDataSet expectedDataSet =
                    new FlatXmlDataSetBuilder()
                            .build(getClass().getClassLoader().getResourceAsStream("DbUnitGettingStartedTest.firstAssertion.xml"));
            ITable expectedTable = expectedDataSet.getTable("book");

用意したXMLファイルはこちら。

src/test/resources/DbUnitGettingStartedTest.firstAssertion.xml

<?xml version="1.0" encoding="UTF-8"?>
<dataset>
    <book isbn="978-4295008477" title="新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]" price="2860" publish_date="2020-03-13" category="java"/>
    <book isbn="978-4621303252" title="Effective Java 第3版" price="4400" publish_date="2018-10-30" category="java"/>
    <book isbn="978-4774189093" title="Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで" price="3278" publish_date="2017-04-18" category="java"/>
    <book isbn="978-4798147406" title="詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド" price="3960" publish_date="2016-08-26" category="mysql"/>
    <book isbn="978-4798161488" title="MySQL徹底入門 第4版 MySQL 8.0対応" price="4180" publish_date="2020-07-06" category="mysql"/>
</dataset>

要素名にテーブル名、属性にカラム名といった感じですね。

その他のファイル形式の場合は、リポジトリのテストに関する部分を見るとよいでしょう。

https://sourceforge.net/p/dbunit/code.git/ci/dbunit-2.7.3/tree/src/test/resources/

https://sourceforge.net/p/dbunit/code.git/ci/dbunit-2.7.3/tree/src/test/java/org/dbunit/dataset/

アサーション。データセット単位でも行えますし、テーブル単位でも行えます。

            // assert dataset
            Assertion.assertEquals(expectedDataSet, actualDataSet);
            // assert table
            Assertion.assertEquals(expectedTable, actualTable);

こちらで、現在データセットおよびテーブル(今回はデータセットとテーブルが同じ範囲ですが)に格納されているデータと、XMLファイルで
用意したデータが等しいことが確認できました。

アサーションを行う対象のデータを絞り込むこともできます。たとえば、SQLを使う場合はこちら。

    @Test
    public void createDataSetFromQuery() throws DatabaseUnitException, SQLException {
        DatabaseConnection databaseConnection = null;
        try (Connection conn = DriverManager
                .getConnection(
                        "jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&characterSetResults=utf-8&connectionCollation=utf8mb4_0900_bin",
                        "kazuhira",
                        "password");) {
            databaseConnection = new MySqlConnection(conn, null);

            DomaConfig
                    .singleton()
                    .getTransactionManager()
                    .required(() -> {
                        BookDao bookDao = new BookDaoImpl(DomaConfig.singleton());

                        List<Book> books = List.of(
                                Book.create("978-4295008477", "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]", 2860, LocalDate.of(2020, 3, 13), "java"),
                                Book.create("978-4621303252", "Effective Java 第3版", 4400, LocalDate.of(2018, 10, 30), "java"),
                                Book.create("978-4774189093", "Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278, LocalDate.of(2017, 4, 18), "java"),
                                Book.create("978-4798161488", "MySQL徹底入門 第4版 MySQL 8.0対応", 4180, LocalDate.of(2020, 7, 6), "mysql"),
                                Book.create("978-4798147406", "詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド", 3960, LocalDate.of(2016, 8, 26), "mysql")
                        );

                        books.forEach(bookDao::insert);
                    });

            ITable actualTable =
                    databaseConnection
                            .createQueryTable("query_result", "select * from book where category = 'mysql' order by isbn asc");

            IDataSet expectedDataSet =
                    new FlatXmlDataSetBuilder()
                            .build(getClass().getClassLoader().getResourceAsStream("DbUnitGettingStartedTest.createDataSetFromQuery.xml"));
            ITable expectedTable = expectedDataSet.getTable("book");

            Assertion.assertEquals(expectedTable, actualTable);
        } finally {
            if (databaseConnection != null) {
                databaseConnection.close();
            }
        }
    }

この部分ですね。先ほどの場合はデータセットとして選択した範囲および、テーブルに関してはそのテーブルの全データが対象になりますが、
今回はカテゴリーがmysqlのものに絞っています。

            ITable actualTable =
                    databaseConnection
                            .createQueryTable("query_result", "select * from book where category = 'mysql' order by isbn asc");

この場合はテーブルのデータとして扱うことになるので、アサーションもテーブル単位になります。

            IDataSet expectedDataSet =
                    new FlatXmlDataSetBuilder()
                            .build(getClass().getClassLoader().getResourceAsStream("DbUnitGettingStartedTest.createDataSetFromQuery.xml"));
            ITable expectedTable = expectedDataSet.getTable("book");

            Assertion.assertEquals(expectedTable, actualTable);

用意したXMLファイルはこちら。カテゴリーがmysqlとなっているもののみになりますね。

src/test/resources/DbUnitGettingStartedTest.createDataSetFromQuery.xml

<?xml version="1.0" encoding="UTF-8"?>
<dataset>
    <book isbn="978-4798147406" title="詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド" price="3960" publish_date="2016-08-26" category="mysql"/>
    <book isbn="978-4798161488" title="MySQL徹底入門 第4版 MySQL 8.0対応" price="4180" publish_date="2020-07-06" category="mysql"/>
</dataset>

データセット内のデータは、主キーによりソートされます。このソートキーを変更するには、SortedTableクラスを使います。

    @Test
    public void sortTable() throws DatabaseUnitException, SQLException {
        DatabaseConnection databaseConnection = null;
        try (Connection conn = DriverManager
                .getConnection(
                        "jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&characterSetResults=utf-8&connectionCollation=utf8mb4_0900_bin",
                        "kazuhira",
                        "password");) {
            databaseConnection = new MySqlConnection(conn, null);

            DomaConfig
                    .singleton()
                    .getTransactionManager()
                    .required(() -> {
                        BookDao bookDao = new BookDaoImpl(DomaConfig.singleton());

                        List<Book> books = List.of(
                                Book.create("978-4295008477", "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]", 2860, LocalDate.of(2020, 3, 13), "java"),
                                Book.create("978-4621303252", "Effective Java 第3版", 4400, LocalDate.of(2018, 10, 30), "java"),
                                Book.create("978-4774189093", "Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278, LocalDate.of(2017, 4, 18), "java"),
                                Book.create("978-4798161488", "MySQL徹底入門 第4版 MySQL 8.0対応", 4180, LocalDate.of(2020, 7, 6), "mysql"),
                                Book.create("978-4798147406", "詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド", 3960, LocalDate.of(2016, 8, 26), "mysql")
                        );

                        books.forEach(bookDao::insert);
                    });

            IDataSet actualDataSet = databaseConnection.createDataSet();
            SortedTable actualTable = new SortedTable(actualDataSet.getTable("book"), new String[]{"price"});
            actualTable.setUseComparable(true);

            IDataSet expectedDataSet =
                    new FlatXmlDataSetBuilder()
                            .build(getClass().getClassLoader().getResourceAsStream("DbUnitGettingStartedTest.sortTable.xml"));
            ITable expectedTable = expectedDataSet.getTable("book");
            //SortedTable expectedTable = new SortedTable(expectedDataSet.getTable("book"), new String[]{"price"});
            //expectedTable.setUseComparable(true);

            Assertion.assertEquals(expectedTable, actualTable);
        } finally {
            if (databaseConnection != null) {
                databaseConnection.close();
            }
        }
    }

こんな感じで、テーブルをさらにSortedTableクラスでラップします。SortedTableクラスのインスタンス作成後に、

SortedTable#setUseComparableメソッドを呼び出す必要があるようです。

            IDataSet actualDataSet = databaseConnection.createDataSet();
            SortedTable actualTable = new SortedTable(actualDataSet.getTable("book"), new String[]{"price"});
            actualTable.setUseComparable(true);

今回は、価格でソートしました。ソート順の指定はできないようです。というか、ソートを行っているのはデータベース上ではなく
Java上でですね。

https://sourceforge.net/p/dbunit/code.git/ci/dbunit-2.7.3/tree/src/main/java/org/dbunit/dataset/SortedTable.java

用意したXMLファイルは、こちら。あらかじめ価格でソートしておきました。

src/test/resources/DbUnitGettingStartedTest.sortTable.xml

<?xml version="1.0" encoding="UTF-8"?>
<dataset>
    <book isbn="978-4295008477" title="新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]" price="2860" publish_date="2020-03-13" category="java"/>
    <book isbn="978-4774189093" title="Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで" price="3278" publish_date="2017-04-18" category="java"/>
    <book isbn="978-4798147406" title="詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド" price="3960" publish_date="2016-08-26" category="mysql"/>
    <book isbn="978-4798161488" title="MySQL徹底入門 第4版 MySQL 8.0対応" price="4180" publish_date="2020-07-06" category="mysql"/>
    <book isbn="978-4621303252" title="Effective Java 第3版" price="4400" publish_date="2018-10-30" category="java"/>
</dataset>

よってこちらはソートしなくてもよいのですが、ソートする場合はコメントアウトしている箇所のような感じになります。

            ITable expectedTable = expectedDataSet.getTable("book");
            //SortedTable expectedTable = new SortedTable(expectedDataSet.getTable("book"), new String[]{"price"});
            //expectedTable.setUseComparable(true);

アサーション時に、データのソート結果が合わないとテストが失敗するので注意しましょう。

DatabaseOperationクラスを使ってみる

最後に、DatabaseOperationクラスを使ってみましょう。このクラスを使うと、テスト前後で行いたいようなデータセットの操作を簡単に
行えます。

ただ、今回はテスト前後で実行するのではなく、テストコード内でアサーションと組み合わせてDatabaseOperationクラスの機能自体を
確認していこうかなと思います。

最初にテストコードを丸ごと載せてしまいましょう。

src/test/java/org/littlewings/dbunit/DbUnitOperationTest.java

package org.littlewings.dbunit;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.time.LocalDate;
import java.util.List;

import org.dbunit.Assertion;
import org.dbunit.DatabaseUnitException;
import org.dbunit.database.DatabaseConnection;
import org.dbunit.dataset.IDataSet;
import org.dbunit.dataset.ITable;
import org.dbunit.dataset.xml.FlatXmlDataSetBuilder;
import org.dbunit.ext.mysql.MySqlConnection;
import org.dbunit.operation.DatabaseOperation;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

public class DbUnitOperationTest {
    @BeforeAll
    public static void setUpAll() {
        BookDao bookDao = new BookDaoImpl(DomaConfig.singleton());
        DomaConfig
                .singleton()
                .getTransactionManager()
                .required(() -> bookDao.createTableIfExistsRecreate());
    }

    @Test
    public void test() throws SQLException, DatabaseUnitException {
        DatabaseConnection databaseConnection = null;
        try (Connection conn = DriverManager
                .getConnection(
                        "jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&characterSetResults=utf-8&connectionCollation=utf8mb4_0900_bin",
                        "kazuhira",
                        "password");) {
            databaseConnection = new MySqlConnection(conn, null);

            // INSERT Operation
            IDataSet insertedDataSet =
                    new FlatXmlDataSetBuilder()
                            .build(getClass().getClassLoader().getResourceAsStream("DbUnitOperationTest.test.xml"));

            DatabaseOperation.INSERT.execute(databaseConnection, insertedDataSet);
            // JUnit 5 Assersions
            Assertions.assertEquals(2, databaseConnection.getRowCount("book"));

            DomaConfig
                    .singleton()
                    .getTransactionManager()
                    .required(() -> {
                        BookDao bookDao = new BookDaoImpl(DomaConfig.singleton());

                        List<Book> books = List.of(
                                Book.create("978-4295008477", "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]", 2860, LocalDate.of(2020, 3, 13), "java"),
                                Book.create("978-4621303252", "Effective Java 第3版", 4400, LocalDate.of(2018, 10, 30), "java"),
                                Book.create("978-4774189093", "Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278, LocalDate.of(2017, 4, 18), "java"),
                                Book.create("978-4798161488", "MySQL徹底入門 第4版 MySQL 8.0対応", 4180, LocalDate.of(2020, 7, 6), "mysql"),
                                Book.create("978-4798147406", "詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド", 3960, LocalDate.of(2016, 8, 26), "mysql")
                        );

                        books.forEach(bookDao::insert);
                    });

            Assertions.assertEquals(7, databaseConnection.getRowCount("book"));

            // REFRESH(update or insert) Operation
            IDataSet refreshedDataSet =
                    new FlatXmlDataSetBuilder()
                            .build(getClass().getClassLoader().getResourceAsStream("DbUnitOperationTest.test.xml"));

            DatabaseOperation.REFRESH.execute(databaseConnection, refreshedDataSet);
            // JUnit 5 Assersions
            Assertions.assertEquals(7, databaseConnection.getRowCount("book"));
            ITable categories = databaseConnection.createQueryTable("result", "select category from book group by category order by category");
            Assertions.assertEquals("java", categories.getValue(0, "category"));
            Assertions.assertEquals("linux", categories.getValue(1, "category"));
            Assertions.assertEquals("mysql", categories.getValue(2, "category"));

            // CLEAN_INSERT(delete all + insert) Operation
            DatabaseOperation.CLEAN_INSERT.execute(databaseConnection, insertedDataSet);

            IDataSet actualDataSet = databaseConnection.createDataSet();

            // JUnit 5 Assersions
            Assertions.assertEquals(2, databaseConnection.getRowCount("book"));
            // DbUnit Assertion
            Assertion.assertEquals(insertedDataSet, actualDataSet);
        } finally {
            if (databaseConnection != null) {
                databaseConnection.close();
            }
        }
    }
}

先ほどのテストコードと同様、1回目にテーブルをdrop & createすることにします。

    @BeforeAll
    public static void setUpAll() {
        BookDao bookDao = new BookDaoImpl(DomaConfig.singleton());
        DomaConfig
                .singleton()
                .getTransactionManager()
                .required(() -> bookDao.createTableIfExistsRecreate());
    }

XMLファイルでのデータセットも用意。

src/test/resources/DbUnitOperationTest.test.xml

<?xml version="1.0" encoding="UTF-8"?>
<dataset>
    <book isbn="978-4797397642" title="本気で学ぶ Linux実践入門 サーバ運用のための業務レベル管理術" price="3278" publish_date="2019-05-30" category="linux"/>
    <book isbn="978-4798155760" title="Ubuntuサーバー徹底入門" price="4180" publish_date="2018-06-13" category="linux"/>
</dataset>

DatabaseOperationクラスを使ったデータセットの操作に絞って見ていきます。

まずは、DatabaseOperation.INSERTで用意したXMLファイルでのデータセットの内容をinsertします。

            // INSERT Operation
            IDataSet insertedDataSet =
                    new FlatXmlDataSetBuilder()
                            .build(getClass().getClassLoader().getResourceAsStream("DbUnitOperationTest.test.xml"));

            DatabaseOperation.INSERT.execute(databaseConnection, insertedDataSet);
            // JUnit 5 Assersions
            Assertions.assertEquals(2, databaseConnection.getRowCount("book"));

DatabaseConnection#getRowCountを使うと、対象のテーブルのレコード数を取得できます。

ここでは、DatabaseOperation.INSERT#executeメソッド実行後に、用意したXMLファイルに含まれるデータ件数、つまり2レコードが
存在していることが確認できました。

次に、Doma 2を使ってデータを登録。

            DomaConfig
                    .singleton()
                    .getTransactionManager()
                    .required(() -> {
                        BookDao bookDao = new BookDaoImpl(DomaConfig.singleton());

                        List<Book> books = List.of(
                                Book.create("978-4295008477", "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]", 2860, LocalDate.of(2020, 3, 13), "java"),
                                Book.create("978-4621303252", "Effective Java 第3版", 4400, LocalDate.of(2018, 10, 30), "java"),
                                Book.create("978-4774189093", "Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278, LocalDate.of(2017, 4, 18), "java"),
                                Book.create("978-4798161488", "MySQL徹底入門 第4版 MySQL 8.0対応", 4180, LocalDate.of(2020, 7, 6), "mysql"),
                                Book.create("978-4798147406", "詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド", 3960, LocalDate.of(2016, 8, 26), "mysql")
                        );

                        books.forEach(bookDao::insert);
                    });

            Assertions.assertEquals(7, databaseConnection.getRowCount("book"));

テーブル内には7件のデータがあることが確認できます。

DatabaseOperation.REFRESHを使うと、対象のデータセットを使ってupdate、データがなければinsertを行います。

            // REFRESH(update or insert) Operation
            IDataSet refreshedDataSet =
                    new FlatXmlDataSetBuilder()
                            .build(getClass().getClassLoader().getResourceAsStream("DbUnitOperationTest.test.xml"));

            DatabaseOperation.REFRESH.execute(databaseConnection, refreshedDataSet);
            // JUnit 5 Assersions
            Assertions.assertEquals(7, databaseConnection.getRowCount("book"));
            ITable categories = databaseConnection.createQueryTable("result", "select category from book group by category order by category");
            Assertions.assertEquals("java", categories.getValue(0, "category"));
            Assertions.assertEquals("linux", categories.getValue(1, "category"));
            Assertions.assertEquals("mysql", categories.getValue(2, "category"));

よって、データの数は変わっていません。

最後にDatabaseOperation.CLEAN_INSERT。こちらは対象のデータセットの範囲を全件削除した後、指定のデータセットのデータを
登録します。

            // CLEAN_INSERT(delete all + insert) Operation
            DatabaseOperation.CLEAN_INSERT.execute(databaseConnection, insertedDataSet);

            IDataSet actualDataSet = databaseConnection.createDataSet();

            // JUnit 5 Assersions
            Assertions.assertEquals(2, databaseConnection.getRowCount("book"));
            // DbUnit Assertion
            Assertion.assertEquals(insertedDataSet, actualDataSet);

よって、今回Doma 2を使って登録したデータは削除され、XMLファイルで用意した2件だけが残っている状態になります。

これらの操作は、このように独立したクラスやその組み合わせで実現されています。

    /** @see DummyOperation */
    public static final DatabaseOperation NONE = new DummyOperation();
    /** @see UpdateOperation */
    public static final DatabaseOperation UPDATE = new UpdateOperation();
    /** @see InsertOperation */
    public static final DatabaseOperation INSERT = new InsertOperation();
    /** @see RefreshOperation */
    public static final DatabaseOperation REFRESH = new RefreshOperation();
    /** @see DeleteOperation */
    public static final DatabaseOperation DELETE = new DeleteOperation();
    /** @see DeleteAllOperation */
    public static final DatabaseOperation DELETE_ALL = new DeleteAllOperation();
    /** @see TruncateTableOperation */
    public static final DatabaseOperation TRUNCATE_TABLE = new TruncateTableOperation();
    /**
     * @see DeleteAllOperation
     * @see InsertOperation
     * @see CompositeOperation
     */
    public static final DatabaseOperation CLEAN_INSERT = new CompositeOperation(
            DELETE_ALL, INSERT);

https://sourceforge.net/p/dbunit/code.git/ci/dbunit-2.7.3/tree/src/main/java/org/dbunit/operation/DatabaseOperation.java

DatabaseOperationクラスの実装が気になるのであれば、ドキュメントの説明およびソースコードを見ると良いでしょう。

Core Components / DatabaseOperation

https://sourceforge.net/p/dbunit/code.git/ci/dbunit-2.7.3/tree/src/main/java/org/dbunit/operation/

その他

今回は扱いませんでした、IDatabaseTesterインターフェースというここまで見てきたような操作をある程度まとめられるクラスも
存在します。実装としてはJdbcDatabaseTesterクラスやDataSourceDatabaseTesterクラスなどを使うのかなと思います。

Getting Started

本来はDBTestCaseクラスを継承できない場合に使うようなのですが、DBTestCaseクラスは古いJUnit向けのものなので、現在は
使うことはないでしょう。

IDatabaseTesterインターフェースの実装クラスを使うか、自分で作成したりしてこのあたりの処理はうまく扱えるように考えて
いくんでしょうね。

まとめ

DbUnitを試してみました。

最初はWebサイトを見て「更新されていないのかな?」と思ったのですが、現在もJUnit 5には対応していないとはいえメンテナンスは
続いているようですし、使い方を押さえておいてもよいかなと思いました。

今回は、こんなところで。

Spring Boot/Spring Frameworkのテストに関するドキュメントをざっくり眺めてみる

これは、なにをしたくて書いたもの?

Springに関するテストの話について、あまり知らないのでちょっと見ておこうかな、と思いまして。

さらっと、Springのテストに関するドキュメントを眺めてみます。

Springのテストに関する話

今回扱うのは、Spring Bootのテストに関する話とSpring Frameworkのテストに関する話(Spring Test)について見ていこうかなと思います。

対象のバージョンは、以下とします。

Spring Bootのテストに関するドキュメントはこちら。

Core Features / Testing

Spring Frameworkのテスト(Spring Test)に関するドキュメントは、こちら。

Testing

それぞれ見ていきますが、今回は単体テストについて

Spring Bootとテスト

まずは、Spring Bootに関するテストについて、見ていきます。

Core Features / Testing

こちらのドキュメントは、4つのセクションに分かれています。

注目して読むべきは、Springアプリケーションのテストに関する話と、Spring Bootアプリケーションに関するテストの話ですね。

ところで、Test Scope DependenciesのSpring Testの箇所には、以下のように書かれています。

Spring Test & Spring Boot Test: Utilities and integration test support for Spring Boot applications.

テストのユーティリティと、Spring Bootアプリケーションのインテグレーションテストをサポートするものだ、と書かれていますね。

また、 Core Features / Testing / Testing Spring Applications を見ると、以下のように書かれています。

One of the major advantages of dependency injection is that it should make your code easier to unit test. You can instantiate objects by using the new operator without even involving Spring. You can also use mock objects instead of real dependencies.

Springを使わず、new演算子を使ったインスタンス化、そしてモックを利用して単体テストができるということが書かれています。

SpringのApplicationContextやSpring Test、Spring Boot Testを使うのはインテグレーションテストであるとも書かれています。

Often, you need to move beyond unit testing and start integration testing (with a Spring ApplicationContext).

The Spring Framework includes a dedicated test module for such integration testing. You can declare a dependency directly to org.springframework:spring-test or use the spring-boot-starter-test “Starter” to pull it in transitively.

つまり、Springのテストに関する機能は、インテグレーションテストを対象にしていることになります。

Spring Frameworkとテスト

ここで、Spring Frameworkのテストに関するドキュメントを見てみます。

Testing

このドキュメントは、主に次の2つの内容で構成されています。

ドキュメントの冒頭を見ると、このページは単体テストのベストプラクティスとSpringのインテグレーションテストに関するサポートに
ついて書いてある、との記載があります。

This chapter covers Spring’s support for integration testing and best practices for unit testing.

ここでも、Spring Frameworkの提供する機能の多くはインテグレーションテスト向けのものだということになりますね。

Spring Framework単体テスト

Spring Frameworkのテストに関する話題はインテグレーションテストに関するものだということはわかりましたが、単体テストのセクションも
ありましたね。こちらも追ってみましょう。

Testing / Unit Testing

このセクションの冒頭を見ると、以下のようなことが書かれています。

  • POJOは、Springやその他のコンテナを使用せずにnew演算子インスタンス化し、JUnitなどでテストできるようにすべきだ
  • コードの分離は、モックを使って実現可能
  • Springは、単体テスト向けのモックオブジェクトとテストサポートを提供する

基本的には単体テストはSpringを使わずに実行できるべきであり、ランタイムのセットアップが不要になるので単体テストは高速に
実行されることを期待しています。

True unit tests typically run extremely quickly, as there is no runtime infrastructure to set up. Emphasizing true unit tests as part of your development methodology can boost your productivity.

とはいえ、単体テスト向けのSpringのサポートもある、という話のようです。

提供されるのは、以下ですね。

  • モックオブジェクト
    • Environment
      • org.springframework.mock.envパッケージに含まれるモック実装
      • MockEnvironmentMockPropertySource
    • JNDI
      • org.springframework.mock.jndiパッケージに含まれるモック実装
      • ただし非推奨化されており、Simple-JNDI等で代替される
    • Servlet API
      • org.springframework.mock.webパッケージに含まれるモック実装
    • Spring Web Reactive
      • org.springframework.mock.http.server.reactiveパッケージに含まれるモック実装
  • 単体テストサポート
    • General Testing Utilities
      • org.springframework.test.utilパッケージに含まれるユーティリティ
      • リフレクションのユーティリティReflectionTestUtilsAOP関連のユーティリティAopTestUtilsなど
    • Spring MVC Testing Utilities
      • org.springframework.test.webパッケージに含まれるユーティリティ
      • Controllerのテストを行うには、ModelAndViewAssertなどを使い、Servlet APIのモック(MockHttpServletRequestMockHttpSessionと組み合わせる

Spring Frameworkとインテグレーションテスト

Spring Frameworkのテストに関するドキュメントには単体テストとインテグレーションテストの内容が書かれているわけですが、
ページ内のボリュームを見ると大半はインテグレーションテスト向けのものであることがわかります。

このセクションの概要を見てみます。

Testing / Integration Testing / Overview

It is important to be able to perform some integration testing without requiring deployment to your application server or connecting to other enterprise infrastructure.

アプリケーションサーバーへのデプロイや、他のエンタープライズインフラストラクチャーへの接続なしにインテグレーションテストを
実行できることを重要視しています。

これで、主にSpringのコンテキストを正しく構築できているか、データベースアクセスなどをテストできる、としています。

このセクションで書かれているSpring Test(spring-test)のorg.springframework.testパッケージには、Springのコンテナを使った
インテグレーションテストに関するクラスが含まれています。

これらを使用したテストはアプリケーションサーバーやその他の環境に依存しておらず、単体テストよりは遅いものの、アプリケーションサーバーに
デプロイしてのSeleniumでのテストやリモートテストよりもずっと高速です。

Springにおける、インテグレーションテストの目標は以下に書かれています。

Testing / Integration Testing / Goals of Integration Testing

こういった感じですね。

また、以下の機能が含まれています。

Spring Bootアプリケーションのテスト

ところで、Spring Bootのテストに関するセクションには、次の2つが含まれていました。

Springアプリケーションのテストは、先ほどまで書いていたSpring Testの話です。

ではSpring Bootアプリケーションのテストに関する話は?というと、Spring BootアプリケーションもSpringアプリケーションであるため、
特別なことはないと書かれています。

A Spring Boot application is a Spring ApplicationContext, so nothing very special has to be done to test it beyond what you would normally do with a vanilla Spring context.

ですが、いくつかの機能は提供するようです。以下に一部を記載しますが、基本的にはAuto Configurationに関する機能のようですね。

まとめ

Springのテストに関するドキュメントを見て、Springのテストのサポートに対する考え方や、カバーするフェーズ(単体テスト
インテグレーションテスト)、どのような機能が提供するかをざっくり眺めてみました。

雰囲気はおよそわかったので、そのうちいくつかの機能を使ってテストに関する勉強をしていきたいですね。