DBUnitを用いた単体テスト基盤について

こんにちは。今回は、 グリモア~私立グリモワール魔法学園~ で使用している単体テスト基盤についてご紹介します。

このテスト基盤は、Javaのサーバーサイドの単体テストをJUnitを用いて行う際に使用されています。

この記事では、単体テスト基盤を導入する前にあがっていたテストデータの管理とテストデータの切り替えの問題点と、問題解決のために開発された、単体テスト基盤のコンセプトと実装についてご紹介します。

弊社の使用している開発環境やミドルウェア、ライブラリは下記の通りです。

  • アプリケーション
    • Java 1.7.0
    • Spring 4.0.2
  • ライブラリ
    • JUnit 4.11
    • MyBatis 3.2.4
    • dbunit 2.5.0
  • データベース
    • MySQL 5.6.26
  • 開発環境
    • Eclipse 4.4

単体テストでの問題点

まず、データベースを用いた単体テストをする際に、以下の3点が問題となっていました。

  1. テストデータの管理方法について
  2. テストごとのテストデータへの切り替え
  3. テスト終了後に元の環境のデータへ差し戻し

まず1については、コードベースで管理するか、SQLファイルで管理するか、またはCSVファイルで管理するかについて検討が必要でした。

次に、開発者・CIサーバーなどのすべての環境で単体テストの結果が再現するように、テスト実行時にテストデータへの差し替えが必要になります。この作業を手作業で行うと、ヒューマンエラーが発生したり、テストの時間もかかるために気軽にテストが実行できないという問題があります。

また、テスト終了後に開発中に手元で管理していたデータがテストデータに上書きされると開発効率が低下するため、テスト終了後は元の環境のデータへ差し戻しが必要でした。

これらの問題点を解決し、テストをより手軽に実行できるようにするため、単体テスト基盤の開発に至りました。

単体テスト基盤の実装

問題点1については、CSVによってテストデータを管理し、DBUnitを用いて簡単にテストデータを挿入できる仕組みの実装しました。

2と3についてはDBUnitのTransactionAwareDatasourceProxyという仕組みを利用することで、テストデータの投入からテスト実行までを同一トランザクションで扱い、最後にロールバックすることで実現しました。

DBUnitの導入

DBUnitとは、データベースにアクセスする単体テスト用のライブラリで、主にデータベースへのデータの登録や削除を、CSVやXMLファイルで実行する際に使用します。
テスト基盤は、このDBUnitのメソッドを組み合わせて実装されています。

導入手順は以下のとおりです。

1.下記のサイトからjarファイルをダウンロードします。

http://dbunit.sourceforge.net/

先述した通り、弊社ではdbunit-2.5.0.jarを使っています。

2.任意のディレクトリにjarファイルを配置します。

3.Eclipseでテストを実行したいプロジェクトのビルドパスにダウンロードしたdbunitのjarを追加します。

以上で使用する準備は完了です。
これでプロジェクトの各クラスからDBUnitのメソッドを呼び出せるようになります。

CSVの管理

テストデータは前述の通りCSVで管理することにしました。1ファイル1テーブルで扱い、CSVの中身は1行目にカラム名、2行目以降にレコードを1行ずつ記述します。

例えば、sampleテーブルに3レコード挿入するCSVは sample.csv というファイル名となり、以下の内容になります。

id,name
111,"hoge"
222,"fuga"
333,"piyo"

また、複数テーブルのCSVをデータセットとして管理し、まとめてデータが挿入できると便利なので、ディレクトリ単位でデータセットを管理できるようにしました。 この時にテーブル設計の制約上、どの順番でテーブルにデータを挿入するか指定できる必要がありました。そこで、ディレクトリの中に table-ordering.txt というファイルを配置し、以下のようにテーブルの挿入順序を指定できるようにしました。

sample
sample02

まとめると、テストデータは、以下のようなディレクトリ構成で管理されます。

sample_group # データセット(任意の名前のディレクトリ)
├── table-ordering.txt # 挿入順序の管理
├── sample.csv # sampleテーブルにデータ挿入
└── sample02.csv # sample02テーブルにデータ挿入

テストデータの挿入

テストデータの投入には、DBUnitで用意されているCsvDataSetとDatabaseOperationを用いています。

CsvDataSet
CSVファイルを読み込んでオブジェクトとして扱うクラス

DatabaseOperation
DBへのデータ挿入・削除を実行するクラス

サービスクラスのテストでは基本すべてのテストでテストデータが必要なので、ベースクラスを作成して、そちらにテストデータを管理するメソッドを追加します。


package test.service;
 
import java.io.File;
import java.sql.SQLException;
 
import javax.annotation.Resource;
 
import org.dbunit.DatabaseUnitException;
import org.dbunit.database.DatabaseConfig;
import org.dbunit.database.DatabaseConnection;
import org.dbunit.dataset.csv.CsvDataSet;
import org.dbunit.ext.mysql.MySqlDataTypeFactory;
import org.dbunit.operation.DatabaseOperation;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
 
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration("classpath:TestApplicationContext.xml")
public abstract class TestServiceSample {
 
    /** ApplicationContext */
    @Autowired
    protected ApplicationContext context;
 
    /** データソース */
    @Resource
    private TransactionAwareDataSourceProxy masterDataSource;
 
