非同期プログラミング

非同期コードの単体テスト

Stephen Cleary

コード サンプルのダウンロード

最近の開発では単体テストが重要視されます。プロジェクトの単体テストを行うメリットとしてよく知られているのは、バグの数が減ること、リリースまでの時間が短くなること、結び付きの強い設計にならないことなどです。どれもすばらしいメリットですが、開発者に直接関わるメリットもあります。単体テストを作成すると、コードに対する自信が深まります。テストが済んだコードへの機能追加やバグ修正が容易になります。これは、コードが変化している間も単体テストがセーフティ ネットの役割を果たすためです。

非同期コードの単体テストの作成には、いくつか特有の課題があります。さらに、単体テストとモック フレームワークにおける非同期サポートの現状はさまざまで、今も進化が続いています。今回は、MSTest、NUnit、および xUnit を取り上げますが、一般的な原則はどの単体テスト フレームワークにも当てはまります。ここでは、例のほとんどで MSTest 構文を使用していますが、他のフレームワークで動作に違いがある場合はその都度指摘します。付属のコード サンプルには、3 つのフレームワークすべての例を含めています。

詳しい説明に入る前に、async キーワードと await キーワードのしくみとなる概念モデルを簡単に紹介します。

async と await の概要

async キーワードには 2 つの役割があります。1 つはそのメソッド内で await キーワードを有効にすることです。もう 1 つは、そのメソッドをステート マシンに変換することです (これは yield キーワードがイタレーターのブロックをステート マシンに変換するしくみに似ています)。async メソッドは、可能な場合は Task または Task<T> を返すようにします。async メソッドは void を返すことも可能ですが、async void メソッドを使用 (またはテスト) するのは非常に難しいためお勧めしません。

async メソッドから返される Task インスタンスは、ステート マシンによって管理されます。ステート マシンは、返される Task インスタンスを作成後、いずれその Task インスタンスを完了します。

async メソッドは、同期処理として実行を開始します。async メソッドは、await 演算子に到達して初めて非同期になります。await 演算子は、Task インスタンスなどの "待機可能な" インスタンスを 1 つ、引数として受け取ります。まず、await 演算子が待機可能なインスタンスをチェックし、既に完了しているかどうかを確認します。完了していれば、メソッドは (同期処理として) 続行します。待機可能なインスタンスがまだ完了していなければ、await 演算子によってメソッドが "一時停止" します。待機可能なインスタンスが完了するとメソッドが再開します。await 演算子の 2 つ目の役割は、待機可能なインスタンスから結果を受け取り、エラーで完了した場合に例外を発生させることです。

async メソッドから返される Task や Task<T> は、概念上は、そのメソッドの実行を表しています。メソッドが完了すると、タスクも完了します。メソッドが値を返す場合は、結果として、タスクがその値で完了しています。メソッドが例外をスローする場合 (かつメソッドで例外をキャッチしない場合)、例外が発生してタスクが完了しています。

この簡単な概要から直接得られる教訓が 2 つあります。1 つは、非同期メソッドの結果をテストする際に重要なのは、メソッドから返される Task だということです。async メソッドは、完了、結果、および例外を報告するために Task を使用します。もう 1 つは、待機可能なインスタンスが既に完了していると、await 演算子が特別な動作をすることです。これについては、後ほど非同期スタブを取り上げるときに解説します。

合格することが間違っている単体テスト

自由市場経済学では、損失は利益と同じくらい重要です。損失があるからこそ、企業は消費者が購入したくなるものを生産し、システム全体で最適な資源配分を進めるように努めます。これと同じように、単体テストに失敗することも、テストに成功するのと同じくらい重要です。単体テストが失敗すべきときに失敗しなければ、テストの成功も意味をなしません。

単体テストに問題があれば、失敗を想定している単体テストが (間違って) 成功します。これは、テスト駆動開発 (TDD) で、赤/緑/リファクタリングのサイクルが多用される理由です。サイクルの "赤" の部分では、コードが不適切な場合に単体テストが失敗することを確認します。間違っていることがわかっているコードをテストすることは、最初はばかげたことのように思えますが、テストは必要なときに確実に失敗しなければならないため、実際には非常に重要です。TDD サイクルの "赤" の部分は、実はテストのテストなのです。

このことに留意して、次の非同期メソッドのテストを考えてみます。

