DBUnitの空文字列とか日付とか使いづらいので直して使う

## 1. はじめに
Java+RDBMSの開発プロジェクトで、単体テストツールにDBUnitを使用するケースは未だ（2020年）に多いと思います。
DBUnitはDBの初期データと実行後のアサーションデータをxls形式やCSV形式を利用できることで非常に便利かつ開発省力化に有効なツールですが、Excelのデータ形式やPOIの仕様の問題で若干使いづらい点があります。

具体的には件名で挙げた通り、可変文字列型（VARCHAR型）の空文字列とNULLを分別できない点と、日付型（DATETIME、TIMESTAMP、DATE、TIME等）の値を正確に設定できない問題があります。

本稿では、この対策方法を記述します。
なお、本稿でのDbUnitのバージョンは2.5.4を使用しています。

## 2. DBUnitはどうなっているのか？
### 2.1 セル情報の読み込み
DBUnitでは`XlsTable`クラスの`getValue(int row, String column)`メソッドで、Excelのセルの値を取得してDBに設定する値に変換しています。

```java:XlsTable#getValueから抜粋
    int type = cell.getCellType();
    switch (type)
    {
        case HSSFCell.CELL_TYPE_NUMERIC:
            if (HSSFDateUtil.isCellDateFormatted(cell))
            {
                return cell.getDateCellValue();
            }
            return new BigDecimal(cell.getNumericCellValue());

        case HSSFCell.CELL_TYPE_STRING:
            return cell.getStringCellValue();

        case HSSFCell.CELL_TYPE_FORMULA:
            throw new DataTypeException("Formula not supported at row=" +
                    row + ", column=" + column);

        case HSSFCell.CELL_TYPE_BLANK:
            return null;

        case HSSFCell.CELL_TYPE_BOOLEAN:
            return cell.getBooleanCellValue() ? Boolean.TRUE : Boolean.FALSE;

        case HSSFCell.CELL_TYPE_ERROR:
            throw new DataTypeException("Error at row=" + row +
                    ", column=" + column);

        default:
            throw new DataTypeException("Unsupported type at row=" + row +
                    ", column=" + column);
    }
```

上記のコードからわかる通り、セルのデータ型が数値型で特定のフォーマットの場合に日付型としています。また、セルが空の場合にNULLとしています。
このため、DBに設定する日付型の値はExcelの日付データの精度によって差分が表示ます。たとえばExcel上では`2020/1/24 10:00`と入れたものがDBのDATETIME型のカラムには`2020/1/24 10:00:01`になることがあります。
また、セルの値が空の場合は一律nullになるため空文字列のデータを作ることができません。

### 2.2 セル情報への出力
DBUnitには、DB（だけに限らないのですが）から読み取ったデータをExcelファイルに出力する機能も提供しています。
Excelへの出力は`XlsDataSet`クラスの`write(IDataSet dataSet, OutputStream out)`メソッドで行っています。
`write`メソッドの内部でセルに設定する値の取得をしています。

```java:XlsDataSet#writeから抜粋
        // write table data
        for (int j = 0; j < table.getRowCount(); j++)
        {
            HSSFRow row = sheet.createRow(j + 1);
            for (int k = 0; k < columns.length; k++)
            {
                Column column = columns[k];
                Object value = table.getValue(j, column.getColumnName());
                if (value != null)
                {
                    HSSFCell cell = row.createCell((short)k);
                    cell.setEncoding(HSSFCell.ENCODING_UTF_16);
                    cell.setCellValue(DataType.asString(value));
                }
            }
        }
```

上記のコードで`IDataSet`内の`ITable`に格納されている値を取得してセルに設定をしています。
値が`null`の場合はセルの生成自体をしないため、Excelの表示上は空のセルとなります。

## 3. どう直すのか
上記で見たDBUnitのソースコードからDBUnitの処理の内容そのものの他に、データ型に対するExcelセル上の値の変換方法を利用者側で変更することができないこともわかります。
このため、DBUnitを都合よく使うために`XlsTable`と`XlsDataSet`のソースコードを修正して独自のクラスを作り、そのクラスからDBUnitを使用することにします。

