Java + Spring によるテストデータ管理(6)

(前回の続き)

今回は、テストメソッドとテストデータの関係について考える。

テストデータとしては以下がありうる。

* 固定データ
* マスタデータ（通常固定だがテストパターンによって変化がありうる）
* テストデータ
* 期待する結果

マスタデータなどは、テストによって使いまわせた方がいい。そう考えるとテストデータの構成は、以下の様な階層構造が良いのではないかと予想する。
これ以外のパターンもあり得るだろうがまずはこれを実装してみよう。

なお、ファイル名.csvとテーブル名とBeanの名前は一致させる前提とする。期待する結果はexpect*.csvという名前にしてみた。

```
src/test/resources
  +---- sql
  |       +--- fixed_data.sql
  |       +--- master1.sql
  |       +--- master2.sql
  |       +--- test_data1.sql
  |       +--- test_data2.sql
  |
  `---- fixtures
          +--- fixed_data.csv
          |
          `-- master_pattern1
                +--- master1.csv
                +--- master2.csv
                +--- test_pattern1
                |      +--- expect.csv
                |      +--- test_data1.csv
                |      `--- test_data2.csv
                `--- test_pattern2
                       +--- expect.csv
                       +--- test_data1.csv
                       `--- test_data2.csv
```

まずはテーブルとテストデータを準備しよう

テーブルは以下にする。単純にするために項目と型は合わせた。さらに、単純にするために上の図よりもパターンを減らしている。

```sql:src/test/resources/sql/fixed_data.sql
drop table fixed_data
create table  fixed_data  (id numeric(2), str varchar(30))
```
``` sql:src/test/resources/sql/master1.sql
drop table master1
create table  master1     (id numeric(2), str varchar(30))
```
``` sql:src/test/resources/sql/test_data1.sql
drop table test_data1
create table  test_data1  (id numeric(2), str varchar(30))
```

次にテストデータを以下の通りとする

```src/test/resources/fixtures/fixed_data.csv
id,str
1,fixed1
2,fixed2
```

```src/test/resources/fixtures/master_pattern1/master1.csv
id,str
1,master1_1
2,master1_2
```

```src/test/resources/fixtures/master_pattern1/test_pattern1/expect.csv
id,str1,str2,str3
1,fixed1,master1_1,pattern1_test_data1_1
2,fixed2,master1_2,pattern1_test_data1_2
```

```src/test/resources/fixtures/master_pattern1/test_pattern1/test_data1.csv
id,str
1,pattern1_test_data1_1
2,pattern1_test_data1_2
```

Beanも必要である。以下のようになる。

```java:src/main/java/jp/sample/fixed_data.java
package jp.sample;

import lombok.Data;

@Data
public class fixed_data {
    int id;
    String str;
}
```

同じようなデータが続くため、`fixed_data` クラスだけ掲載した。`master1` などのソースは同様の内容なので省略する。

これに対するテスト対象クラスは以下とする。

```java:src/main/java/jp/sample/JavaDBSample5.java 
package jp.sample;

import java.util.List;

import java.sql.ResultSet;
import java.sql.SQLException;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import org.springframework.jdbc.core.BeanPropertyRowMapper;
 
@Component
public class JavaDBSample5 {
  @Autowired
  JdbcTemplate jdbcTemplate;

  @Transactional
  public List<Result> select1() {
    return jdbcTemplate.query("select"
                              + "  f.id"
                              + " ,f.str as str1"
                              + " ,m.str as str2"
                              + " ,t.str as str3"
                              + " from fixed_data f"
                              + " left join master1 m"
                              + "   on f.id = m.id"
                              + " left join test_data1 t"
                              + "   on m.id = t.id",
      new RowMapper<Result>() {

          @Override
          public Result mapRow(ResultSet rs, int rowNum) throws SQLException {
            Result result = new Result();
            result.setId(rs.getInt("ID"));
            result.setStr1(rs.getString("STR1"));
            result.setStr2(rs.getString("STR2"));
            result.setStr3(rs.getString("STR3"));
            return result;
          }
      });
  }
}
```

Beanクラス Result は以下のとおり。

```java:src/main/java/jp/sample/Result.java 
package jp.sample;

import lombok.Data;

