データベースの内容を安定させる
ここまで書いてきたユニットテストは、実際にデータベースにアクセスするものでした。ですから、Customersテーブルの例えば顧客名「Thomas Hardy」が違う名前に変更されると、テストが失敗するようになります。
ビジネスロジックの大部分のユニットテストでは、データベースに実際にアクセスする必要がないので、モックやダミー実装に置き換えられるようにすればよいです。しかし、例えば今回のDbCommandHelperのように、データベースへのアクセスを実際にテストしなければならない部分は、どうしたらよいでしょう。
テスト用のデータで初期化する
まず考え付くのは、テストの前にデータベースの内容を書き換えて、いつも同じにすることでしょう。バッチ処理で行ったり、データ量がそれほどでもなければテストの中で実行したり、乱暴ですが場合によってはデータベースのファイル自体を置き換えてしまうことも可能でしょう。
それほどデータ量が多くない場合、ADO.NETでは次のようにして、簡単にデータをXMLファイルに落とすことができます。
[TestFixture]
public class テスト用データ {
string xmlFileName = "テスト用顧客データ.xml";
string cmdText = @"SELECT * FROM Customers";
private DataTable GetAllCustomersForTest() {
using (DbCommandHelper改5 helper = new DbCommandHelper改5())
using (DbCommand cmd = helper.CreateCommand(cmdText))
using (SqlCeDataAdapter adp = new SqlCeDataAdapter((SqlCeCommand)cmd)) {
var tbl = new DataTable("Customers");
adp.FillSchema(tbl, SchemaType.Source);
adp.Fill(tbl);
return tbl;
}
}
//[TestCase] //←普段はコメントアウトしておくこと
public void CustomersテーブルをXMLファイルに落とす() {
var tbl = GetAllCustomersForTest();
tbl.WriteXml(xmlFileName, XmlWriteMode.WriteSchema);
Assert.Inconclusive("これはテストデータを作成する。普段は使わない");
}
}
こうやって用意しておいたXMLファイルのデータを、テスト実行前にデータベースへ投入してやります。DROP-CREATEしてもよいですが、ここでは既存データとマージする方法を示します。
public int LoadTestData() {
var tableFromFile = new DataTable();
tableFromFile.ReadXml(xmlFileName);
var dbTable = GetAllCustomersForTest();
dbTable.Merge(tableFromFile);
using (TransactionScope tran = new TransactionScope()) {
int affected = 0;
using (SqlCeDataAdapter adp = new SqlCeDataAdapter(cmdText, DbConnectionProvider改5.Connection))
using (SqlCeCommandBuilder cb = new SqlCeCommandBuilder(adp))
affected = adp.Update(dbTable);
tran.Complete();
return affected;
}
}
それぞれのユニットテストを実行する前に、毎回このコードを呼び出してデータを投入しましょうか? そうすれば、たしかに安定してテストできるようにはなります。が、テストの実行時間がとても長くなってしまいます。それでは実用になりません。
System.Transactionsを使う
System.Transactions名前空間を使うと、多重コミットがサポートされます。ユニットテストのコードでトランザクションを開始してから製品コードを呼び出すと、製品コード側でデータベースに変更を加えてコミットしても、その後でユニットテスト側でロールバックしてしまえば、データベースにはまったく変更が残りません。
データの投入は、例えばTestFixtureSetUpで行います。
[TestFixture]
public class 顧客管理ロジック改5Test {
[TestFixtureSetUp]
public void TestFixtureSetUp() {
テスト用データ.LoadTestData(); //データ投入(時間が掛かる!)
}
// 以下略
このとき、LoadTestData()の中でスタティック変数にフラグを立てておくと、NUnitランナーではテストをリロードするまでクリアされません。テストをリロードするまで、長時間掛かるデータのロードを再実行しなくても済みます。
そして、データベースに影響を与えるテストは、TransactionScopeで囲ってロールバックするようにします。例えば、さきほどのLoadTestData()はメソッド内でTransactionScopeを使ってコミットしていますが、次のようなテストが書けます。
[TestCase]
public void LoadTestDataTest() {
// 準備: 該当するデータがあったら、手動で変えておいてください。
// ↓データベースを書き換えてあるので、ヒットしないはず
Assert.AreEqual(0, 顧客管理ロジック改5.前方一致で姓名を検索する("Trujillo").Count());
using (TransactionScope tran = new TransactionScope(TransactionScopeOption.RequiresNew)) {
LoadTestData(); // LoadTestData() の中で、トランザクションをコミットした
// ファイルからロードしたデータになっているので、こんどは1件ヒットする
Assert.AreEqual(1, 顧客管理ロジック改5.前方一致で姓名を検索する("Trujillo").Count());
} // こちら側のトランザクションはロールバック
// ロールバックされている
Assert.AreEqual(0, 顧客管理ロジック改5.前方一致で姓名を検索する("Trujillo").Count());
DbConnectionProvider改5.CloseTransactionConnection(); //トランザクション中で使った接続をクローズ
}
System.Transactionsを使うときの注意
このように非常に便利なSystem.Transactionsですが、「トランザクションの昇格」に注意する必要があります。
-
トランザクションの昇格が発生すると、MS DTC(分散トランザクションコーディネーター)が必要になる。1つの
TransactionScopeに複数のDB接続を参加させると、昇格が発生する。昇格が発生するとパフォーマンスが落ちる。 - SQL Server 2005では、接続を閉じて開くだけで(同時に開いているのは1本でも)、昇格が発生する。
- SQL Server 2008以降は、同時に複数接続を開こうとしなければ大丈夫(参考記事)。
- SQL Server Compactは分散トランザクションに対応していない。トランザクション内で1本の接続を使いまわす必要がある。
サンプルコードはSQL Server Compactを使っています。そのため、上のコードで使っているDbConnectionProviderは、トランザクション中は同じ接続オブジェクトを使い、トランザクションが全部終わってから接続を開放するように改造してあります(サンプルコードのCsTdd07最終形プロジェクトを参照)。
まとめ
- テストしにくいものは切り離せ! 実際にDBに接続してテストしなければならないコードを、できるだけ少なくしよう。
- DBの挙動をテストしなければならないときは、DBの内容を安定させる手法を考えよう。
- テスト目的がDBの挙動でないところは、DBアクセス部分をモックやダミーで置き換えると、テスト時間が短縮できる。
なお、記事中では紹介しきれなかったモックを使ったテストなども、サンプルコードのCsTdd07最終形プロジェクトに収めてありますので、参考にしてください。データベースにアクセスするコードは長くて汚くなりがちですが、このようにしてTDDしていくとコードメトリックスも良好になるものです(次の図)。