public sealed class SystemUnderTest
{
  public static async Task SimpleAsync()
  {
    await Task.Delay(10);
  }
}

非同期処理の単体テストの初心者が初めて作成するテストは、多くの場合、次のようになります。

// Warning: bad code!
[TestMethod]
public void IncorrectlyPassingTest()
{
  SystemUnderTest.SimpleAsync();
}

残念ながら、実際のところ、この単体テストでは非同期メソッドを適切にテストすることはできません。テスト対象のコードを失敗させるために次のように修正しても、単体テストは依然、成功します。

public sealed class SystemUnderTest
{
  public static async Task SimpleAsync()
  {
    await Task.Delay(10);
    throw new Exception("Should fail.");
  }
}

これは、非同期メソッドの動作をテストする場合は返される Task を確認する必要があるという、async/await 概念モデルの最初の教訓を示しています。最適な確認方法は、テスト対象のメソッドから Task が返されるのを待機することです。この例は、赤/緑/リファクタリング テスト開発サイクルのメリットも示しています。テスト対象のコードが失敗する場合は、テストも失敗すべきです。

多くの最新単体テスト フレームワークは、Task を返す非同期単体テストをサポートします。IncorrectlyPassingTest メソッドを実行すると、コンパイラ警告 CS4014 が出力され、await を使用して、SimpleAsync から返される Task を使用するように推奨されます。Task を待機するように単体テスト メソッドを変更する最も自然なアプローチは、テスト メソッドを async Task メソッドに変更することです。次のように変更すると、テスト メソッドが (正しく) 失敗するようになります。

[TestMethod]
public async Task CorrectlyFailingTest()
{
  await SystemUnderTest.FailAsync();
}

async void の単体テストを避ける

非同期処理の経験が豊富な開発者は、async void を避けることを知っています。2013 年 3 月号「非同期プログラミングのベスト プラクティス」(msdn.microsoft.com/ja-jp/magazine/jj991977.aspx) で、async void に関する問題を説明しました。async void の単体テストを行う手法を導入しても、単体テスト フレームワークでテスト結果を簡単に取得できるようになるわけではありません。このような難しさがあるにもかかわらず、一部の単体テスト フレームワークは独自の SynchronizationContext を用意して、async void の単体テストをサポートしています。単体テストはこの SynchronizationContext で実行されます。

SynchronizationContext によりテストの実行環境が変わるため、これを用意することには賛否両論があります。特に、async メソッドが Task を待機する際は、既定で現在の SynchronizationContext で async メソッドが再開されます。そのため、SynchronizationContext の有無によって、テスト対象のシステムの動作が間接的に変化します。SynchronizationContext の詳細について興味がある方は、このテーマについての MSDN マガジンの記事 (msdn.microsoft.com/ja-jp/magazine/gg598924.aspx) をご覧ください。

MSTest には SynchronizationContext がありません。実際、MSBuild はプロジェクトの中に async void の単体テストを使用するテストを見つけると、警告 UTA007 を出力し、単体テスト メソッドでは void ではなく Task を返すべきであることが開発者に通知されます。MSBuild は async void の単体テストを実行しません。

NUnit はバージョン 2.6.2 の時点では async void の単体テストをサポートしていません。NUnit の次期メジャー更新のバージョン 2.9.6 では async void の単体テストをサポートしますが、バージョン 2.9.7 でサポートを中止することが既に決まっています。NUnit は、async void の単体テストのためだけに SynchronizationContext を用意しています。

xUnit に関しては、本稿執筆時点では、バージョン 2.0.0 で async void の単体テストのサポートを追加する予定です。NUnit とは異なり、xUnit では、非同期メソッドを含むすべてのテスト メソッド用に SynchronizationContext を用意しています。とは言え、MSTest が async void の単体テストをサポートしないことと、NUnit が以前の決定を覆してサポートを打ち切ることから、xUnit でもバージョン 2 のリリース前に async void の単体テストのサポートが中止されることになっても驚きはありません。

結局のところ、async void の単体テストとは、フレームワークにとってサポートが複雑で、テスト実行環境の変更が必要になり、async Task の単体テストには何のメリットももたらさない存在です。その上、async void の単体テストのサポートは、フレームワークごと、さらにはフレームワークのバージョンごとに異なります。このような理由から、async void の単体テストは避けることをお勧めします。

