理系学生日記

おまえはいつまで学生気分なのか

DBUnit の IDataSet を JavaBean で表現する

DBUnit においては、DB に投入するデータセットは IDataSet と呼ばれる Interface によって規定されている。この記述だけではイメージが分かないが、これを実装したものをいくつか挙げれば、イメージを掴んで頂けると思う。

要するに IDataSet を実装することにより、どんな媒体からでも DBUnit に対するテストデータを読み込むことができる、ということになる。

問題意識

テストデータを用意する上でダルいのは、そのテストデータの整備、およびそのメンテである。
end-to-end のテストを実施する際に用意すべきテストデータは多数のテーブルに及ぶとともに、ちょっとでも ER に変更が生じれば、個々のテストケースのテストデータを逐一見直さなければならない。ダルい。ダルすぎる。これをアプリケーションで解決したい。アプリケーションで解決するためには、テストデータの表現を CSV や XML ではなく、Java の世界、コードの世界に持ってこなければならない。
このため、テストデータを表現する JavaBean から IDataSet を作成してみた。

実装方針

まず、IDataSet とは何なのかというと、以下 7 つのメソッドの実装を要求する Interface になっている。

  • ITable getTable(String tableName)
    • 引数のテーブル名が指すテーブルに含まれる一連のデータを表現する ITable を返却する
  • ITableMetaData getTableMetaData(String tableName)
    • 引数のテーブル名が指すテーブルのメタデータ (カラム数やテーブル名等) を表現する ITableMetaData を返却する
  • String[] getTableNames()
    • データセットに含まれる、(テストデータを含む)テーブル名の配列を返却する
  • ITable[] getTables()
    • データセットに含まれる全テーブル情報を返却する
  • boolean isCaseSensitiveTableNames()
    • テーブル名が caseSensitive だったら true を返却する
  • ITableIterator iterator()
  • ITableIterator reverseIterator()
    • getTable() が返却するテーブル情報を走査するための Iterator を返却する

かなりメンドくさそうだが、メソッド実装用に、DBUnit が多くのデフォルト実装を用意してくれているので、それを利用すれば実装量は大したことはない。
今回の実装方針は、テストデータを JavaBean で表現することとしたため、テストデータを含むテーブルの表現である ITable と、データセットの表現である IDataSet を実装することにした。
JavaBean が個々のテーブルのテストデータ 1 レコードを表現し、その Bean のフィールドがテーブルのカラムを意味するものとする。

実装

実装自体は、YAML で DBUnit の IDataSet を実装していた JYaml - Yaml library for the Java language を参考にした。インスパイアした。された。

最初に ITable 実装を示す。
JavaBean のフィールドをテーブルのカラム名に変換するため、camelCase を snake_case に変換する必要があり、このコンバータとしての Util クラスを挟んでいるが、Google の guavaあたりにもこういう util あったと思うので、適宜使えば良いと思う。

public class JavaBeanTable implements ITable {

    /** テストデータをレコードとして保持するリスト */
    private List<BeanMap> dataList;

    /** テーブルのメタ情報 */
    private ITableMetaData meta;

    /**
     * テーブル情報を構築する
     * @param name テーブル名
     * @param dataList テストデータのレコード
     */
    public JavaBeanTable(String name, List<?> dataList) {
        if ( dataList == null || dataList.isEmpty() ) {
            throw new IllegalArgumentException(String.format("dataList が空のため、[%s] テーブルのメタデータが取得できません", name));
        }

        this.meta = createMeta(name, dataList.get(0));

        List<BeanMap> tmpList = new ArrayList<>();
        for (Object e : dataList) {
            tmpList.add(new BeanMap(e));
        }

        this.dataList = tmpList;
    }

    /**
     * テストデータを追加する
     * @param dataList テストデータを Java Bean として含むリスト
     * @return 自分自身
     */
    public JavaBeanTable appendDataList(List<?> dataList) {
        List<BeanMap> tmpList = new ArrayList<>();
        for (Object e : dataList) {
            tmpList.add(new BeanMap(e));
        }

        this.dataList.addAll(tmpList);
        return this;
    }

    /**
     * テストデータのレコード数を返却する
     * @return テストデータのレコード数
     */
    @Override
    public int getRowCount() {
        return dataList.size();
    }