### 3.1 セル情報の読み込み
`XlsTable#getValue`メソッドで、セルのデータ型が文字列型の場合でも日付型のパータンにマッチする場合は日付型を設定するように変更します。
また、セル上に`null`と文字列を記載した場合はDBにNULL値を設定するようにします。

```java:XlsTable#getValueを修正
    int type = cell.getCellType();
    switch (type)
    {
        case HSSFCell.CELL_TYPE_NUMERIC:
            if (HSSFDateUtil.isCellDateFormatted(cell))
            {
                return cell.getDateCellValue();
            }
            return new BigDecimal(cell.getNumericCellValue());

        case HSSFCell.CELL_TYPE_STRING:
            /* 独自実装（ここから） */
            String cellValue = cell.getRichStringCellValue().getString();

            // セルの値が"null"の場合はNULLを設定
            if ("null".equals(cellValue)) { return null; }

            // セルの値が"yyyy/MM/dd HH:mm:ss"形式の場合はDate型にパースして設定
            if (Pattern.compile("\\d{4}/\\d{2}/\\d{2} \\d{2}:\\d{2}:\\d{2}").matcher(cellValue).matches()) {
                return new SimpleDateFormat("yyyy/MM/dd HH:mm:ss").parse(cellValue);
            }

            // 上記以外はセルの値をそのまま設定
            return cellValue;
            /* 独自実装（ここまで） */

        case HSSFCell.CELL_TYPE_FORMULA:
            throw new DataTypeException("Formula not supported at row=" +
                    row + ", column=" + column);

        case HSSFCell.CELL_TYPE_BLANK:
            // セルが空の場合は空文字列を返却
            return "";

        case HSSFCell.CELL_TYPE_BOOLEAN:
            return cell.getBooleanCellValue() ? Boolean.TRUE : Boolean.FALSE;

        case HSSFCell.CELL_TYPE_ERROR:
            throw new DataTypeException("Error at row=" + row +
                    ", column=" + column);

        default:
            throw new DataTypeException("Unsupported type at row=" + row +
                    ", column=" + column);
    }
```

### 3.2 セル情報への出力
JUnitの初期データ設定とDBデータの比較だけであればセル情報の読み込みだけを直せばよいのですが、DBデータのxls形式出力も使用すると試験データの作成を効率化できます。
そのため、セル情報読み込みの修正に合わせて、セル情報への出力処理も修正します。

```java:XlsDataSet#writeを修正
        // write table data
        for (int j = 0; j < table.getRowCount(); j++)
        {
            HSSFRow row = sheet.createRow(j + 1);
            for (int k = 0; k < columns.length; k++)
            {
                Column column = columns[k];
                Object value = table.getValue(j, column.getColumnName());
                /* 独自実装（ここから） */
                if (null == value) {
                    cell.setCellValue("null");
                } else if (value instanceof java.sql.Timestamp) {
                    cell.setCellValue(new SimpleDateFormat("yyyy/MM/dd HH:mm:ss").format(value));
                } else {
                    cell.setCellValue(DataType.asString(value));
                }
                /* 独自実装（ここまで） */
            }
        }
```

値がnullの場合はセルの値に"null"文字列を設定するようにします。
データ型がTimestamp型の場合は`yyyy/MM/dd HH:mm:ss`形式の文字列を設定するようにします。

## 4. 独自クラスの実装
上記の方針で、実際に`XlsDataSet`と`XlsTable`のソースコードを拝借して独自クラスを定義します。
修正箇所はJDKライブラリのみを使用しているので、DBUnitを使用できる環境であればコンパイル可能だと思います。

`XlsDataSet`を修正したクラスとして`MyXlsDataSet`クラスを定義します。`XlsTable`クラスはパッケージプライベートクラスとして定義されていて外部パッケージからはアクセスできないクラスとなっています。
ここでは、`MyXlsTable`クラスを`MyXlsDataSet`の内部クラスとして定義します。