async Task の単体テスト

void を返す async の単体テストで生じる問題はいずれも、Task を返す async の単体テストでは発生しません。Task を返す async の単体テストは、ほぼすべての単体テスト フレームワークで幅広くサポートされています。MSTest は Visual Studio 2012 で、NUnit はバージョン 2.6.2 と 2.9.6 で、xUnit はバージョン 1.9 でサポートが追加されました。そのため、3 年以内にリリースされた単体テスト フレームワークであれば、async Task の単体テストは機能します。

残念ながら、古い単体テスト フレームワークは、async Task の単体テストを理解しません。本稿執筆時点で、async Task の単体テストをサポートしていない主要プラットフォームが 1 つあります。Xamarin です。Xamarin はカスタマイズされた古いバージョンの NUnitLite を使用しており、その NUnitLite は現在 async Task の単体テストをサポートしていません。近い将来、サポートが追加されると予想されます。それまでの回避策は、async のテスト ロジックをスレッド プールの別のスレッドで実行し、実際のテストが完了するまで (同期をとって) 単体テスト メソッドをブロックすることです。効率はよくありませんが、問題なく機能します。Wait を使用すると AggregateException 内に例外がラップされるため、回避策のコードでは次のように、Wait ではなく GetAwaiter().GetResult() を使用します。

[Test]
public void XamarinExampleTest()
{
  // This workaround is necessary on Xamarin,
  // which doesn't support async unit test methods.
  Task.Run(async () =>
  {
    // Actual test code here.
  }).GetAwaiter().GetResult();
}

例外のテスト

テストの際、ユーザーが自分のプロファイルを更新できるといった成功するシナリオをテストするのは当然です。しかし、ユーザーが他人のプロファイルを更新できないといった例外をテストすることも非常に重要です。例外は、メソッドのパラメーターとまったく同様に、API の外部で扱われます。そのため、失敗を想定している場合は、内部のコードの単体テストを行うことが重要になります。

従来は、単体テストの失敗を想定していることを示すために、ExpectedExceptionAttribute をその単体テスト メソッドに配置していました。ただし、ExpectedExceptionAttribute にはいくつか問題がありました。1 つは、コード全体として単体テストが失敗することしか想定できないことです。テストの特定部分のみの失敗を想定していることを示す方法はありません。これは、非常に単純なテストでは問題になりませんが、テストの規模が大きくなると誤解を招くおそれがあります。ExpectedExceptionAttribute の 2 つ目の問題は、例外の型しかチェックできないことです。エラー コードやメッセージなどの属性を確認する方法はありません。

このような理由から、近年は、コードの重要な部分をデリゲートとして受け取り、スローされた例外を返す Assert.ThrowsException のようなものを使用する方向に向かっています。これで、ExpectedExceptionAttribute の問題点が解消されます。デスクトップ MSTest フレームワークがサポートしているのは ExpectedExceptionAttribute のみ、Windows ストア単体テストプロジェクト用の新しい MSTest フレームワークがサポートしているのは Assert.ThrowsException のみです。xUnit は Assert.Throws のみをサポートしており、NUnit は両方のアプローチをサポートしています。図 1 は、MSTest 構文を使用した、両方の種類のテストの例です。

図 1 同期テスト メソッドを使用した例外のテスト

// Old style; only works on desktop.
[TestMethod]
[ExpectedException(typeof(Exception))]
public void ExampleExpectedExceptionTest()
{
  SystemUnderTest.Fail();
}
// New style; only works on Windows Store.
[TestMethod]
public void ExampleThrowsExceptionTest()
{
  var ex = Assert.ThrowsException<Exception>(() 
    => { SystemUnderTest.Fail(); });
}

では、非同期コードについてはどうでしょう。async Task の単体テストは、MSTest と NUnit ではどちらも ExpectedExceptionAttribute を使用して完璧に機能します (xUnit は ExpectedExceptionAttribute をまったくサポートしていません)。ただし、async 対応の ThrowsException のサポートは、あまり一貫性がありません。MSTest は async ThrowsException をサポートしますが、Windows ストア単体テスト プロジェクトおいてのみです。xUnit は、async ThrowsAsync を xUnit 2.0.0 のプレリリース ビルドで導入しています。

