🙆

.NET で parallel unit test

2022/02/11に公開

.NET で parallel unit test

開発を始めた最初のころは単体テストにもそれほど時間がかかりませんが、開発が進んでいくとどんどん時間がかかるようになりますよね。全部動かすのがおっくうになって... 気が付いたら大半失敗するみたいなことに...

まではいったことないのですが、テストなんて時間がかからないに越したことはないです。

最近のパソコンは 8 core 16 スレッドなんてのも珍しくありません。並列で単体テストを動かせば、単純計算で 1/16 の時間で済むわけです。(単純計算すぎるけど...)

フルテストに 16 分かかってたものがなんと 1 分に! まあ、そんなに甘くはないでしょうけど、5 分くらいになればめっけもんですよね。

NUnit

NUnit は .NET では最初期の単体テストフレームワークです。現在のバージョンは並列実行がサポートされていますが、現状デフォルトではオフになっています。

アセンブリで Parallelizable(ParallelScope.Fixtures) 属性をつけると、アセンブリ内のテストはクラス単位で並列化されます。

Properties/AssemblyInfo.cs
using NUnit.Framework;

[assembly: Parallelizable(ParallelScope.Fixtures)]

簡単でいいですね!

しかし、並列テストでは標準出力が混ざってしまう可能性があります。TestATestB の二つのテストが同時に実行された場合、標準出力がどちらのものかわからなくなるわけです。これは NUnit に限ったことではありませんが...

NUnit はテストの開始時に Console.Out を専用のものに置き換えています。ここで標準出力の内容をキャプチャしてテスト結果に含めているのですが、こいつが並列テスト対応になっていて、混ざることはありません。すごいですね。

ただし、調子に乗って Parallelizable(ParallelScope.Children) を指定してはいけません。NUnit はテストクラスのインスタンスをすべてのテストメソッドで共有しているからです。

public sealed class TestClass
{
    private Helper _helper;

    [SetUp]
    public void SetUp()
    {
        _helper = new Helper();
    }
    
    [TearDown]
    public void TearDown()
    {
        _helper.Dispose();
    }
    
    [Test] public void Test1() { /* ... */ }
    [Test] public void Test2() { /* ... */ }
}

このとき、Test1Test2 は同じインスタンスです。_helper 変数は両方のテストで同じものが使われます。メソッド単位で並列テストをするとおかしなことになります。

  • それぞれのテストで使われる Helper インスタンスはどちらのテストで new されたインスタンスかわからなくなる
  • Dispose() が呼び出された後の Helper インスタンスにアクセスしてテストが失敗することもあります (失敗しないこともあるのがまた怖い)

Parallelizable(ParallelScope.Children) は使ってはいけません。Parallelizable(ParallelScope.Fixtures) にしましょう!

パラメーターテストをすると一つのテストクラスでも数十くらいのテストになるので、できれば並列化したいところですがあきらめましょう。製品開発では相当な数のテストクラスを書くので、テストクラス単位での並列化でも十分でしょう。

標準出力は本当に混ざらないのか?

結論から言うと、混ざることもあります。

Microsoft.Extensions.Logging で使われている標準の ConsoleLoggerProviderConsoleLogger をキャッシュしています。ですので、他のテストの標準出力に出力されてしまうことがあるようです。

Extensions.Logging.NUnit あたりを使えば解決します。それほど難しくないので、自作してもよいでしょう。

どのテストを実行しているかの追跡は AsyncLocal<T> を利用しているようです。自分でスレッドを作ったりするとダメそうです。こちらは厄介です。

xUnit.net

xUnit.net は Nunit より新しいのでいろいろと期待できます。そう思っていた時期がありました。

xUnit.net は標準でテストはコレクション単位で並列実行されます。コレクションって何かというと、標準ではテストクラスです。厳密にはテストクラスを一つだけ含むコレクションが作られて、その単位で並列実行されます。

NUnit と違って、テストクラスのインスタンスを共有しません。テストメソッドごとにテストクラスのインスタンスは新しく作成されます。

ということはテストメソッド単位で並列実行ができそう! と思いきや、そんなことはできませんでした。並列実行の最小単位はコレクションでした...

テストスケジューラーを組みなおせば行けるのですが、不整合が起きないように書くのはかなり大変そうです。そもそも一見組みなおせるような作りなのですが、独自のスケジューラーはかなり書きにくいつくりをしていてあきらめました。

まあ、テストクラス単位で並列実行されるなら NUnit と同じでなのであきらめることにします。

とはいえ、残念仕様が結構多いんです。

NUnit と違って標準出力をキャプチャしたりしません。テストクラスのコンストラクターで出力用のヘルパーオブジェクトを受け取る必要があります。

public sealed class TestA
{
    private readonly ITestOutputHelper _output;

    public TestA(ITestOutputHelper output)
    {
        _output = output;
    }

    [Fact]
    public void Test()
    {
        _output.WriteLine("Hello!");
    }
}

まあ、そういうもんだと思えば問題はないのですが、Console.Write() を直接使っているような行儀の悪いライブラリの標準出力をキャプチャするのは大変です。

もう一つが、複数のテストクラスでオブジェクトを共有する場合、それらのテストクラスをすべて同じコレクションに含められてしまうことです。

TestATestB で同じオブジェクトを共有するには次のように書きます。

[Collection("WebHost")]
public sealed class TestA
{
    private readonly IWebHost _host;

    public TestA(IWebHost host)
    {
        _host = host;
    }
    
    [Fact] public void Test() { /* ... */ }
}

[Collection("WebHost")]
public sealed class TestB
{
    public TestB(IWebHost host) { /* ... */ }
    // ...
}

結果、この二つのテストクラスは同じ WebHost コレクションに所属することになり、シーケンシャルに実行されます。作成に時間がかかるウェブホストのようなインスタンスは共有して高速化したつもりでも、なぜか遅くなってしまうという...

ほかにも残念な...

xUnit.net の最大の欠点はパラメーターテストがテストクラスに使えないことです。

パラメーターはテストメソッドにしか指定できないため、セットアップや後始末の処理をテストメソッドに書くことになりがちです。結構しんどいです。(おかげで、セットアップや後始末を長々書かないで済むようなプログラミングをする癖がつきましたが...)

せっかく、セットアップはコンストラクターで、後始末は IDisposable.Dispose() でというわかりやすい仕様なのにもったいないです。

public sealed class TestA
{
    // readonly がつけられるの最高!
    private readonly Helper _helper;

    public TestA()
    {
        _helper = new Helper();
    }

    [Fact] public void Test() { /* ... * }
}

まとめ

意外と NUnit 悪くないですね。少なくとも、NUnit 3 を使っているならば、並列実行のために xUnit.net に乗り換える必要はありません。

これから書く方は... NUnit の方がいいと思います。並列実行についてよりも、テストクラスへのパラメーターテストに対応していない xUnit.net は厳しいです。

  • NUnit
    • テストクラスのインスタンスを共有しないでほしい
    • xUnit.net のように、セットアップはコンストラクターで、後始末は IDisposable.Dispose() でできるようにしてほしい
  • xUnit.net
    • パラメーターテストをテストクラスにも対応して!
    • テストの並列単位とコレクションを分けて!

って感じです。

Discussion