日付型についてはDATETIME型の他にDATE型やTIME型も考慮して、`Pattern`をキー`DateFormat`を値とする`HashMap`を定義して、`Map`の各キーの`Pattern`を検査してマッチした場合はキーに紐づく値の`DateFormat`でパースするようにしています。ここは、`Map`ではなくても`Pattern`と`DateFormat`の組み合わせを格納し`List`や配列でも構いません。

このコードはあくまで試験用なので同期化を考慮していないので注意が必要です。
複数のスレッドからこのクラスを使ってExcelファイルを読み取る場合は、`SimpleDateFormat`を都度作成するか`ThreadLocal`に格納するかの工夫が必要です。

```java:MyXlsDataSet
public class MyXlsDataSet extends AbstractDataSet {
    private static final Logger logger = LoggerFactory.getLogger(MyXlsDataSet.class);

    /* 独自実装（ここから） */
    private static final SimpleDateFormat TIMESTAMP_FORMAT = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
    private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy/MM/dd");
    private static final SimpleDateFormat TIME_FORMAT = new SimpleDateFormat("HH:mm:ss");
    /* 独自実装（ここまで） */

    private final ITable[] _tables;

    /**
     * Creates a new XlsDataSet object that loads the specified Excel document.
     */
    public DateFormattedXlsDataSet(File file) throws IOException, DataSetException {
        this(new FileInputStream(file));
    }

    /**
     * Creates a new XlsDataSet object that loads the specified Excel document.
     */
    public DateFormattedXlsDataSet(InputStream in) throws IOException, DataSetException {
        HSSFWorkbook workbook = new HSSFWorkbook(in);
        _tables = new ITable[workbook.getNumberOfSheets()];
        for (int i = 0; i < _tables.length; i++) {
            _tables[i] = new DateFormattedXlsTable(workbook.getSheetName(i), workbook.getSheetAt(i));
        }
    }

    /**
     * Write the specified dataset to the specified Excel document.
     */
    public static void write(IDataSet dataSet, OutputStream out)
            throws IOException, DataSetException {
        logger.debug("write(dataSet=" + dataSet + ", out=" + out + ") - start");

        HSSFWorkbook workbook = new HSSFWorkbook();

        int index = 0;
        ITableIterator iterator = dataSet.iterator();
        while (iterator.next()) {
            // create the table i.e. sheet
            ITable table = iterator.getTable();
            ITableMetaData metaData = table.getTableMetaData();
            HSSFSheet sheet = workbook.createSheet(metaData.getTableName());

            // write table metadata i.e. first row in sheet
            // workbook.setSheetName(index, metaData.getTableName(), HSSFWorkbook.ENCODING_UTF_16);
            workbook.setSheetName(index, metaData.getTableName());

            HSSFRow headerRow = sheet.createRow(0);
            Column[] columns = metaData.getColumns();
            for (int j = 0; j < columns.length; j++) {
                Column column = columns[j];
                HSSFCell cell = headerRow.createCell((short) j);
                // cell.setEncoding(HSSFCell.ENCODING_UTF_16);
                cell.setCellValue(column.getColumnName());
            }

            // write table data
            for (int j = 0; j < table.getRowCount(); j++) {
                HSSFRow row = sheet.createRow(j + 1);
                for (int k = 0; k < columns.length; k++) {
                    Column column = columns[k];
                    Object value = table.getValue(j, column.getColumnName());

                    /* 独自実装（ここから） */
                    HSSFCell cell = row.createCell((short) k);
                    if (null == value) {
                        cell.setCellValue("null");
                    } else if (value instanceof java.sql.Timestamp) {
                        cell.setCellValue(TIMESTAMP_FORMAT.format(value));
                    } else if (value instanceof java.sql.Date) {
                        cell.setCellValue(DATE_FORMAT.format(value));
                    } else if (value instanceof Time) {
                        cell.setCellValue(TIME_FORMAT.format(value));
                    } else {
                        cell.setCellValue(DataType.asString(value));
                    }
                    /* 独自実装（ここまで） */

                    // if (value != null) {
                    //   HSSFCell cell = row.createCell((short) k);
                    //   cell.setEncoding(HSSFCell.ENCODING_UTF_16);
                    //   cell.setCellValue(DataType.asString(value));
                    // }
                }
            }

            index++;
        }

        // write xls document
        workbook.write(out);
        out.flush();
    }

    ////////////////////////////////////////////////////////////////////////////
    // AbstractDataSet class

    protected ITableIterator createIterator(boolean reversed) throws DataSetException {
        // logger.debug("createIterator(reversed=" + reversed + ") - start");

        return new DefaultTableIterator(_tables, reversed);
    }

    private static class MyXlsTable extends AbstractTable {
        /* 独自実装（ここから） */
        // UT用なので同期化は考慮しない
        private static final HashMap<Pattern, SimpleDateFormat> DATETIME_PATTERN_MAP =
                new HashMap<Pattern, SimpleDateFormat>();

        static {
            DATETIME_PATTERN_MAP.put(
                    Pattern.compile("\\d{4}/\\d{2}/\\d{2}"), new SimpleDateFormat("yyyy/MM/dd"));
            DATETIME_PATTERN_MAP.put(
                    Pattern.compile("\\d{4}-\\d{2}-\\d{2}"), new SimpleDateFormat("yyyy-MM-dd"));
            DATETIME_PATTERN_MAP.put(
                    Pattern.compile("\\d{4}/\\d{2}/\\d{2} \\d{2}:\\d{2}:\\d{2}"),
                    new SimpleDateFormat("yyyy/MM/dd HH:mm:ss"));
            DATETIME_PATTERN_MAP.put(
                    Pattern.compile("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"),
                    new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
        }
        /* 独自実装（ここまで） */
        // private static final Logger logger = LoggerFactory.getLogger(XlsTable.class);

        private final ITableMetaData _metaData;
        private final Sheet _sheet;

        private final DecimalFormatSymbols symbols = new DecimalFormatSymbols();

        public DateFormattedXlsTable(String sheetName, Sheet sheet) throws DataSetException {
            int rowCount = sheet.getLastRowNum();
            if (rowCount >= 0 && sheet.getRow(0) != null) {
                _metaData = createMetaData(sheetName, sheet.getRow(0));
            } else {
                _metaData = new DefaultTableMetaData(sheetName, new Column[0]);
            }

            _sheet = sheet;

            // Needed for later "BigDecimal"/"Number" conversion
            symbols.setDecimalSeparator('.');
        }

        static ITableMetaData createMetaData(String tableName, Row sampleRow) {
            logger.debug("createMetaData(tableName={}, sampleRow={}) - start", tableName, sampleRow);

            List columnList = new ArrayList();
            for (int i = 0; ; i++) {
                Cell cell = sampleRow.getCell(i);
                if (cell == null) {
                    break;
                }

                String columnName = cell.getRichStringCellValue().getString();
                if (columnName != null) {
                    columnName = columnName.trim();
                }

                // Bugfix for issue ID 2818981 - if a cell has a formatting but no name also ignore it
                if (columnName.length() <= 0) {
                    // logger.debug("The column name of column # {} is empty - will skip here assuming the
                    // last column was reached", String.valueOf(i));
                    break;
                }

                Column column = new Column(columnName, DataType.UNKNOWN);
                columnList.add(column);
            }
            Column[] columns = (Column[]) columnList.toArray(new Column[0]);
            return new DefaultTableMetaData(tableName, columns);
        }

        ////////////////////////////////////////////////////////////////////////////
        // ITable interface

        public int getRowCount() {
            logger.debug("getRowCount() - start");

            return _sheet.getLastRowNum();
        }

        public ITableMetaData getTableMetaData() {
            logger.debug("getTableMetaData() - start");

            return _metaData;
        }

        public Object getValue(int row, String column) throws DataSetException {
            if (logger.isDebugEnabled()) {
                logger.debug("getValue(row={}, columnName={}) - start", Integer.toString(row), column);
            }

            assertValidRowIndex(row);

            int columnIndex = getColumnIndex(column);
            Cell cell = _sheet.getRow(row + 1).getCell(columnIndex);
            if (cell == null) {
                return null;
            }

            int type = cell.getCellType();
            switch (type) {
                case Cell.CELL_TYPE_NUMERIC:
                    CellStyle style = cell.getCellStyle();
                    if (DateUtil.isCellDateFormatted(cell)) {
                        return getDateValue(cell);
                    } else if (XlsDataSetWriter.DATE_FORMAT_AS_NUMBER_DBUNIT.equals(
                            style.getDataFormatString())) {
                        // The special dbunit date format
                        return getDateValueFromJavaNumber(cell);
                    } else {
                        return getNumericValue(cell);
                    }

                case Cell.CELL_TYPE_STRING:
                    /* 独自実装（ここから） */
                    String cellValue = cell.getRichStringCellValue().getString();
                    if ("null".equals(cellValue)) {
                        return null;
                    }
                    Set<Pattern> patternSet = DATETIME_PATTERN_MAP.keySet();
                    for (Pattern pattern : patternSet) {
                        if (pattern.matcher(cellValue).matches()) {
                            SimpleDateFormat format = DATETIME_PATTERN_MAP.get(pattern);
                            try {
                                return format.parse(cellValue);
                            } catch (ParseException e) {
                                continue;
                            }
                        }
                    }
                    return cellValue;
                　　/* 独自実装（ここまで） */

                case Cell.CELL_TYPE_FORMULA:
                    throw new DataTypeException("Formula not supported at row=" + row + ", column=" + column);

                case Cell.CELL_TYPE_BLANK:
                    return ""; // 独自実装

                case Cell.CELL_TYPE_BOOLEAN:
                    return cell.getBooleanCellValue() ? Boolean.TRUE : Boolean.FALSE;

                case Cell.CELL_TYPE_ERROR:
                    throw new DataTypeException("Error at row=" + row + ", column=" + column);

                default:
                    throw new DataTypeException("Unsupported type at row=" + row + ", column=" + column);
            }
        }

        protected Object getDateValueFromJavaNumber(Cell cell) {
            logger.debug("getDateValueFromJavaNumber(cell={}) - start", cell);

            double numericValue = cell.getNumericCellValue();
            BigDecimal numericValueBd = new BigDecimal(String.valueOf(numericValue));
            numericValueBd = stripTrailingZeros(numericValueBd);
            return new Long(numericValueBd.longValue());
        }

        protected Object getDateValue(Cell cell) {
            logger.debug("getDateValue(cell={}) - start", cell);

            double numericValue = cell.getNumericCellValue();
            Date date = DateUtil.getJavaDate(numericValue);
            return new Long(date.getTime());
        }

        /**
         * Removes all trailing zeros from the end of the given BigDecimal value up to the decimal
         * point.
         *
         * @param value The value to be stripped
         * @return The value without trailing zeros
         */
        private BigDecimal stripTrailingZeros(BigDecimal value) {
            if (value.scale() <= 0) {
                return value;
            }

            String valueAsString = String.valueOf(value);
            int idx = valueAsString.indexOf(".");
            if (idx == -1) {
                return value;
            }

            for (int i = valueAsString.length() - 1; i > idx; i--) {
                if (valueAsString.charAt(i) == '0') {
                    valueAsString = valueAsString.substring(0, i);
                } else if (valueAsString.charAt(i) == '.') {
                    valueAsString = valueAsString.substring(0, i);
                    // Stop when decimal point is reached
                    break;
                } else {
                    break;
                }
            }
            BigDecimal result = new BigDecimal(valueAsString);
            return result;
        }

        protected BigDecimal getNumericValue(Cell cell) {
            logger.debug("getNumericValue(cell={}) - start", cell);

            String formatString = cell.getCellStyle().getDataFormatString();
            String resultString = null;
            double cellValue = cell.getNumericCellValue();

            if ((formatString != null)) {
                if (!formatString.equals("General") && !formatString.equals("@")) {
                    logger.debug("formatString={}", formatString);
                    DecimalFormat nf = new DecimalFormat(formatString, symbols);
                    resultString = nf.format(cellValue);
                }
            }

            BigDecimal result;
            if (resultString != null) {
                try {
                    result = new BigDecimal(resultString);
                } catch (NumberFormatException e) {
                    logger.debug("Exception occurred while trying create a BigDecimal. value={}",
                            resultString);
                    // Probably was not a BigDecimal format retrieved from the excel. Some
                    // date formats are not yet recognized by HSSF as DateFormats so that
                    // we could get here.
                    result = toBigDecimal(cellValue);
                }
            } else {
                result = toBigDecimal(cellValue);
            }
            return result;
        }

        /**
         * @param cellValue
         * @return
         * @since 2.4.6
         */
        private BigDecimal toBigDecimal(double cellValue) {
            String resultString = String.valueOf(cellValue);
            // To ensure that intergral numbers do not have decimal point and trailing zero
            // (to restore backward compatibility and provide a string representation consistent with
            // Excel)
            if (resultString.endsWith(".0")) {
                resultString = resultString.substring(0, resultString.length() - 2);
            }
            BigDecimal result = new BigDecimal(resultString);
            return result;
        }

        public String toString() {
            StringBuilder sb = new StringBuilder();
            sb.append(getClass().getName()).append("[");
            sb.append("_metaData=").append(this._metaData == null ? "null" : this._metaData.toString());
            sb.append(", _sheet=").append(this._sheet == null ? "null" : "" + this._sheet);
            sb.append(", symbols=").append(this.symbols == null ? "null" : "" + this.symbols);
            sb.append("]");
            return sb.toString();
        }
    }
}
```