@Data
public class Result {
    int id;
    String str1;
    String str2;
    String str3;
}
```

さて、TestDataUtils で、先ほど検討したテストデータの構成を読む方法を考える

テストデータトップフォルダ fixtures は固定だとして、test_pattern1 というフォルダ名を与えられたらその階層までのデータをすべてinsertするというのはどうだろうかと考えてみた。
シグネチャーは以下である

```
public void loadFixtures(String patternName)
```

このメソッドは、expect で始まるファイルをテーブルに入れないよう特別扱いする必要がある。

さて、実装であるが以下のようになった。

```java:
package jp.sample;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.LineNumberReader;
import java.io.Reader;
import java.sql.Connection;
import java.util.Date;
import java.util.List;

import javax.sql.DataSource;

import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.jdbc.datasource.init.ScriptUtils;
import org.springframework.util.StringUtils;

import au.com.bytecode.opencsv.bean.CsvToBean;
import au.com.bytecode.opencsv.bean.HeaderColumnNameMappingStrategy;

public class TestDataUtils {
    DataSource dataSource;

    public TestDataUtils(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public void executeScript(Resource resource) {
        ResourceDatabasePopulator rdp = new ResourceDatabasePopulator();
        rdp.addScript(resource);
        rdp.setSqlScriptEncoding("UTF-8");
        rdp.setIgnoreFailedDrops(true);
        rdp.setContinueOnError(false);

        Connection conn = DataSourceUtils.getConnection(dataSource);
        rdp.populate(conn);
    }

    public void executeScript(String resourcePath) {
        Resource resource = new ClassPathResource(resourcePath);
        executeScript(resource);
    }

    public void executeScript(File file) {
        Resource resource = new FileSystemResource(file);
        executeScript(resource);
    }

    public <T> List<T> getBeans(Class<T> clazz, Resource resource) throws IOException {
        HeaderColumnNameMappingStrategy strategy = new HeaderColumnNameMappingStrategy();
        strategy.setType(clazz);

        CsvToBean csv = new CsvToBean();

        try (Reader reader = new InputStreamReader(resource.getInputStream(), "UTF-8")) {
            return csv.parse(strategy, reader);
        }
    }

    public <T> List<T> getBeans(Class<T> clazz, String csvPath) throws IOException {
        Resource resource = new ClassPathResource(csvPath, getClass());

        return getBeans(clazz, resource);
    }

    public <T> List<T> getBeans(Class<T> clazz, File file) throws IOException {
        Resource resource = new FileSystemResource(file);

        return getBeans(clazz, resource);
    }

    public void insert(Resource resource, List beans) throws IOException {
        String script;
        try (LineNumberReader reader = new LineNumberReader(new InputStreamReader(resource.getInputStream()))) {
            script = ScriptUtils.readScript(reader, "--", ";");
        }

        NamedParameterJdbcTemplate npjt = new NamedParameterJdbcTemplate(dataSource);
        for (Object bean : beans) {
            npjt.update(script, new BeanPropertySqlParameterSource(bean));
        }
    }

    public void insert(String sqlPath, List beans) throws IOException {
        Resource resource = new ClassPathResource(sqlPath, getClass());

        insert(resource, beans);
    }

    void loadFile(File csvFile) throws IOException, ClassNotFoundException {
        String fileName = csvFile.getName();
        String tableName = StringUtils.stripFilenameExtension(fileName);

        Class clazz = Class.forName("jp.sample." + tableName);
        List beans = getBeans(clazz, csvFile);

        Resource sqlCreateScript = new ClassPathResource("/sql/ddl/" + tableName + ".sql", getClass());
        Resource sqlInsertScript = new ClassPathResource("/sql/dml/" + tableName + ".sql", getClass());

        executeScript(sqlCreateScript);

        insert(sqlInsertScript, beans);
    }

    private void loadParentFiles(File dir) throws IOException, ClassNotFoundException {
        File parent = dir.getParentFile();
        boolean top = parent.getName().equalsIgnoreCase("fixtures");

        if (!top) {
            loadParentFiles(parent);
        }

        for (File file : parent.listFiles()) {
            if (file.isFile()) {
                if (file.getName().toLowerCase().startsWith("expect")) {
                    continue;
                }

                if (!"csv".equalsIgnoreCase(StringUtils.getFilenameExtension(file.getName()))) {
                    continue;
                }

                loadFile(file);
            }
        }

        if (top) {
            return;
        }
    }

    private void loadChildFiles(File dir) throws IOException, ClassNotFoundException {
        for (File file : dir.listFiles()) {
            if (file.isDirectory()) {
                loadChildFiles(file);
            } else {
                if (file.getName().toLowerCase().startsWith("expect")) {
                    continue;
                }

                if (!"csv".equalsIgnoreCase(StringUtils.getFilenameExtension(file.getName()))) {
                    continue;
                }

                loadFile(file);
            }
        }
    }

    private File searchPath(File dir, String patternName) {
        for (File file : dir.listFiles()) {
            if (file.isDirectory()) {
                if (patternName.equalsIgnoreCase(file.getName())) {
                    return file;
                }
                File ret = searchPath(file, patternName);
                if (ret != null) {
                    return ret;
                }
            }
        }

        return null;
    }

    public void loadFixtures(String patternName) throws IOException, ClassNotFoundException {
        Resource resource = new ClassPathResource("/fixtures", getClass());

        File dir = searchPath(resource.getFile(), patternName);
        if (dir == null) {
            throw new FileNotFoundException("directory \"" + patternName + "\" does not exists in /fixtures");
        }

        loadParentFiles(dir);
        loadChildFiles(dir);
    }

    private <T> List<T> loadBeans(Class<T> clazz, File dir, String fileName) throws IOException {
        fileName = fileName + ".csv";

        for (File file : dir.listFiles()) {
            if (file.isFile()) {
                if (fileName.equalsIgnoreCase(file.getName())) {
                    return getBeans(clazz, file);
                }
            }
        }

        throw new FileNotFoundException("file \"" + fileName + "\" does not exist in \"" + dir.getPath());
    }

    public <T> List<T> getBeans(Class<T> clazz, String patternName, String filename) throws IOException {
        Resource resource = new ClassPathResource("/fixtures", getClass());
        File dir = searchPath(resource.getFile(), patternName);
        if (dir == null) {
            throw new FileNotFoundException("directory \"" + patternName + "\" does not exists in /fixtures");
        }

        return loadBeans(clazz, dir, filename);
    }

}
```

テストクラスは以下のようになる。

```java:src/test/java/jp/sample/JavaDBSample5Test.java
package jp.sample;

import java.io.IOException;
import java.io.Reader;
import java.util.List;

import javax.sql.DataSource;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.ContextConfiguration;

import org.junit.runner.RunWith;
import org.junit.*;

import static org.junit.Assert.*;
import static org.hamcrest.CoreMatchers.*;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations="classpath:applicationContext.xml")
public class JavaDBSample5Test {

