概要
外部モジュールの静的メソッドを呼んでいるメソッドを, 単体テスト可能にする.
この記事は, C#入門者向けなので, C#の言語仕様自体の説明も入る...かもしれない.
問題のコード
次のようなコードがあるとする.
テスト対象のSampleMethod
メソッドは, 何か処理した後, 液晶画面に"Hello World!"と出力するだけの, しょうもないメソッドである.
class SampleClass
{
/// <summary>
/// テスト対象のメソッド
/// </summary>
public void SampleMethod()
{
// 何か処理する.
// 今はあまり関係ないので省略.
// 実機に付属している液晶画面に出力
LcdConsole.WriteLine("Hello World!");
}
}
このコードはPC上じゃなくて, 液晶画面が付属している特別なハードウェア(以降, "実機"と呼ぶ)上で動作させるものとする.
(イメージはMindstorm-EV3.)
ここで, LcdConsole.WriteLine
メソッドは, System.Console.WriteLineみたいな感じで, string型の引数を1つとり, 液晶画面に文字列を出力する静的メソッドである. 戻り値は無いとする.
このSampleMethod
メソッドのテストを作成し, CIツール(Travis CIとかAppVeyorとか)で自動テストさせたい.
でも, CIツールの環境では, 液晶画面がないのでLcdConsole.WriteLine
メソッドは動作しない.
しょうもないメソッドにも関わらず, 単体テストをするにはひと工夫いるようだ.
以降で, LcdConsole.WriteLine
をスタブメソッドに置き換えて, SampleMethod
をテストできるようにしてみる.
前提知識 : メソッドを格納できる変数
スタブメソッドに置き換える方法を説明する前に, "メソッドを格納できる型"について説明する. (デリゲートという)
C#では, 変数に値やクラスだけでなく, メソッドも格納することができる.
そのような変数の型として, .NetではSystem.Action
型やSystem.Func
型が用意されている.
public delegate void Action(); // 引数をとらず戻り値が無いメソッドを格納できる型
public delegate void Action<in T>(T obj); // T型の引数をとり戻り値が無いメソッドを格納できる型
public delegate void Action<in T1, in T2>(T1 arg1, T2 arg2); // T1型とT2型の引数をとり戻り値がないメソッドを格納できる型
public delegate TResult Func<out TResult>(); // 引数をとらずTResult型の戻り値を返すメソッドを格納できる型
public delegate TResult Func<in T, out TResult>(T arg); // T型の引数をとりTResult型の戻り値を返すメソッドを格納できる型
public delegate TResult Func<in T1, in T2, out TResult>(T1 arg1, T2 arg2); // T1型とT2型の引数をとりTResult型の戻り値を返すメソッドを格納できる型
こんな感じで, いろんなAction
型とFunc
型が存在する.
delegate
については詳しく説明しないが, ようは"メソッドを格納できる型"を表す.
例を次に示す.
public void Execute()
{
int a = 5;
int b = 3;
int result;
Func<int, int, int> calculateMethod; // メソッドを格納する変数
calculateMethod = add; // addメソッドをセットする
result = calculateMethod(a, b); // addメソッドが実行される. resultは 8 になる
calculateMethod = sub; // subメソッドに入れ替える
result = calculateMethod(a, b); // subメソッドが実行される. resultは 2 になる
}
/// <summary>
/// 足し算をする
/// </summary>
private int add(int operand1, int operand2)
{
return operand1 + operand2;
}
/// <summary>
/// 引き算をする
/// </summary>
private int sub(int operand1, int operand2)
{
return operand1 - operand2;
}
Func<int, int, int>
型は, int
型の引数を2つとり, int
型の戻り値を返すメソッドを格納できる.
そのFunc<int, int, int>
型の変数calculateMethod
に, add
メソッドやsub
メソッドを格納している.
格納したメソッドを実行するには, その変数名を使って, calculateMethod(a, b)
のように書く.
スタブメソッドに置き換える方法
スタブを使ってメソッドをテストするときには, そのメソッドのコードはそのままで, 呼ばれるメソッドを変更できるようにする必要がある.
つまり, LcdConsole.WriteLine("Hello World!");
と書いてはダメだ.
これだとどう頑張ってもLcdConsole
クラスのWriteLine
メソッドが呼ばれてしまう.
ここで, 前節で述べた"メソッドを格納できる変数"に注目する.
前節で挙げた例にもあるように, そのような変数を使うと, 異なるメソッドを同じ記述で実行することができる.
calculateMethod = add; // addメソッドをセットする
result = calculateMethod(a, b); // addメソッドが実行される. resultは 8 になる
calculateMethod = sub; // subメソッドに入れ替える
result = calculateMethod(a, b); // subメソッドが実行される. resultは 2 になる
早速, LcdConsole.WriteLine
メソッドを, 変数を通して呼ぶようにしてみよう.
public class SampleClass
{
/// <summary>
/// テスト対象のメソッド
/// </summary>
public void SampleMethod()
{
// 何か処理する.
// 今はあまり関係ないので省略.
// 実機に付属している液晶画面に出力
LcdConsoleWriteLine("Hello World!");
}
public Action<string> LcdConsoleWriteLine = LcdConsole.WriteLine;
}
LcdConsole.WriteLine
メソッドを直接呼ぶ代わりに, 変数LcdConsoleWriteLine
を用意して, その変数を通してメソッドを呼んでいる.
変数の型はAction<string>
型である.
これは前節で述べたように, "string型の引数を1つ取り戻り値が無いメソッドを格納できる型"である.
すなわち, LcdConsole.WriteLine
メソッドを格納することができる.
スタブメソッドに置き換えてみる
では, 実際にSampleMethod
のテストで, スタブメソッドに入れ替えてみよう.
テストコードを次に示す. テストフレームワークはMSTestである.
[TestClass]
public class SampleClassTest
{
[TestMethod, Description("SampleMethodメソッドのテスト")]
public void SampleMethodTest()
{
// Arrange (準備)
SampleClass instance = new SampleClass();
instance.LcdConsoleWriteLine = WriteLineStub; // スタブメソッドに入れ替える
// Act (実行)
instance.SampleMethod();
// Assert (検証)
// 何か処理が行われたことを確認する
// 今回は省略
}
/// <summary>
/// LcdConsole.WriteLineメソッドのスタブメソッド
/// </summary>
private void WriteLineStub(string text)
{
// 何もしない
}
}
SampleMethod
を実行する前に, 変数LcdConsoleWriteLine
にWriteLineStub
を格納して, 動作を変更している.
ここでは, 実機上でしか動作しないLcdConsole.WriteLine
メソッドの代わりに, テスト環境で実行しても問題の無いWriteLineStub
メソッドに入れ替えている.
これで, テストの時だけLcdConsole.WriteLine
メソッドの代わりにWriteLineStub
メソッドを実行させることができ, CIツールでの単体テストが可能となる.
変数は誰が持つか
ここまでで, 当初の目的は達成できた.
ここからは, コードのさらなる改善を検討する.
今, メソッドを格納する変数LcdConsoleWriteLine
は, インスタンスフィールドとしてSampleClass
が持っている.
これにより, 次のような気になる点がある.
- "液晶画面に文字列を出力する方法を保持する"という責務を,
SampleClass
が持っている. (最初, そんな責務は無かったのに!) - もともと静的メソッドとして呼んでたので, やっぱり同じように静的メソッドとして呼びたい.
というわけで, 変数の持ち方を見直してみる.
staticな変数として, SampleClass
以外が持つことにする.
public class LcdConsoleWrapper
{
public static Action<string> WriteLine = LcdConsole.WriteLine;
}
SampleMethod
メソッドの中身は, 次のようになるだろう.
public class SampleClass
{
/// <summary>
/// テスト対象のメソッド
/// </summary>
public void SampleMethod()
{
// 何か処理する.
// 今はあまり関係ないので省略.
// 実機に付属している液晶画面に出力
LcdConsoleWrapper.WriteLine("Hello World!");
}
}
もともとの, LcdConsole.WriteLine("Hello World!");
という書き方に近くなった.
このメソッドのテストコードは次のようになる.
[TestClass]
public class SampleClassTest
{
[TestMethod, Description("SampleMethodメソッドのテスト")]
public void SampleMethodTest()
{
// Arrange (準備)
SampleClass instance = new SampleClass();
LcdConsoleWrapper.WriteLine = WriteLineStub; // スタブメソッドに入れ替える
// Act (実行)
instance.SampleMethod();
// Assert (検証)
// 何か処理が行われたことを確認する
// 今回は省略
}
/// <summary>
/// LcdConsole.WriteLineメソッドのスタブメソッド
/// </summary>
private void WriteLineStub(string text)
{
// 何もしない
}
}
静的な変数の注意点
さて, 前節でコードが少し改善され, もともとのLcdConsole.WriteLine
メソッドと同じように静的メソッドで文字列表示ができるようになった.
しかし, staticにしたがゆえに, 注意しなければならない点もある.
例えば, 次のように, 新たなテストコードを追加したとする.
[TestClass]
public class SampleClassTest
{
[TestMethod, Description("SampleMethodメソッドのテスト")]
public void SampleMethodTest()
{
// Arrange (準備)
SampleClass instance = new SampleClass();
LcdConsoleWrapper.WriteLine = WriteLineStub; // スタブメソッドに入れ替える
// Act (実行)
instance.SampleMethod();
// Assert (検証)
// 何か処理が行われたことを確認する
// 今回は省略
}
[TestMethod, Description("SampleMethodメソッドの別のテスト")]
public void SampleMethodTest2()
{
// このテストコードの開始の時点で,
// 既に変数LcdConsoleWrapper.WriteLine内のメソッドはスタブメソッドに入れ替わっている
// Arrange
// 省略
// Act
// 省略
// Assert
// 省略
}
/// <summary>
/// LcdConsole.WriteLineメソッドのスタブメソッド
/// </summary>
private void WriteLineStub(string text)
{
// 何もしない
}
}
今度はテストメソッドが2つある.
ここで, SampleMethodTest
の次にSampleMethodTest2
が実行された場合を考える.
静的な変数ということは, SampleMethodTest
が終わっても, 変数の値はそのままということである.
そのため, SampleMethodTest2
が始まった時点で, 変数LcdConsoleWrapper.WriteLine
の値はスタブメソッドに入れ替わった状態である.
テストは次のことに気を付けて書くべきである.
- テストは互いに独立にする. テストがどのような順番で実行されても, 同じテスト結果が出るようにする.
- 一つのテスト中で変数, プログラムの状態, デバイスの状態を変更したりするかもしれないが, そのテストが終わったらもとに戻すこと. 立つ鳥跡を濁さず.
今回は, 実機でしか動かないメソッドを, "何もしない"スタブメソッドに入れ替えているだけなので, そのままの状態で別のテストを実行しても問題はないかもしれない.
しかし, 問題のあるケースもある.
例えば, bool型を返すメソッドを"必ずfalseを返す"スタブメソッドに入れ替えたままにすると, 別のテストがうまく通らなくなってしまうかもしれない.
というわけで, 次のように修正する.
[TestMethod, Description("SampleMethodメソッドのテスト")]
public void SampleMethodTest()
{
// Arrange (準備)
SampleClass instance = new SampleClass();
Action<string> originalMethod = LcdConsoleWrapper.WriteLine; // もともとのメソッドを退避させる
LcdConsoleWrapper.WriteLine = WriteLineStub; // スタブメソッドに入れ替える
// Act (実行)
instance.SampleMethod();
LcdConsoleWrapper.WriteLine = originalMethod; // 実行が終わったら, もとのメソッドに戻す
// Assert (検証)
// 何か処理が行われたことを確認する
// 今回は省略
}
これで, Actが終わったときに, LcdConsoleWrapper.WriteLine
がもとのメソッドに戻るようになる.
さらなる改善を求めて
これまでの説明でできたコードには, まだまだ気になる箇所がある.
- 変数
LcdConsoleWrapper.WriteLine
がpublicである. テストのときにスタブメソッドに入れ替えるという目的のためにpublicにしてあるが, このままだと通常のコードからも自由にメソッドを入れ替えられてしまう. 通常のコードに対しては, この変数の値を取得するだけで, 値を設定できないようにする必要がある. - 前節で, 静的な変数の値をAct後に元に戻すようにしたが, そもそもAct中に例外が発生してテストに失敗したら, 静的な変数の値は元に戻らない.
- スタブメソッドをもっと手軽に書きたい. 今回の例では1個だったので気にならないが, "必ずfalseを返すスタブ", "必ず空文字を返すスタブ", ...などいろいろ種類が増えていくとめんどくさい.
これらは,
- ラムダ式
- 自動実装プロパティ
- プライベートメンバに外からアクセスするという, オブジェクト指向の常識を打ち破るような方法(PrivateType)
- MSTestのInitialize, Cleanup
などを用いて改善できる.
とても記事が長くなった & ちょっと記事の概要からずれてきたので省略する(気が向けばまた記事を書くかもしれない)が, 最終的にコードは次のようになる.
public class LcdConsoleWrapper
{
public static Action<string> WriteLine { get; } = LcdConsole.WriteLine; // Getterのみ
}
テスト対象のコードは変わらず,
public class SampleClass
{
/// <summary>
/// テスト対象のメソッド
/// </summary>
public void SampleMethod()
{
// 何か処理する.
// 今はあまり関係ないので省略.
// 実機に付属している液晶画面に出力
LcdConsoleWrapper.WriteLine("Hello World!");
}
}
そしてテストコードは,
[TestClass]
public class SampleClassTest
{
/// <summary>
/// もともとのLcdConsoleWrapper.WriteLineの値を退避させておくための変数
/// </summary>
private Action<string> originalMethod;
[TestInitialize] // ひとつのテストが始まる前に呼ばれるメソッド
public void InitializeTest()
{
originalMethod = null;
}
[TestCleanup] // ひとつのテストが終わるたびに呼ばれるメソッド
public void CleanupTest()
{
if (originalMethod != null)
{
// 実行が終わったら, もとのメソッドに戻す
PrivateType privateType = new PrivateType(typeof(LcdConsoleWrapper)); // privateなメンバにアクセスするためのオブジェクト
privateType.SetStaticFieldOrProperty(
$"<{nameof(LcdConsoleWrapper.WriteLine)}>k__BackingField",
new Action<string>(originalMethod));
}
}
[TestMethod, Description("SampleMethodメソッドのテスト")]
public void SampleMethodTest()
{
// Arrange (準備)
SampleClass instance = new SampleClass();
originalMethod = LcdConsoleWrapper.WriteLine; // もともとのメソッドを退避させる
// スタブメソッドに入れ替える
PrivateType privateType = new PrivateType(typeof(LcdConsoleWrapper)); // privateなメンバにアクセスするためのオブジェクト
privateType.SetStaticField(
$"<{nameof(LcdConsoleWrapper.WriteLine)}>k__BackingField",
new Action<string>(_ => { }));
// Act (実行)
instance.SampleMethod();
// Assert (検証)
// 何か処理が行われたことを確認する
// 今回は省略
}
}
となる.
おしまい.