异步编程

单元测试异步代码

Stephen Cleary

下载代码示例

单元测试是现代开发的基础。对项目进行单元测试的好处非常容易理解:单元测试降低了 Bug 数量,缩短了上市时间,防止过度耦合的设计。这些都是很好的优势,但它还有更多与开发人员更直接相关的优点。在我编写单元测试时,我会对代码更有信心。在已测试的代码中更易于添加功能或修复 Bug,因为在代码发生更改时,单元测试起着安全网的作用。

为异步代码编写单元测试面临一些独特的挑战。另外,单元测试和模拟框架中异步支持的当前状态有所不同,且仍在不断发展。本文将探讨 MSTest、NUnit 和 xUnit,但一般原则适用于任何单元测试框架。本文中的大多数示例使用 MSTest 语法,但我将会在此过程中指出任何行为的差异。代码下载包含所有三种框架的示例。

在深入研究具体示例之前,我将简要回顾 async 和 await 关键字如何进行工作的概念模型。

Nutshell 中的 Async 和 Await

async 关键字执行两项操作:它将启用该方法中的 await 关键字,并将此方法转换为状态机(类似于 yield 关键字如何将迭代器块转换为状态机)。异步方法应尽可能返回 Task 或 Task<T>。允许异步方法返回 void,但并不推荐,因为使用(或测试)async void 方法十分困难。

从异步方法返回的任务实例由状态机进行管理。状态机将创建要返回的任务实例,并在稍后完成此任务。

异步方法开始同步执行。只有当异步方法到达 await 运算符时,此方法才成为异步。await 运算符采用单个参数 awaitable,如 Task 实例。首先,await 运算符将确认 awaitable 是否已经完成,如果已完成,此方法继续(同步)。如果 awaitable 尚未完成,await 运算符将“暂停”此方法,并在 awaitable 完成之后继续执行此方法。await 运算符执行的第二项操作是检索来自 awaitable 的任何结果,如果 awaitable 完成但有错误则会引发异常。

异步方法返回 Task 或 Task<T> 从概念上讲表示执行了该方法。该方法完成时,任务完成。如果该方法返回一个值,则此任务完成并将该值作为其结果。如果该方法引发异常(且没有捕获此异常),则此任务完成但有异常。

从这个简要概述可以得出两条直接的经验。第一条经验,在测试异步方法的结果时,重要的部分是它返回的 Task。异步方法使用该 Task 来报告操作完成、结果和异常。第二条经验是在 awaitable 已经完成时,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.");
  }
}

这说明了来自于 async/await 概念模型的第一条经验:要测试异步方法的行为,您必须观察它返回的任务。最好的方法是等待任务从待测试的方法返回。此示例还说明了红/绿/重构测试开发周期的好处,您必须确保在待测试的代码失败时,测试失败。

大多数现代单元测试框架支持返回任务的异步单元测试。IncorrectlyPassingTest 方法将会导致编译器警告 CS4014,建议用 await 来使用从 SimpleAsync 返回的任务。在单元测试方法更改为等待任务时,最简单的方法就是将测试方法更改为 async Task 方法。这可确保测试方法(正确地)失败:

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

避免 Async Void 单元测试

经验丰富的 async 用户知道要避免 async void。在 2013 年 3 月份的文章《异步编程的最佳做法》(bit.ly/1ulDCiI) 中,我描述了 async void 的问题。async void 单元测试方法并没有为其单元测试框架提供检索测试结构的简单方法。尽管存在此困难,但一些单元测试框架通过提供自带的执行单元测试的 SynchronizationContext,支持 async void 单元测试。

提供 SynchronizationContext 多少存在一些争议,因为它确实改变了测试运行的环境。具体来说,在异步方法等待任务时,默认情况下,它将继续使用当前 SynchronizationContext 上的异步方法。因此 SynchronizationContext 是否存在将间接更改待测试系统的行为。如果您对 SynchronizationContext 的详细信息感到好奇,请参阅我撰写的有关此主题的 MSDN 杂志文章 bit.ly/1hIar1p

MSTest 不会提供 SynchronizationContext。事实上,在 MSBuild 发现使用 async void 单元测试的项目中的测试时,它将对其进行检测并发布警告 UTA007,通知用户此单元测试方法应返回 Task 而不是 void。MSBuild 不会运行 async void 单元测试。

从版本 2.6.2 开始,NUnit 支持 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 单元测试

返回 Task 的异步单元测试没有返回 void 的异步单元测试所有的任何问题。返回 Task 的异步单元测试受几乎所有单元测试框架的广泛支持。MSTest 在 Visual Studio 2012 中增加了支持,NUnit 在版本 2.6.2 和 2.9.6 中增加了支持,且 xUnit 在版本 1.9 中增加了支持。因此,只要您的单元测试框架是 3 年之内的版本,async Task 单元测试都应该可以正常工作。