NUnit はもっと複雑です。本稿執筆時点では、NUnit は Assert.Throws などの検証メソッドで非同期コードをサポートしています。しかし、これを機能させるために NUnit は SynchronizationContext を利用しており、そのため async void の単体テストと同じ問題が発生します。また、構文は今のところ、図 2 の例に示すようにもろい部分があります。NUnit は既に async void の単体テストのサポート中止を予定しています。そのため、このサポートが同時に打ち切られても不思議はないでしょう。手短に言えば、このアプローチは使用しないことをお勧めします。

図 2 不安定な NUnit 例外テスト

[Test]
public void FailureTest_AssertThrows()
{
  // This works, though it actually implements a nested loop,
  // synchronously blocking the Assert.Throws call until the asynchronous
  // FailAsync call completes.
  Assert.Throws<Exception>(async () => await SystemUnderTest.FailAsync());
}
// Does NOT pass.
[Test]
public void BadFailureTest_AssertThrows()
{
  Assert.Throws<Exception>(() => SystemUnderTest.FailAsync());
}

このような事実から、async 対応の ThrowsException/Throws に対する現在のサポートは、十分ではありません。今回の単体テスト コードでは、図 3 の AssertEx に非常によく似た型を使用しています。この型は、アサーションを行うのではなく Exception オブジェクトそのものをスローするだけという点でかなりシンプルですが、主な単体テスト フレームワークすべてで機能します。

図 3 例外を非同期にテストする AssertEx クラス

using System;
using System.Threading.Tasks;
public static class AssertEx
{
  public static async Task<TException> 
    ThrowsAsync<TException>(Func<Task> action,
    bool allowDerivedTypes = true) where TException : Exception
  {
    try
    {
      await action();
    }
    catch (Exception ex)
    {
      if (allowDerivedTypes && !(ex is TException))
        throw new Exception("Delegate threw exception of type " +
          ex.GetType().Name + ", but " + typeof(TException).Name +
          " or a derived type was expected.", ex);
      if (!allowDerivedTypes && ex.GetType() != typeof(TException))
        throw new Exception("Delegate threw exception of type " +
          ex.GetType().Name + ", but " + typeof(TException).Name +
          " was expected.", ex);
      return (TException)ex;
    }
    throw new Exception("Delegate did not throw expected exception " +
      typeof(TException).Name + ".");
  }
  public static Task<Exception> ThrowsAsync(Func<Task> action)
  {
    return ThrowsAsync<Exception>(action, true);
  }
}

このようにすると、async Task の単体テストでは、次のように ExpectedExceptionAttribute ではなく、新しい ThrowsAsync を 使用できます。

[TestMethod]
public async Task FailureTest_AssertEx()
{
  var ex = await AssertEx.ThrowsAsync(() 
    => SystemUnderTest.FailAsync());
}

async のスタブとモック

私見ですが、スタブ、モック、フェイクなどの仕掛けなしでテストできるのは、ごくシンプルなコードのみです。ここでは、これらテストを補助する仕掛けをすべてモックと呼ぶことにします。モックを使用すると、実装ではなくインターフェイスに対してプログラムを作成できるようになります。非同期メソッドは、インターフェイスと完璧に連携します。図 4 のコードは、コードで非同期メソッドとのインターフェイスを利用する方法を示しています。

図 4 インターフェイスから非同期メソッドを使用する

public interface IMyService
{
  Task<int> GetAsync();
}
public sealed class SystemUnderTest
{
  private readonly IMyService _service;
  public SystemUnderTest(IMyService service)
  {
    _service = service;
  }
  public async Task<int> RetrieveValueAsync()
  {
    return 42 + await _service.GetAsync();
  }
}

このコードでは、簡単にインターフェイスのテスト実装を作成してテスト対象のシステムに渡すことができます。図 5 は、非同期操作の成功、非同期操作の失敗、および同期操作の成功という 3 つの主要なスタブ ケースのテスト方法を示しています。非同期操作の成功と非同期操作の失敗が、非同期コードをテストする主な 2 つのシナリオですが、同期操作のケースをテストすることも重要です。それは、待機可能なインスタンスが既に完了している場合、await 演算子の動作が異なり同期操作になるためです。図 5 のコードでは、Moq モック フレームワークを使用してスタブ実装を生成しています。

