概要
既に決められたテストモデルAがあって、テストモデルB1~B3はテストモデルAから派生したテストに、追加されたものがある。
派生されたテストの入力データや結果はテストモデルAとは挙動が異なる場合がある。
そのような場合、わざわざテストモデルをいちいち書いていると、ソースコードの再利用性に問題が生じるので、
いくつかの工夫をして全てのテストを整合性を保って実施できるようにしたい
開発環境
環境こそ古いC#ではあるが、ちょっとしたTipsであるので、
モダンな言語であればよく似たやり方で出来るはず
- 言語: C# 7.0
- ユニットテスト: MSTest
手順
前提
- 入力データをInput型のInputDataとして、それを可変配列で管理している。
- 出力データをOutput型のOutputDataとして、それを可変配列で出力される。
- InputData, OutputDataの内容はテストケースごとに変化する可能性がある。
- 共通のモデルクラスSomeModelは、メソッドRunを実行出来る。
- Runは引数はInput1つとし、Outputを返り値として出力する
① アサート用の関数をTestCaseに準備
例えば、入力データをInput型のInputData, 出力データをOutput型のOutputDataというオブジェクトに設定していて、
それを配列で管理しているものとすると、
public class TestCaseFormat
{
// ポイント1:InputDataの変更権限は親のテストケースのみとする
private List<Input> InputData
{
get;
}
// ポイント2:InputDataのsetterはアクセス権の問題があるので別途メソッドを追加する
protected void UpdateInputData(IEnumerable<Input> input_data)
{
InputData.clear();
InputData.AddRange(input_data);
}
// アサーション処理
// ポイント3:modifiedはList型(データ変更が可能)だが、assertはIEnumerable型(データ変更が出来ない)とする。
protected void RunAndAssert( Action<List<Input>> modified, Action<IEnumerable<Output>> assert)
{
modified( InputData );
var model = new SomeModel();
OutputData = model.Run(InputData);
assert( OutputData );
}
}
② TestCaseAを用意
[TestClass]
public class TestCaseA : TestCaseFormat
{
// nullの場合はデフォルト値(自分で実行)なので、
// このクラスで定義されたデフォルトのinstanceを選択する
private void Define<T>( ref T instance, T default_instance ) where T : class
{
if(instance == null) {
instance = default_instance;
}
}
// テストケースでnull入力で、型が異なる場合は
// テストとして異常なのでエラーにする
private bool IsValidTestCalling( Action<List<Input>> modified, Action<IEnumerable<Output>> assert )
{
if( modified == null && assert == null && GetType() != typeof(TestCaseA) ){
return false;
}
else
{
return true;
}
}
/** 以下、流用前のソースコード(ちょっとだけ加工する) ***/
// ポイント4:コンストラクタでテストケースAの場合の入力データを入れる
public TestCaseA()
{
UpdateInputData( /* (...Inputデータを入れる...) */ );
}
// ポイント5:各パラメータが'null'の場合は自分自身のテストとする
[TestMethod]
[DataTestMethod]
[DataRow(null, null)]
public virtual void TestCase1( Action<List<Input>> modified, Action<IEnumerable<Output>> assert )
{
if( !IsValidTestCalling( modified, assert) ) {
throw new NotImplementedException("子テストケースが実装されていません");
}
Define( modified, (input_data) => {
//...(Inputの変更処理)...
}); /* TestModelAのTestCase1のデフォルトの入力パラメータ */
Define( assert, (output_data) => {
//...(アサーション)...
}); /* TestModelAのTestCase1のデフォルトのアサーション */
RunAndAssert( modified, assert );
}
[TestMethod]
[DataTestMethod]
[DataRow(null, null)]
public virtual void TestCase2( Action<List<Input>> modified, Action<IEnumerable<Output>> assert )
{
if( !IsValidTestCalling( modified, assert) )
{
throw new NotImplementedException("子テストケースが実装されていません");
}
Define( modified, (input_data) => {
//...(Inputの変更処理)...
}); /* TestModelAのTestCase1のデフォルトの入力パラメータ */
Define( assert, (output_data) => {
//...(アサーション)...
}); /* TestModelAのTestCase1のデフォルトのアサーション */
RunAndAssert( modified, assert );
}
}
③ テストケースB1に派生する
[TestClass]
public class TestCaseB1 : TestCaseA
{
public TestCaseB1()
{
UpdateInputData( /* (...Inputデータを入れる...) */ );
}
// ポイント6:テストに変更がある場合、定義を追加する
// なお、TestCase2はオーバーライドしないので内容が変わらない
[TestMethod]
[DataTestMethod]
[DataRow(null, null)]
public override void TestCase1( Action<List<Input>> modified, Action<IEnumerable<Output>> assert ){
base.TestCase1( (input_data) => {
//...(Inputの変更処理)...
}, (output_data) => {
//...(アサーション)...
}
}
}
効果
アクセス権限を上手く切り替えながら実装しており、いくつかのメリットがあると思う.
- テストケース修正不要部分のコードでは、追加でテストコードを書かなくても良い
- 実装異常の検出が出来る(派生したテストケースの引数がnullの場合は未実装扱いにできるなど)
- TDD(テスト駆動開発)を適用しやすい気がする
- モデルに入れるデータがちょっと違う場合でも、「違う結果である」ことをテストツールが検出してくれるので、データの差分によりテストを評価しやすい
- Assertの段階でIEnumerableを使うことで、テスト作業者がアサートで返すべき結果を加工することをある程度防げる
課題
クラス設計がややこしいので、どういう風にすればもう少し汎用的に取り扱いしやすいかを考える必要はありそうだ