遗憾的是,过期的单元测试框架不了解 async Task 单元测试。到撰写本文时为止,还有一个主要的平台不支持 async Task 单元测试:Xamarin。Xamarin 使用自定义的 NUnitLite 的较低版本,目前并不支持 async Task 单元测试。我期望在不久的将来会增加支持。同时,我使用了一个低效但能起作用的解决方法:在其他线程池线程上执行异步测试逻辑,然后(同步地)阻止单元测试方法,直到实际测试完成。此解决方法代码使用 GetAwaiter().GetResult() 而不是 Wait,因为 Wait 将任何异常封装在 AggregateException 内:

[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 以表明此单元测试预计会失败。但是,ExpectedException­Attribute 有几个问题。第一个问题是它只能预计整个单元测试失败,而无法仅指明测试的特定部分预计失败。在非常简单的测试中这不是问题,但随着测试时间变长,会造成误导性的结果。ExpectedExceptionAttribute 的第二个问题是,它局限于检查异常的类型,而无法检查其他属性,如错误代码或消息。

由于上述原因,因此近年来更倾向于使用类似于 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)。但是,对异步就绪 ThrowsException 的支持就不够统一了。MSTest 支持异步 ThrowsException,但仅适用于 Windows 应用商店单元测试项目。xUnit 在 xUnit 2.0.0 的预发布版本中引入了异步 ThrowsAsync。

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());
}

因此,目前对异步就绪 ThrowsException/Throws 的支持并不是很让人满意。在我自己的单元测试代码中,我使用类似于图 3 中 AssertEx 的类型。此类型相当简单,因为它只引发空白的异常对象,而不是发表声明,但此相同代码在所有主要的单元测试框架中都能正常运行。

图 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 单元测试使用更现代的 ThrowsAsync,而不是 ExpectedExceptionAttribute,如下所示:

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

异步存根和模拟

我认为,不使用存根、模拟、虚设或其他此类设备,只能测试最简单的代码。在本篇介绍性文章中,我只会把所有这些测试助手当作模拟。在使用模拟时,编程为接口而不是实现将十分有用。异步方法与这些接口完美地结合使用;图 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 显示了如何测试三个主要存根案例:异步成功、异步失败和同步成功。异步成功和失败是测试异步代码的两个主要方案,但测试同步案例也很重要。这是因为如果 await 运算符的 awaitable 已经完成,则 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> 时,简单的默认行为是返回默认 (Task<T>),换言之,即返回 null 任务,而这将造成 NullReferenceException。

这种行为是不会令人满意的。异步方法的更合理默认行为应该是返回 Task.FromResult­(default(T)),即任务完成时返回默认值 T。这使待测试的系统可以使用返回的任务。Moq 在 Moq 版本 4.2 中实现了异步方法的这种默认行为方式。据我所知,到撰写本文时为止,这是唯一使用这种异步友好默认方式的模拟库。

总结

自引入 Visual Studio 2012 以来,Async 和 await 就已经存在了,它们有足够长的时间来等待一些最佳做法出现。单元测试框架和帮助器组件(如模拟库)正在趋向于提供一致的异步友好支持。如今异步单元测试已经成为现实,未来将发展得更好。如果最近您还没有进行异步单元测试,现在就是更新您的单元测试框架和模拟库以确保得到最好异步支持的好机会。

单元测试框架正在逐渐减少使用 async void 单元测试,而趋向于使用 async Task 单元测试。如果您有任何 async void 单元测试,我建议您立即将其更改为 async Task 单元测试。

我预计在未来几年,您就会在异步单元测试方面看到对测试失败案例的更好支持。在您的单元测试框架得到良好支持之前,我建议您使用本文中提到的 AssertEx 类型,或更适合您的特定单元测试框架的类似类型。

适当的异步单元测试是异步过程的重要部分,我很高兴能看到这些框架和库采用异步。几年前,我的首次闪电秀中的一个主题就是异步单元测试,当时异步还只是社区技术预览,相比之下,现在就容易多了!


Stephen Cleary 生活在密歇根州北部,他是一位丈夫、父亲和程序员。他 16 年来一直从事多线程处理和异步编程的工作,自从第一个社区技术预览版出现以来,他就一直在 Microsoft .NET Framework 中使用异步支持。他是《C# 并发编程经典实例》(O’Reilly Media, 2014) 一书的作者。他的主页(包括博客)位于 stephencleary.com

衷心感谢以下 Microsoft 技术专家对本文的审阅:James McCaffrey