    /**
     * 対象テーブルの (DBUnit で言う) メタデータを返却する
     * @param name テーブル名
     * @param record 対象テーブルのレコードを表現する Java Bean。ここで指定されたレコードからメタデータが抽出される
     * @return
     */
    private <E> ITableMetaData createMeta(String name, E record) {
        BeanMap beanMap = new BeanMap(record);
        Column[] columns = new Column[beanMap.keySet().size() - 1]; // -1 をしているのは、".class" フィールドが存在しているため

        // record からカラム名を抽出
        int counter = 0;
        for (Object obj : beanMap.keySet()) {
            String key = (String) obj;

            // class フィールドはテーブルのカラムになり得ないので無視
            if ("class".equals(key)) {
                continue;
            }

            // camelCase で表現されたフィールド名を snake_case に変換
            String snakeCase = StringCaseConverter.camelToSnake(key);

            DataType dataType = resolveDataType(beanMap.get(key));
            columns[counter++] = new Column(snakeCase, dataType);
        }
        return new DefaultTableMetaData(name, columns);
    }

    /**
     * 引数の値に対して、適切な DataType を返却する <br />
     *
     * 基本的には DataType.UNKNOWN を返却すれば良いはずだが、一部適切な型を返却しなければうまくいかない場合があり、
     * その際に適宜実装を修正していけばいいんじゃないかな。
     *
     * @param value 属性値
     * @return 属性値に対応する DataType
     */
    private DataType resolveDataType(Object value) {
        if ( value instanceof Boolean ) {
            return DataType.BOOLEAN;
        }
        else {
            return DataType.UNKNOWN;
        }
    }

    /**
     * テーブルのメタ情報を返却する
     */
    @Override
    public ITableMetaData getTableMetaData() {
        return this.meta;
    }

    /**
     * 指定された行数、列名のデータ値を返却する
     */
    @Override
    public Object getValue(int row, String columnName) throws DataSetException {
        if (this.dataList.size() <= row) {
            throw new RowOutOfBoundsException();
        }

        String camelCase = StringCaseConverter.snakeToCamel(columnName);
        return this.dataList.get(row).get(camelCase);
    }
}

続いて DataSet そのもの。

public class JavaBeanDataSet implements IDataSet {

    /** テーブル毎のテーブル情報(テストデータを含む) を保持する Map */
    private Map<String, JavaBeanTable> tableMap = new HashMap<String, JavaBeanTable>();

    /**
     * 指定したテーブルに対し、テストデータを追加する
     * @param tableName テーブル名
     * @param dataList テーブルに INSERT するテストデータ (Java Bean) のリスト
     * @return 自身を返却する (メソッドチェーンで使用することを想定)
     */
    public JavaBeanDataSet addTableData(String tableName, List<?> dataList) {

        if (!tableMap.containsKey(tableName)) {
            JavaBeanTable table = new JavaBeanTable(tableName, dataList);
            tableMap.put(tableName, table);
        }
        else {
            JavaBeanTable table = tableMap.get(tableName);
            table.appendDataList(dataList);
        }

        return this;
    }

    @Override
    public ITable getTable(String tableName) throws DataSetException {
        return tableMap.get(tableName);
    }

    @Override
    public ITableMetaData getTableMetaData(String arg0) throws DataSetException {
        return tableMap.get(arg0).getTableMetaData();
    }

    @Override
    public String[] getTableNames() throws DataSetException {
        return tableMap.keySet().toArray(new String[tableMap.size()]);
    }

    @Override
    public ITable[] getTables() throws DataSetException {
        return tableMap.values().toArray(new ITable[tableMap.size()]);
    }

    @Override
    public boolean isCaseSensitiveTableNames() {
        // Case Sensitivity は不要
        return false;
    }

    @Override
    public ITableIterator iterator() throws DataSetException {
        return new DefaultTableIterator(getTables());
    }

    @Override
    public ITableIterator reverseIterator() throws DataSetException {
        return new DefaultTableIterator(getTables(), true);
    }

}

これらを使えば、以下のようにしてデータセットを定義していくことができるようになる。

  JavaBeanDataSet dataSet = new JavaBeanDataSet();
  dataSet.addTableData("TABLE_A", Arrays.asList(testDataBean1, testDataBean2));

あとは、テストデータ作成用フレームワークというか、いわゆる fixture を作っていけばいいんじゃないかな。