DBが絡むテスト
DBが絡むテストをするときにテストデータの投入やデータの検証を
テストコードをなるべく書かずにやりたいことがある。
特にテストコードを書きなれていないメンバがいる場合や
そこそこたくさんのテストデータを用意しなければいけない場合など。
テストデータと期待値を外部ファイルに作成してそれを読み込む形で
テストデータ投入と検証を行いたい、JavaだったらDBUnitがある、C#では?
NDbUnitなるプロダクトがあったりするのだけど、
データをXMLではなくCSVファイルまたはExcelファイルで作成したかったのと
スキーマ定義ファイル(?)なるものを作るのがめんどい、
ので実装してみることにした。
仕様
- テスト前に投入するテストデータを前提データ、
検証に使用する期待値のデータを期待値データと呼ぶことにする - 前提データと期待値データはCSVで作成する
(ExcelはOfficeのライセンスがないので。。。) - 前提データと期待値データはセット(データセットと呼ぶことにする)
にしてディレクトリに格納する - データセットはテストIDで一意に識別される
- データセットのディレクトリ名に規約を設けて特定のパターンの場合に
データセットとして認識されるようにしておく、ディレクトリ名でテストIDを定義する - データセットの配置先ルートディレクトリを指定して、テストIDを指定して前提データ投入と検証を行う
- 任意のクエリの実行結果も検証できるようにする
- なるべくテーブル定義に依存しないようにする(必ずしもテーブル定義とあっている必要はない)
データセットはこんな感じでプロジェクトのルートや
ソリューションのルートに置いておく。
プロジェクトルート/
testdata/
.../ # ディレクトリ構造は自由にできるようにしておく
T__SAMPLE1__SampleTest1/
R__item.csv
R__sales.csv
E__sales.csv
E__assert_sales.csv
T__SAMPLE2__SampleTest2/
...
データセットのディレクトリ名規約: T__{テストID}__{コメント}
テストデータのファイル名規約: R__{テーブル名}
期待値データのファイル名規約: E__{テーブル名 or クエリ名}
成果物
GitHubで公開してる
DumbAssert.csをプロジェクトに追加して使う
こんな感じになった
例えばこんなテーブルがDBにあるとする(SQLite想定)
CREATE TABLE "article" (
"article_id" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"content" TEXT NOT NULL,
"published" TEXT NOT NULL,
"tag" TEXT,
"version" INTEGER NOT NULL
PRIMARY KEY("article_id" AUTOINCREMENT)
);
データセットを用意する
project-root/
testdata/
T__A1__PublishArticle/
R__article.csv
E__article.csv
E__assert_article.csv
前提データ、NULLは<NULL>
で表現する(設定で変えられるようになってる ※後述)。
article_id,name,content,published,tag,version
1,test1,content1,0,tag1,1
2,test2,content2,0,<NULL>,1
期待値データ、記述していないカラムは検証対象外になる。
ソート順は記述されたカラムの昇順になる。
この場合ORDER BY article_id asc, name asc, content asc, published asc, tag asc
になる
article_id,name,content,published,tag
1,test1,content1,0,tag1
2,test2,content2,1,<NULL>
期待値データ、任意のクエリの実行結果を検証することもできる。
ストアドファンクションやビューの検証にも使える。
@query{select article_id,published where article_id = 2 order by article_id}
article_id,published
2,1
テストコード、Prepare
で前提データを投入、
Prepare
時に読み込んだデータセットの期待値データがAssert
で検証される
[Test]
public void TestSample1()
{
// データセットのルートディレクトリを指定(必須)
DumbAssertConfig.TestDataBaseDir = /*データセットのルートディレクトリ*/;
// エンコーディングを指定(デフォルトはUTF-8)
DumbAssertConfig.Encoding = Encoding.GetEncoding("UTF-8");
// 前提データ投入前にデータを削除(デフォルトはtrue)
DumbAssertConfig.DeleteBeforeInsert = true;
// NULLの代替文字列を指定(デフォルトは"<NULL>")
DumbAssertConfig.NullString = "<NULL>";
// DateTimeの文字列表現パターンを指定
// ※ToStringのパラメタ(デフォルトは"yyyy-MM-dd HH:mm:ss.fff")
DumbAssertConfig.DateTimePattern = "yyyy-MM-dd HH:mm:ss.fff";
// 生成されるSQLのカラム名をダブルクォートでクォートするか(デフォルトはtrue)
DumbAssertConfig.QuoteColumnName = true;
// 改行コード
//(デフォルトはEnvironment.NewLine ※WindowsならCRLF、Mac/LinuxならLF)
DumbAssertConfig.NewLine = Environment.NewLine;
using(IDbConnection conn = new SQLiteConnection(/*接続文字列*/)) {
conn.Open();
DumbAssert du = new DumbAssert(conn);
du.Prepare("A1");
...
DB操作を伴う処理
...
du.Assert();
conn.Close();
}
}
既存のトランザクションを与えて前提データの投入と検証をすることができるので、
テスト後にロールバックしてデータを元に戻すということができる。
[Test]
public void TestUseExistingTransaction()
{
DumbAssertConfig.TestDataBaseDir = /*データセットのルートディレクトリ*/;
using(IDbConnection conn = new SQLiteConnection(/*接続文字列*/)) {
conn.Open();
var tx = conn.BeginTransaction();
DumbAssert du = new DumbAssert(conn, tx);
du.Prepare("A1");
...
DB操作を伴う処理
...
du.Assert();
tx.Rollback();
conn.Close();
}
}
前提データだけのデータセットを作って共通の前提データとして利用することもできる。
du.Prepare("Common1")
du.Prepare("A1");
...
DB操作を伴う処理
...
du.Assert();
特定のデータセットの期待値のみを検証することも可能。
du.Prepare("A1")
du.Prepare("A2");
...
DB操作を伴う処理
...
du.Assert("A1");
実装
CSVの読み込み
可能な限り依存を減らしたかったので.NETの標準機能だけでつくることにした。
using Microsoft.VisualBasic.FileIO;
...
TextFieldParser parser =
new TextFieldParser(filePath, DumbAssertConfig.Encoding);
parser.SetDelimiters(",");
this.Data = new List<string[]>();
while(!parser.EndOfData)
{
this.Data.Add(parser.ReadFields());
}
...
データの検証
基本的にデータをADO.NETで取得してCSVの期待値と比較しているだけなのだけど、
文字列ならいいけど数値や日時、ブール型などをどう扱うかという問題については
全部文字列化して比較するという少々雑な方法をとっている。
シリアライザを設定してどのように文字列化するかを変更できるようになっている。
文字列と数値、日時以外はテストしてないので動くか微妙。。。
そのうちテストする。
public class DumbAssertSerializer
{
public string Serialize(object value)
{
switch(value)
{
case null: return DumbAssertConfig.NullString;
case Boolean val: return Serialize(val);
case Byte val: return Serialize(val);
case Char val: return Serialize(val);
case DateTime val: return Serialize(val);
...
default: return null;
}
}
public string Serialize(Boolean value) { return value.ToString(); }
public string Serialize(Byte value) { return value.ToString(); }
public string Serialize(Char value) { return value.ToString(); }
public string Serialize(DateTime value) { return value.ToString(DumbAssertConfig.DateTimePattern); }
...
}
public class DumbAssertConfig
{
...
public static DumbAssertSerializer Serializer = new DumbAssertSerializer();
public static string NullString = "<NULL>";
...
public static string DateTimePattern = "yyyy-MM-dd HH:mm:ss.fff";
}