図 5 非同期コードのスタブ実装

[TestMethod]
public async Task RetrieveValue_SynchronousSuccess_Adds42()
{
  var service = new Mock<IMyService>();
  service.Setup(x => x.GetAsync()).Returns(() => Task.FromResult(5));
  // Or: service.Setup(x => x.GetAsync()).ReturnsAsync(5);
  var system = new SystemUnderTest(service.Object);
  var result = await system.RetrieveValueAsync();
  Assert.AreEqual(47, result);
}
[TestMethod]
public async Task RetrieveValue_AsynchronousSuccess_Adds42()
{
  var service = new Mock<IMyService>();
  service.Setup(x => x.GetAsync()).Returns(async () =>
  {
    await Task.Yield();
    return 5;
  });
  var system = new SystemUnderTest(service.Object);
  var result = await system.RetrieveValueAsync();
  Assert.AreEqual(47, result);
}
[TestMethod]
public async Task RetrieveValue_AsynchronousFailure_Throws()
{
  var service = new Mock<IMyService>();
  service.Setup(x => x.GetAsync()).Returns(async () =>
  {
    await Task.Yield();
    throw new Exception();
  });
  var system = new SystemUnderTest(service.Object);
  await AssertEx.ThrowsAsync(system.RetrieveValueAsync);
}

モック フレームワークに関して言えば、非同期単体テストに提供されているサポートはあまりありません。動作を何も定義していないメソッドの既定の動作について考えてみます。既定で例外をスローするモック フレームワーク (Microsoft Stubs など) もあれば、既定値を返すフレームワーク (Moq など) もあります。非同期メソッドが Task<T> を返す場合、単純な既定の動作は default(Task<T>) を返すこと、言い換えれば、NullReferenceException を発生させる null タスクを返すことです。

これは、望ましくない動作です。非同期メソッドの妥当な既定の動作は、Task.FromResult(default(T)) を返すことです。これは、既定値 T で完了する Task です。これにより、テスト対象のシステムで、返された Task を使用できるようになります。Moq は、バージョン 4.2 で、非同期メソッドの既定の動作として、このスタイルが実装されました。本稿執筆時点で私の知る限り、このような非同期処理対応の既定の動作を使用しているモック ライブラリは Moq だけです。

まとめ

async と await は、Visual Studio 2012 に導入されてからずっと利用されており、いくつかベスト プラクティスが生まれるのに十分な時間がたちました。単体テスト フレームワークと、モック ライブラリなどのヘルパー コンポーネントは、非同期処理対応のサポートに一貫性を持たせる方向に進んでいます。非同期単体テストは、現在既に現実になっており、今後、さらに洗練されていくでしょう。最近非同期単体テストを行っていない場合は、今が単体テスト フレームワークとモック ライブラリを更新して、最高の非同期処理サポートを手に入れる絶好のタイミングです。

単体テスト フレームワークは async void の単体テストを避け、async Task の単体テストを行う方向に進んでいます。async void の単体テストがある場合は、今すぐ async Task の単体テストに変更することをお勧めします。

これから数年間で、非同期処理の単体テストで失敗することが前提のテストのサポートが充実することでしょう。お使いの単体テスト フレームワークに十分なサポートが提供されるまで、ここで紹介した AssertEx 型や、これに似た型を特定の単体テスト フレームワーク用にカスタマイズして使用することをお勧めします。

適切な非同期単体テストは、非同期処理の実現にとって重要な要素です。今回紹介したフレームワークやライブラリが非同期機能に対応していることは喜ばしいことです。非同期機能がまだ Community Technology Preview だった数年前、私が初めて行ったライトニング トークのテーマの 1 つが、非同期機能の単体テストでした。最近では、非同期機能の単体テストの実行が一層簡単になっています。


Stephen Clearyはミシガン北部在住の夫、父親兼プログラマです。彼は、マルチスレッドと非同期プログラミングに 16 年間取り組み、最初の Community Technology Preview から Microsoft .NET Framework の非同期サポートを使ってきました。『Concurrency in C# Cookbook』(O’Reilly Media、2014 年) の著者でもあります。彼のホーム ページとブログは、stephencleary.com (英語) から利用できます。

この記事のレビューに協力してくれたマイクロソフト技術スタッフの James McCaffrey に心より感謝いたします。