    @Autowired
    DataSource dataSource;

    @Autowired
    JavaDBSample5 obj;

    TestDataUtils td;

    @Before
    public void setUp() throws IOException, ClassNotFoundException {
        td = new TestDataUtils(dataSource);
        td.loadFixtures("test_pattern1");
    }

    @Test
    public void testSampleSelect() throws IOException {
        List<Result> expect = td.getBeans(Result.class, "test_pattern1", "expect");
        List<Result> actual = obj.sampleSelect();
        assertThat(actual, is(expect));
    }
}
```

ちょっと、大きくなってしまった。
機能も色々細かくなってきたので次回このテストクラスとともに、使い方や何が効率的なのか、問題点として何が残っているか整理しよう。

蛇足：ここに来て、TestDataUtils という名前よりは、FixtureHelper の方がかっこいいかなあっと思い始めた。検索してみたら同名のクラス(? Javaではない)はあるようだ。

追記：

当初、テストクラスの動作は

1. setUp() で、resources/sql 配下のSQLをすべて実行(テーブルを作成)する
2. setUp() で、fixtures/ 配下のcsvファイルをロードする
3. テストメソッドを実行する

となることを考えていたのだが、作ってみたら

1. setUp() で、fixtures/ 配下のcsvに対応する resources/sql/ddl 配下のSQLを実行する(テーブルを作成)
2. setUp() で、fixtures/ 配下のcsvに対応する resources/sql/dml 配下のINSERT用SQLをCSVを与えてロードする
3. テストメソッドを実行する

という流れになってしまった。INSERT用SQLを用意しないと行けなくなっている点(手抜き)が目標と離れており、CSVのロードに必要なテーブルだけを作成するというのは依存度の確認という点で良くなっている。

現在は、以下のようにしようと思っている

1. setUp() で、すべてのテーブルを最初に削除する(オプション)
2. setUp() で、fixtures/ 配下のcsvに対応する resources/sql 配下のSQLを実行する(テーブルを作成)
3. setUp() で、fixtures/ 配下のcsvに対応する INSERT文をCSVの項目名からSQLを作成しロードする
4. テストメソッドを実行する

最初に全テーブルをDROPする点、INSERT用のSQLを事前に用意しなくてよいようにする点を次回改善する。
特に、全テーブルのDROPは個々のテストの依存をなくすために必須であることに気がついたので追加を検討している。ただ、そうしたくない場面もあるだろうと思いオプション機能にしようと思う。