皆さんはテスト書いていますか? テスト、特にユニットテストは品質を担保する上で避けては通れないものです。
そのため世には多くのテストフレームワークが普及していますが、果たしてそれでスマートにテストが書けるようになるのでしょうか。
答えは NO です。
どんなに優れたテストフレームワークがあっても、テスト対象のモジュールがテストし易いように作られていなければ宝の持ち腐れ。テストフレームワークは銀の弾丸ではありません。
また、テストを不規則に書いてしまってもレビューやメンテナンスコストが跳ね上がってしまいます。
そこで本記事では、C# におけるユニットテストの実装プラクティスを示したいと思います。
テストフレームワークに MSTest を用いますが、他のフレームワークでも問題ありません。
まずテスタブルに
テスト対象として、byte コレクションを 16 進文字列に変換するメソッドを考えてみましょう。
public static class Extensions {
public static string ToHexString(IReadOnlyCollection<byte> source) {
if (source == null) throw new ArgumentNullException(nameof(source));
var format = AppSettings.ToLowerHexString ? "x2" : "X2";
var chars = new Char[source.Count * 2];
var ci = 0;
foreach (var b in source) {
var str = b.ToString(format, CultureInfo.InvariantCulture.NumberFormat);
chars[ci++] = str[0];
chars[ci++] = str[1];
}
return new String(chars);
}
}
public static class AppSettings {
public static bool ToLowerHexString { get; } = bool.Parse(ConfigurationManager.AppSettings[nameof(ToLowerHexString)] ?? "false");
}
引数に渡された byte コレクションを列挙し、それぞれを 16 進文字列に変換し繋げ合わせるだけのシンプルなロジックです。
ですがこのテスト、一筋縄ではいきません。
実際に書いてみましょう。
public void ToHexStringTest() {
// 大文字の 16 進文字列を返す?
Assert.AreEqual("FFC0", Extensions.ToHexString(new byte[] { 255, 192 });
// 小文字の 16 進文字列を返す?
Assert.AreEqual("ffc0", Extensions.ToHexString(new byte[] { 255, 192 });
}
ToHexString()
が大文字の 16 進文字列を返すのか、小文字の 16 進文字列を返すのかハッキリしません。
それもそのはず、どちらを返すかは AppSettings.ToLowerHexString
に依存している為です(そしてそれは ToHexString()
のシグネチャーからは読み取れない)。
もちろん リフレクション等を使って AppSettings.ToLowerHexString
の値を差し替えてテストを行う事も可能ですが、全然スマートじゃありません。
元凶はロジック中で AppSettings.ToLowerHexString
へ直接依存している事なので、この依存を外に出してしまいましょう。
public static class Extensions {
public static string ToHexString(IReadOnlyCollection<byte> source, bool toLower) {
if (source == null) throw new ArgumentNullException(nameof(source));
var format = toLower ? "x2" : "X2";
var chars = new Char[source.Count * 2];
var ci = 0;
foreach (var b in source) {
var str = b.ToString(format, CultureInfo.InvariantCulture.NumberFormat);
chars[ci++] = str[0];
chars[ci++] = str[1];
}
return new String(chars);
}
}
これで、戻り値の 16 進文字列が大文字なのか小文字なのかは ToHexString()
の引数で与えられるようになりました。
これなら先ほど詰まったテストコードもスマートに書き直せますね。
public void ToHexStringTest() {
// 大文字の 16 進文字列を返す
Assert.AreEqual("FFC0", Extensions.ToHexString(new byte[] { 255, 192 }, toLower: false);
// 小文字の 16 進文字列を返す
Assert.AreEqual("ffc0", Extensions.ToHexString(new byte[] { 255, 192 }, toLower: true);
}
重要な事は、テスト対象のロジックに注力できるよう無関係な依存は極力排除することです。
DI とか DIP とかを使えばいいと思います(適当
仕様の表明
メンバーのドキュメントコメントに仕様を書いておく事もとても有効です。
/// <summary>
/// <c>byte</c> のコレクションを 16 進文字列に変換します。
/// </summary>
/// <param name="source">16 進文字列に変換したい <c>byte</c> のコレクション。</param>
/// <param name="toLower">小文字の 16 進文字列に変換する場合は <c>true</c>、それ以外は <c>false</c>。</param>
/// <returns><paramref name="source"/> を 16 進数表記に変換した文字列。</returns>
/// <exception cref="ArgumentNullException"><paramref name="source"/> is <c>null</c>.</exception>
public static string ToHexString(IReadOnlyCollection<byte> source, bool toLower) {
...
}
これには2つの利点があって、
- メンバーのテストを書く際に、何をテストすれば良いかが明確になる(テスタブル!)
- レビューする際に、そのメンバーが満たすべき仕様をその場で確認できる。
もちろん仕様書を別途アウトプットする事も有効ですが、ドキュメントコメントに仕様を記述する事に比べて
- 仕様書を探さなければならない。
- 仕様書が古い可能性が高い(更新もれの可能性)。
といった点で弱いので、ドキュメントコメント上の仕様をマスターとし、必要なら仕様書をレプリカといった位置づけにするのが良いです。そもそも、仕様書の用途によっては記載する必要のないメンバーもあると思いますし。
ドキュメントコメント上に仕様を書いておけば、Visual Studio ならインテリセンスに概要が表示されるので、とってもおススメです!
表明は公開メンバーだけでいい
あと、そこそこ大事な事だけど、ドキュメントコメント上に仕様を記述するのは公開メンバーだけでOKです。public
とか protected
とかの修飾子が付いているメンバーですね。逆に private
とか internal
とかついているメンバーはオブジェクトやモジュール内に閉じているのでそこまで重要じゃないです。
特に private メンバーは仕様というより実装都合の産物である事が殆ど、というか全部じゃないかな。そもそも仕様が無いので書かなくて(書けなくて)いいです。テストもいらん。
public static class Extensions {
/// <summary>
/// <c>byte</c> のコレクションを 16 進文字列に変換します。
/// </summary>
/// <param name="source">16 進文字列に変換したい <c>byte</c> のコレクション。</param>
/// <param name="toLower">小文字の 16 進文字列に変換する場合は <c>true</c>、それ以外は <c>false</c>。</param>
/// <returns><paramref name="source"/> を 16 進数表記に変換した文字列。</returns>
/// <exception cref="ArgumentNullException"><paramref name="source"/> is <c>null</c>.</exception>
public static string ToHexString(IReadOnlyCollection<byte> source, bool toLower) {
if (source == null) {
throw new ArgumentNullException(nameof(source));
}
var chars = new char[source.Count * 2];
var ci = 0;
foreach (var b in source) {
chars[ci++] = ToHexChar(b >> 4, toLower);
chars[ci++] = ToHexChar(b & 0x0f, toLower);
}
return new string(chars);
}
private static char ToHexChar(int b, bool toLower) {
var val = b & 0x0f;
if (val < 10) {
return (char)('0' + val);
}
else {
var offset = toLower ? 'a' : 'A';
return (char)(offset + val - 10);
}
}
}
上記の ToHexChar()
のような private メソッドには表明やらテストを書いても良さそうですが、ToHexString()
の仕様に含まれているので ToHexString()
のテストを書けば十分。他のメソッドに呼ばれることも無いので、ぶっちゃけローカル関数でいい。
他のメソッドからも呼ばれる場合は、仕様を表明しておかないと後で割と面倒なことになるので、表明した方が良いです。でもそれ private じゃなくて public や internal なヘルパークラスの方が良いかもね。
internal メンバーには仕様があるかも知れないので、ドキュメントコメントで仕様を表明する事も、それを元にテストを記述する事もできますが、公開メンバーほど厳密に取り組まなくてもいいです。だって非公開なのだから、後で仕様?実装?を変えるの容易だからね。ころころ変わる可能性が高ければ、逆に書かない方が良いまである。仕様と実装が乖離する悪夢は見たくない。
なので、公開メンバーだけ抑えておけば必要十分です。
テストメソッドの構造
さっきは適当にワンライナーで書きましたが、実務ではちゃんと構造化した方が良いです。
凡そのテストメソッドの流れは
- テストの準備
- テストを実施
- テスト結果の検証
になると思うので、この構造を目に見える形(=読み易い)にします。
コードで書くとこんな感じ。
public void ToHexStringTest() {
// 準備
var source = new byte[] { 255, 192 };
var toLower = false;
var expected = "FFC0";
// 実施
var actual = Extensions.ToHexString(source, toLower);
// 検証
Assert.AreEqual(expected, actual);
}
常にこの構造となるよう意識する事で、テストを書くことを単純化できますし、レビュアーのリードコストも低減できます。
テストメソッドの粒度
原則、テストメソッドはテスト対象のメンバー毎に1つ。理由は2つ。
1つ目は上にも書いた通り、テストを書くことを単純化できる事。
ユースケース毎のテストが必要な場合でも、ユースケースをモデル化すればこのルールでテストを書けます。Clean Architecture とか使おう。
2つ目は、同じテスト対象に対するテストメソッドは得てして同じようなコードになるので、その無駄を省くため。テストメソッドの個数も最適化されるので、総じてテストがメンテナンスし易くなります。
// こういうのは読む量が増えるし、似ているコードは何が違うのかに
// 注意を払いながら読む必要があるのでかなり気を遣う。
// (結局同じだったりする)
public void バイト配列を大文字の16進文字列に変換() {
var source = new byte[] { 255, 192 };
var toLower = false;
var expected = "FFC0";
var actual = Extensions.ToHexString(source, toLower);
Assert.AreEqual(expected, actual);
}
public void バイト配列を小文字の16進文字列に変換() {
var source = new byte[] { 255, 192 };
var toLower = true;
var expected = "FFC0";
var actual = Extensions.ToHexString(source, toLower);
Assert.AreEqual(expected, actual);
}
public void バイト配列ではなくnullを渡して例外() {
var source = (byte[])null;
var toLower = false;
try {
Extensions.ToHexString(source, toLower);
}
catch (ArgumentNullException) { }
}
なので、テストケースはまとめて単一のテストメソッドにぶち込もう!(次の項に続く)
※ ちなみに、不具合 issue が立てられて、その確認用に別途テストメソッドを設けるのはアリだと思う。
/// <summary>
/// https://xxxxxxxxxx/issues/123
/// </summary>
public void Issue_123() {
...
}
テストケースの宣言
「テストケース毎にテストメソッドを分けて書くのは冗長で読むのも大変なので、なるべく纏めたい」というのが前項の内容。
実はこれ、多くのテストフレームワークがサポートしてくれます!
詳細はそっちを参照してね。
.
.
.
でもいいんですが、ここではテストフレームワークによらない(かつ簡潔な)書き方をしてみます。
public void ToHexStringTest() {
foreach (var item in TestCases()) {
// 実施
var message = $"No.{item.testNumber}";
var actual = (string)null;
var actualException = (Exception)null;
try {
actual = Extensions.ToHexString(item.source, item.toLower);
}
catch (Exception ex) {
actualException = ex;
}
// 検証
if (actualException == null) {
Assert.AreEqual(item.expected, actual, message);
}
Assert.AreEqual(item.expectedExceptionType, actualException?.GetType(), message);
}
// テストケース一覧。
(int testNumber, byte[] source, bool toLower, string expected, Type expectedExceptionType)[] TestCases() => new[] {
(1, new byte[] { 255, 192 }, false , "FFC0", (Type)null),
(2, new byte[] { 255, 192 }, true , "ffc0", (Type)null),
(3, null , default(bool), null , (Type)typeof(ArgumentNullException)),
};
}
テストケースは TestCases()
ローカル関数にまとめています。それぞれの要素は
Var | Description |
---|---|
testNumber | テスト番号。 |
source, toLower | テスト対象メソッドのパラメーター。 |
expected | テスト対象メソッドの戻り値の期待値。 |
exceptedExceptionType | テスト対象メソッドから投げられる例外型の期待値。 |
を ValueTuple<>
で固めたオブジェクトで構成しています。
これを foreach
で列挙して各テストケースのテストを 実施 -> 検証 の流れで行うだけです。
ね!簡単でしょ()
TestCaseRunner によるプラクティス
え?めんどくさい?
何がめんどくさいって、戻り値の検証と例外の検証がミックスされているテストコードを毎回書くのがめんどくさい、だるい、やってられない。
偉大なる三大美徳にも挙げられています。汝怠惰であれ!ってね。
どーせ毎回同じパターンになるだろうコードを毎回書いたり読んだりするのは不毛です。なので見やすく、書きやすくしましょう。
TestCaseRunner
使い方はいたって簡単。
new TestCaseRunner(テストケースの説明)
.Run(テスト対象のコード)
.Verify(テスト対象コードの戻り値の検証, 例外の検証);
これだけ。
先の ToHexStringTest()
に適用すると
public void ToHexStringTest() {
foreach (var item in TestCases()) {
new TestCaseRunner($"No.{item.testNumber}")
.Run(() => Extensions.ToHexString(item.source, item.toLower))
.Verify(item.expected, item.expectedExceptionType);
}
(int testNumber, byte[] source, bool toLower, string expected, Type expectedExceptionType)[] TestCases() => new[] {
(1, new byte[] { 255, 192 }, false , "FFC0", (Type)null),
(2, new byte[] { 255, 192 }, true , "ffc0", (Type)null),
(3, null , default(bool), null , (Type)typeof(ArgumentNullException)),
};
}
これだけ(2回目
もっと詳しく知りたい人は readme.md 読んで!
総括
5行にまとめた。
- 公開メンバーは、ドキュメントコメントで仕様を表明する。
- 表明された仕様以外の詳細(インフラ等)は切り離し、テスタブルにする。
- テストメソッドは「準備」「実施」「検証」のフローが自明となるよう整理する。TestCaseRunner で容易に実現!
- 原則として、テスト対象のメンバー1つに対してテストメソッド1つとする。テストケースはまとめる。
- 少なくとも、表明された仕様を試行するテストケースは作る。
Wishing you the best!