はじめに
自分はテストコードを書くとき、引数と期待値のリストを先に作ってテストする部分を後に書くことが多いです。
ReadOnlySpan<(int candidate, bool ok)> testCases = [
(-1, false),
(0, false),
(1, false),
(2, true),
(3, true),
(4, false),
];
foreach (var (candidate, ok) in testCases)
Assert.Equal(ok, IsPrime(candidate));
大抵の場合これでうまくいきますが、エラーが出てデバッグしたいときに問題があります。ブレークポイントをテスト部分に置いてもテストケースの順にテストが実行されるため、エラーが出たテストケースまでジャンプすることが難しいのです。テストケースが少ない場合(3とか)は F5 キーを連打すれば解決するのですが、頻繁にデバッグが必要なときは煩わしさがあります。
今回は望ましい挙動を模索的に実装していくだったり頑固なバグを取るような、デバッグのしやすさに着目したテストの書き方を考えてみます。
サンプルコード
テストコード
using Xunit;
public class __UnitTestExampleTest
{
private static bool IsPrime(int candidate)
{
if (candidate < 2)
return false;
if ((candidate & 1) != 0)
{
int num = (int)MathF.Sqrt(candidate);
for (int i = 3; i <= num; i += 2)
if (candidate % i == 0)
return false;
return true;
}
return candidate == 2;
}
[Fact]
void テストケースの値を先に書き出すやり方()
{
ReadOnlySpan<(int candidate, bool ok)> testCases = [
(-1, false),
(0, false),
(1, false),
(2, true),
(3, true),
(4, false),
(5, true),
(6, false),
(7, true),
(8, false),
(9, false),
(10, false),
];
foreach (var (candidate, ok) in testCases)
Assert.Equal(ok, IsPrime(candidate));
}
[Fact]
void テストケースを関数にくくりだして個別に引数を与えるやり方()
{
void Test(int candidate, bool ok) => Assert.Equal(ok, IsPrime(candidate));
Test(-1, false);
Test(0, false);
Test(1, false);
Test(2, true);
Test(3, true);
Test(4, false);
Test(5, true);
Test(6, false);
Test(7, true);
Test(8, false);
Test(9, false);
Test(10, false);
}
[Fact]
void テストケースを関数にくくりだして個別に引数を与えるやり方_テスト本体を後ろに書く()
{
Test(-1, false);
Test(0, false);
Test(1, false);
Test(2, true);
Test(3, true);
Test(4, false);
Test(5, true);
Test(6, false);
Test(7, true);
Test(8, false);
Test(9, false);
Test(10, false);
void Test(int candidate, bool ok) => Assert.Equal(ok, IsPrime(candidate));
}
[Fact]
void テストケースをベタ書きするやり方()
{
Assert.False(IsPrime(-1));
Assert.False(IsPrime(0));
Assert.False(IsPrime(1));
Assert.True(IsPrime(2));
Assert.True(IsPrime(3));
Assert.False(IsPrime(4));
Assert.True(IsPrime(5));
Assert.False(IsPrime(6));
Assert.True(IsPrime(7));
Assert.False(IsPrime(8));
Assert.False(IsPrime(9));
Assert.False(IsPrime(10));
}
}
デバッグのしやすさに着目したテストコード
void Test(int candidate, bool ok) => Assert.Equal(ok, IsPrime(candidate));
Test(-1, false);
Test(0, false);
Test(1, false);
Test(2, true);
Test(3, true);
Test(4, false);
テスト本体をローカル関数にくくりだしました。デバッグしたい Test()
にブレークポイントを置けば、無事ジャンプすることができます。
ローカル関数は参照するコードより後ろに書くこともできます。
Test(-1, false);
void Test(int candidate, bool ok) => Assert.Equal(ok, IsPrime(candidate));
例のようにコードが十分短い場合は、ローカル関数にくくりださずベタ書きでいいかもしれません。
Assert.False(IsPrime(-1));
Assert.False(IsPrime(0));
Assert.False(IsPrime(1));
Test()
は static
メソッドとしてテストメソッドの外に書くこともできますが、個人的にはローカル関数にするのが無難です。
- ローカル変数のキャプチャができないので引数が増えがち
-
IsPrime_TestHelper()
など、名前衝突を避けるために名前が長くなる - 該当のテストメソッド以外から呼ばないことを強制するのが難しい。インテリセンスの候補一覧に出てきてしまうのもあまりよくない
- テストメソッドと離れた場所に書かれると、スクロールしてテストコードと行ったり来たりが必要になり視認性が悪い
ローカル関数でできること
ローカル関数は機能が豊富です。テストを書くときに役立ちそうな機能をあげてみます。
参考:ローカル関数と匿名関数 https://ufcpp.net/study/csharp/functional/fun_localfunctions/
- ローカル変数の捕獲 https://ufcpp.net/study/csharp/functional/fun_localfunctions/#capture-local
- オプション引数 https://ufcpp.net/study/csharp/functional/fun_localfunctions/#lambda-default
おわりに
今回は普段テストコードを書いていてもやっとしたところの改善を考えてみました。↑ の例ではシンプルだったのであまり意味がなさそうに見えますが、複雑になってくると威力を発揮する気がします。ローカル関数はオプション引数を使ってうまくコード互換性を維持できたりするので、変更やリファクタリングにも強い印象です。