引用したソースコードが2020年1月24日現在で最新の2.6.0になっていましたが、検証に使用したDBUnitのバージョンは2.5.4であるため、Excelのエンコードに関する設定をコメントアウトしています。
2.6.0以上を使用する場合は下記の行のコメントを解除してください。

```
            // workbook.setSheetName(index, metaData.getTableName(), HSSFWorkbook.ENCODING_UTF_16);
```

```
                // cell.setEncoding(HSSFCell.ENCODING_UTF_16);
```

## 5. 独自クラスを使う
上記の独自クラスのコンパイルが成功したら、使い方は`XlsDataSet`と全く同じです。
ただし、DBUnitは標準で空白のセルの出力を許していないため、取得した`DatabaseConnection `に対して`DatabaseConfig.FEATURE_ALLOW_EMPTY_FIELDS`をtrueにする処理を入れた方が良いです。

```java:ExcelデータをDBに設定
    DatabaseConnection connection = new DatabaseConnection(sqlConnection, schemaName);
    connection.getConfig().setProperty(DatabaseConfig.FEATURE_ALLOW_EMPTY_FIELDS, true);
    IDataSet xlsDataSet = new MyXlsDataset(new File(xlsFilePath));
    DatabaseOperation.CLEAN_INSERT.execute(connection, compositDataSet);
```