    /** CSVデータ格納ディレクトリ */
    private static final String CSV_DIRECTORY = "src/test/resources/csv/;"
 
    /**
     * テストデータ投入メソッド
     *
     * @param datas テストデータ
     * @throws Exception 例外
     */
    public void insertData(String... datas) throws Exception {
        DatabaseConnection dbConn = null;
        try {
            // DatabaseConnectionの作成
            dbConn = openDbConn();
            for (String data : datas) {
                // データセットの取得
                CsvDataSet dataSet = new CsvDataSet(new File(CSV_DIRECTORY + data));
                // セットアップ実行
                DatabaseOperation.CLEAN_INSERT.execute(dbConn, dataSet);
            }
        } finally {
            // DatabaseConnectionの破棄
            closeDbConn(dbConn);
        }
    }
 
    /**
     * テストデータ投入メソッド
     *
     * @param datas テストデータ
     * @throws Exception 例外
     */
    public void deleteData(String... datas) throws Exception {
        DatabaseConnection dbConn = null;
        try {
            // DatabaseConnectionの作成
            dbConn = openDbConn();
            for (String data : datas) {
                // データセットの取得
                CsvDataSet dataSet = new CsvDataSet(new File(CSV_DIRECTORY + data));
                // セットアップ実行
                DatabaseOperation.DELETE_ALL.execute(dbConn, dataSet);
            }
        } finally {
            // DatabaseConnectionの破棄
            closeDbConn(dbConn);
        }
    }
 
    /**
     * DBコネクションオープン
     *
     * @return DBコネクション
     * @throws DatabaseUnitException 例外
     */
    private DatabaseConnection openDbConn() throws DatabaseUnitException {
        DatabaseConnection databaseConnection = new DatabaseConnection(DataSourceUtils.getConnection(masterDataSource));
        DatabaseConfig config = databaseConnection.getConfig();
        config.setProperty(DatabaseConfig.PROPERTY_DATATYPE_FACTORY, new MySqlDataTypeFactory());
        return databaseConnection;
    }
 
    /**
     * DBコネクションクローズ
     *
     * @param dbConn DBコネクション
     * @throws SQLException 例外
     */
    private void closeDbConn(DatabaseConnection dbConn) throws SQLException {
        if (dbConn != null) {
            dbConn.close();
        }
    }
}

順番に実装を説明していきます。

まず、insertDataメソッドでCSVの挿入を行います。50行目で、テストデータのデータセットを読み込みます。CSVを配置したディレクトリを指定すると、その配下のtable-ordering.txtに記載されているテーブルのCSVが全て読み込まれます。

table-ordering.txtに記載が漏れていたり、同じディレクトリ内にCSVが入っていないと登録されないのでご注意ください。

次の52行目で、データベースへの挿入を行っています。CLEAN_INSERTを指定すると、対象テーブルを全て削除した状態で挿入が行われます。

このままでは、テストデータが反映されてしまうため、ロールバックを行う設定を記述します。先ほどのベースクラスで指定した ApplicationContextSample.xml を以下のように記述します。

<?xml version="1.0" encoding="UTF-8"?>
 
<beans xmlns="http://www.springframework.org/schema/beans" xmlns:mvc="http://www.springframework.org/schema/mvc" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:context="http://www.springframework.org/schema/context" xmlns:util="http://www.springframework.org/schema/util" xmlns:tx="http://www.springframework.org/schema/tx" xmlns:aop="http://www.springframework.org/schema/aop" xsi:schemaLocation="http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop-4.0.xsd http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.0.xsd http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc-4.0.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.0.xsd" >
 
    <context:annotation-config />
 
    <!-- サービス定義 -->
    <bean id="testDataSource" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close">
        <property name="driverClassName" value="com.mysql.jdbc.Driver" />
        <property name="url" value="jdbc:mysql://mysqlhost/sample?useUnicode=true&characterEncoding=UTF-8&socketTimeout=3000&connectTimeout=3000" />
        <property name="username" value="root" />
        <property name="password" value="" />
        <property name="initialSize" value="30" />
        <property name="maxActive" value="2" />
        <property name="maxIdle" value="2" />
        <property name="minIdle" value="2" />
        <property name="maxWait" value="120000" />
        <property name="validationQuery" value="select 0" />
    </bean>
    <!-- DataSourceをTransactionAwareDataSourceProxyにし、dbUnitテストデータ登録データソースと同じトランザクションにする -->
    <bean id="masterDataSource" class="org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy">
        <constructor-arg ref="testDataSource" />
    </bean>
</beans>

ポイントは22行目で、masterDataSourceにTransactionAwareDataSourceProxyを用いている点で、これでテストデータの挿入からテスト実行までが同一トランザクションで扱われ、テストメソッド実行後にロールバックが実施されます。

テスト実行中のデータベースへの操作は反映されないため、これで終了後のデータの切り戻しが実現できます。

まとめ

単体テストを実施する上での問題点を基盤の開発により解決することで、スピードの向上が実現でき、システム全体の品質の向上につなげることが出来ました。

実際にリリース後の不具合の数も、既存の運用タイトルに比べ大幅に抑えられています。
今後もアプリのソースだけでなく、テストのような表面に見えづらい部分でも改善を重ねて、クオリティを上げていきたいと思います。