```java:ExcelデータとDBを比較
    DatabaseConnection connection = new DatabaseConnection(sqlConnection, schemaName);
    connection.getConfig().setProperty(DatabaseConfig.FEATURE_ALLOW_EMPTY_FIELDS, true);
    IDataSet expected= new MyXlsDataset(new File(expectedXlsFilePath));
    QueryDataSet actual = new QueryDataSet(connection);
    Assert.assertEquals(expected, actual);
```

```java:DBデータをExcelファイルに出力
    DatabaseConnection connection = new DatabaseConnection(sqlConnection, schemaName);
    connection.getConfig().setProperty(DatabaseConfig.FEATURE_ALLOW_EMPTY_FIELDS, true);
    QueryDataSet dbDataSet= new QueryDataSet(connection);
    FileOutputStream fileOutputStream = new FileOutputStream(outputFilePath);
    MyXlsDataset.write(dbDataSet, fileOutputStream);
```

gradleを使用しているプロジェクトでは、gradleタスクとしてDBUnitを使ってDBのエクスポートやインポートをしたくなるかもしれません。もちろんそれは可能ですが、その場合は上記の`MyXlsDataSet`クラスはjarライブラリにする必要があります。

## 6. さいごに
DBUnitはLGPLライセンスを持っています。
作成したプログラムのテストコードを含めて配布する場合など上記のコードを実装コードに含める場合は、LGPLライセンスに従う必要があるので注意をしてください。