异步编程

单元测试异步代码:改善测试的三种解决方案

Sven Grand

下载代码示例

最近十年,异步编程变得越来越重要。无论是基于 CPU 的并行还是基于 IO 的并发,开发人员都可以采用异步帮助最大限度地利用可用资源,最终实现事半功倍。响应更及时的客户端应用程序和更具扩展性的服务器应用程序触手可及。

软件开发人员已经学会了许多高效构建同步功能的设计模式,但设计异步软件的最佳做法还相对较新,尽管随着 Microsoft .NET Framework 4 和 4.5 的发布,由编程语言和库所提供的对并行编程与并发编程的支持得到了显著提高。尽管针对使用新技术已经有了不少好的建议(请参见《异步编程的最佳做法》(bit.ly/1ulDCiI),以及《讨论:异步最佳做法》(bit.ly/1DsFuMi)),但许多开发人员仍不了解使用语言功能(如 async 和 await 以及任务并行库 (TPL))为应用程序和库设计内部和外部 API 的最佳做法。

这种差距不仅影响了这些开发人员构建的应用程序和库的性能和可靠性,还影响其解决方案的可测试性,因为许多可用于创建可靠异步设计的最佳做法更便于实现单元测试。

考虑到这种最佳做法,本文将介绍几种设计和重构代码以提高可测试性的方法,并演示这将如何影响测试。这些解决方案适用于利用 async 和 await 的代码,以及在较早框架和库中基于较低级别多线程机制的代码。并且在此过程中,这些解决方案不仅可以更好地分解以进行测试,还可以使已开发代码的用户更轻松、更高效使用这些解决方案。

与我合作的团队正在为医疗 X 射线设备开发软件。在此领域,我们的单元测试覆盖范围始终处于较高级别,这一点至关重要。最近,一名开发人员问我:“您老是催我们为所有代码编写单元测试。但在代码启动另一个线程或正在使用稍后会启动线程并运行数次的计时器时,我怎样才能编写合理的单元测试?”

这是一个有意义的问题。假设我要测试以下代码:

public void StartAsynchronousOperation()
{
  Message = "Init";
  Task.Run(() =>
    {
      Thread.Sleep(1900);
      Message += " Work";
    });
}
public string Message { get; private set; }

我第一次尝试为该代码编写的测试并不太乐观:

[Test]
public void FragileAndSlowTest()
{
  var sut = new SystemUnderTest();
  // Operation to be tested will run in another thread.
  sut. StartAsynchronousOperation();
  // Bad idea: Hoping that the other thread finishes execution after 2 seconds.
  Thread.Sleep(2000);
  // Assert outcome of the thread.
  Assert.AreEqual("Init Work", sut.Message);
}

我希望单元测试可以快速运行并提供可预测的结果,但我编写的测试非常脆弱且运行缓慢。StartAsynchronousOperation 方法在另一个线程中启动了要测试的操作,并且该测试应检查此操作的结果。它启动新线程或初始化线程池中的现有线程以及执行此操作所花费的时间都不可预测,因为这取决于在测试计算机上运行的其他进程。如果休眠状态太短或异步操作尚未完成,测试可能随时会失败。我左右为难:我要么冒着测试太过脆弱的风险,尽可能缩短等待时间,要么增加休眠时间以确保测试更可靠,但大幅减慢测试运行速度。

要测试使用计时器的代码时遇到的问题与此类似:

private System.Threading.Timer timer;
private readonly Object lockObject = new Object();
public void StartRecurring()
{
  Message = "Init";
  // Set up timer with 1 sec delay and 1 sec interval.
  timer = new Timer(o => { lock(lockObject){ Message += " Poll";} }, null,
    new TimeSpan(0, 0, 0, 1), new TimeSpan(0, 0, 0, 1));
}
public string Message { get; private set; }

并且此测试很可能会遇到相同的问题:

[Test]
public void FragileAndSlowTestWithTimer()
{
  var sut = new SystemUnderTest();
  // Execute code to set up timer with 1 sec delay and interval.
  sut.StartRecurring();
  // Bad idea: Wait for timer to trigger three times.
  Thread.Sleep(3100);
  // Assert outcome.
  Assert.AreEqual("Init Poll Poll Poll", sut.Message);
}

在我使用多个不同的代码分支来测试相当复杂的操作时,最终得到了大量的单独测试。随着每个新测试的产生,我的测试套件变得越来越慢。这些测试套件的维护成本有所增加,因为我必须花时间调查突发的失败。此外,缓慢的测试套件倾向于偶尔执行,因此优势更少。有些时候,我可能会完全停止执行这些缓慢且间歇性失败的测试。

在操作引发异常时,前面介绍的两种测试方式也无法进行检测。由于此操作在不同的线程上运行,因此异常无法传播到测试运行程序线程。这限制了测试检查待测试代码的正确的错误行为的能力。

我将介绍三种通用的解决方案,以便通过改进已测试代码的设计来避免缓慢、脆弱的单元测试,我还将展示这些解决方案如何使单元测试可以检查异常。每种解决方案都有优点以及缺点或限制。最后,我将针对不同情况应选择哪种解决方案提出一些建议。

本文讨论的是单元测试。通常,单元测试在与系统其他部分隔离的情况下测试代码。系统其他部分之一是 OS 多线程功能。标准库类和方法用于安排异步工作,但多线程方面应排除在单元测试之外,多线程应专注于异步运行的功能。

在代码包含运行于某个线程的功能块时,异步代码的单元测试才有意义,且单元测试应确认这些功能块是否按预期工作。在单元测试已经表明该功能正确时,使用其他测试策略来发现并发问题是很重要的。有几种方法可用于测试并分析多线程代码以便发现这些问题(请参见,例如,“确认并发问题的工具和技术”(bit.ly/1tVjpll))。例如,压力测试可将整个系统或系统的大部分置于负载状态。用这些策略来补充单元测试是明智的,但超出了本文的讨论范围。本文的解决方案将说明如何在使用单元测试隔离测试功能时,排除多线程部分。

解决方案 1:将功能与多线程分离

对异步操作中涉及的功能进行单元测试的最简单的解决方案是将功能与多线程分离。Gerard Mezaros 在他的《xUnit 测试模式》(Addison-Wesley, 2007) 一书的谦卑对象模式中说明了这种方法。将要测试的功能提取到独立的新类,而多线程部分仍保留在调用新类的谦卑对象中(请参见图 1)。

谦卑对象模式
图 1 谦卑对象模式

以下代码显示重构后提取出来的功能,为纯同步代码:

public class Functionality
{
  public void Init()
  {
    Message = "Init";
  }
  public void Do()
  {
    Message += " Work";
  }
  public string Message { get; private set; }
}

在重构前,此功能与异步代码混合,但我已将其移到了 Functionality 类。现在此类可以通过简单的单元测试进行测试,因为它不再包含任何多线程。请注意,一般来说这种重构很重要,不仅对单元测试而言如此:组件不应为本质上的同步操作提供异步包装,而应让调用者确定是否卸载此操作的调用。在单元测试时,我选择不卸载,但消费应用程序可能会因响应能力或并行执行而决定卸载。有关详细信息,请参阅 Stephen Toub 的博文《是否应该为同步方法提供异步包装?》(bit.ly/1shQPfn)。

在这种模式的轻量型变体中,可以将 SystemUnderTest 类的某些私有方法变为公开以允许测试直接调用这些方法。在这种情况下,不需要为测试不带多线程的功能创建其他类。

通过谦卑对象模式分离功能很简单,不仅可以为立即安排异步工作的代码执行一次此操作,还可以为使用计时器的代码执行此操作。在这种情况下,将在谦卑对象中处理计时器,并将重复操作移到 Functionality 类或公共方法。此解决方案的一个优点是测试可以直接检查由待测试代码引发的异常。不管用于安排异步工作的是什么技术,都可应用谦卑对象模式。此解决方案的缺点是不会测试谦卑对象本身的代码,以及必须修改待测试的代码。

解决方案 2:同步测试

如果测试能检测异步运行的待测试操作是否完成,则可以避免两个缺点:脆弱和缓慢。尽管测试运行多线程代码,但在测试与待测试代码安排的操作进行同步时,测试能够可靠快速地运行。在异步执行的负面影响最小化时,测试可以专注于功能。

理想情况下,待测试的方法将返回某个类型的实例,此实例在操作完成时将得到信号通知。自 .NET Framework 版本 4 开始提供的 Task 类型可以很好地满足这一需求,且自 .NET Framework 4.5 开始提供的 async/await 功能使得组合 Task 的操作很容易:

public async Task DoWorkAsync()
{
  Message = "Init";
  await Task.Run( async() =>
  {
    await Task.Delay(1900);
    Message += " Work";
  });
}
public string Message { get; private set; }

此重构表示有助于单元测试案例以及对提供的异步功能的常规使用的一般最佳做法。通过返回代表异步操作的 Task,代码的使用者能够轻松地确定异步操作何时完成,是否出现异常而失败,以及是否返回结果。

这使得对异步方法进行单元测试与对同步方法进行单元测试同样简单。现在只需通过调用目标方法并等待返回的 Task 完成,测试即可轻松地与待测试的代码进行同步。在查看异步操作的结果之前,通过 Task Wait 方法可以同步实现等待(阻止调用线程),或使用 await 关键字可以异步实现等待(使用继续符来避免阻止调用线程)(请参见图 2)。

通过 Async 和 Await 同步
图 2 通过 Async 和 Await 同步

为了在单元测试方法中使用 await,测试本身必须在其签名中使用异步进行声明。不再需要 Sleep 语句:

[Test]
public async Task SynchronizeTestWithCodeViaAwait()
{
  var sut = new SystemUnderTest();
  // Schedule operation to run asynchronously and wait until it is finished.
  await sut.StartAsync();
  // Assert outcome of the operation.
  Assert.AreEqual("Init Work", sut.Message);
}

幸运的是,主要单元测试框架 MSTest、xUnit.net 和 NUnit 的最新版本均支持 async 和 await 测试(请参见 Stephen Cleary 的博客 bit.ly/1x18mta)。它们的测试运行程序在开始评估断言语句之前,可以处理 async Task 测试并等待线程完成。如果单元测试框架的测试运行程序无法处理 async Task 测试方法签名,此测试至少可以调用从待测试的系统返回的 Task 上的 Wait 方法。

此外,借助于 TaskCompletionSource 类,可以增强基于计时器的功能(在代码下载中查看详细信息)。然后,此测试可以等待特定重复操作完成:

[Test]
public async Task SynchronizeTestWithRecurringOperationViaAwait()
{
  var sut = new SystemUnderTest();
  // Execute code to set up timer with 1 sec delay and interval.
  var firstNotification = sut.StartRecurring();
  // Wait that operation has finished two times.
  var secondNotification = await firstNotification.GetNext();
  await secondNotification.GetNext();
  // Assert outcome.
  Assert.AreEqual("Init Poll Poll", sut.Message);
}

遗憾的是,待测试的代码有时无法使用 async 和 await,例如在您测试已发布的代码时和因重大更改原因无法更改测试的方法签名时。在此类情况下,必须使用其他技术实现同步。在完成操作时,如果待测试的类调用事件或调用依赖对象,则可以实现同步。以下示例说明了在调用依赖对象时,如何实现测试:

private readonly ISomeInterface dependent;
public void StartAsynchronousOperation()
{
  Task.Run(()=>
  {
    Message += " Work";
    // The asynchronous operation is finished.
    dependent.DoMore()
  });
}

代码下载中另外提供了基于事件的同步的示例。

现在,如果测试时依赖对象被替换为存根,测试可与异步操作同步(请参见图 3)。

通过依赖对象存根进行同步
图 3 通过依赖对象存根进行同步

测试必须使存根熟悉线程安全通知机制,因为存根代码在其他线程中运行。在以下测试代码示例中,使用了 ManualResetEventSlim,且通过 RhinoMocks 模拟框架生成了存根:

// Setup
var finishedEvent = new ManualResetEventSlim();
var dependentStub = MockRepository.GenerateStub<ISomeInterface>();
dependentStub.Stub(x => x.DoMore()).
  WhenCalled(x => finishedEvent.Set());
var sut = new SystemUnderTest(dependentStub);

现在此测试可以执行异步操作并等待通知:

// Schedule operation to run asynchronously.
sut.StartAsynchronousOperation();
// Wait for operation to be finished.
finishedEvent.Wait();
// Assert outcome of operation.
Assert.AreEqual("Init Work", sut.Message);

将测试与已测试线程同步的解决方案可应用于具有某些特征的代码:待测试的代码具有通知机制(类似于 async 和 await 或纯事件),或代码调用依赖对象。

async 和 await 同步的一大优势是能够将任何类型的结果传播回调用客户端。一种特殊的结果是异常。因此测试可以显式处理异常。其他同步机制只能通过有缺陷的结果间接识别失败。

带有基于计时器功能的代码可利用 async/await、事件或对依赖对象的调用来允许测试与计时器操作进行同步。每当重复操作完成,测试都会得到通知并可以查看结果(请参见代码下载中的示例)。

遗憾的是,即使您使用通知,计时器也会使单元测试运行缓慢。您要测试的重复操作通常只在某些延迟之后启动。测试的运行速度会下降,且至少需要花费延迟的时间。这是基于通知先决条件的另一个缺点。

现在,我将介绍一个可避免前两个解决方案的部分限制的解决方案。

解决方案 3:在一个线程中进行测试

对于此解决方案,应按以下方式准备待测试的代码:测试稍后可以直接在其所在的线程上触发操作的执行。这是对 jMock 团队的 Java 方法的转换(请参见“测试多线程代码”(jmock.org/threads.html))。

以下示例中的待测试系统使用插入的任务计划程序对象来计划异步工作。为了演示第三种解决方案的功能,我添加了第二个操作,它会在第一个操作完成时启动:

private readonly TaskScheduler taskScheduler;
public void StartAsynchronousOperation()
{
  Message = "Init";
  Task task1 = Task.Factory.StartNew(()=>{Message += " Work1";},
                                     CancellationToken.None,
                                     TaskCreationOptions.None,
                                     taskScheduler);
  task1.ContinueWith(((t)=>{Message += " Work2";}, taskScheduler);
}

将待测试的系统修改为使用独立的 TaskScheduler。在测试过程中,“普通的”TaskScheduler 将替换为 DeterministicTaskScheduler,后者允许同步启动异步操作(请参见图 4)。

SystemUnderTest 中使用独立的 TaskScheduler
图 4 在 SystemUnderTest 中使用独立的 TaskScheduler

以下测试可以在测试所在的线程中执行计划的操作。测试将 Deterministic­TaskScheduler 插入到待测试的代码中。DeterministicTaskScheduler 不会立即衍生新的线程,只会在计划的任务中排队。在下一个语句中,RunTasksUntil­Idle 方法将同步执行两个操作:

[Test]
public void TestCodeSynchronously()
{
  var dts = new DeterministicTaskScheduler();
  var sut = new SystemUnderTest(dts);
  // Execute code to schedule first operation and return immediately.
  sut.StartAsynchronousOperation();
  // Execute all operations on the current thread.
  dts.RunTasksUntilIdle();
  // Assert outcome of the two operations.
  Assert.AreEqual("Init Work1 Work2", sut.Message);
}

DeterministicTaskScheduler 替代 TaskScheduler 方法提供计划功能,并专门向其中添加 RunTasksUntilIdle 方法以进行测试(有关 DeterministicTaskScheduler 实现的详细信息,请参见代码下载)。正如在同步单元测试中一样,存根可用于一次专门针对单个功能单元。

使用计时器的代码经常出现问题,不仅仅是因为测试脆弱且缓慢。当代码使用的计时器不在工作线程上运行时,单元测试会变得更加复杂。在 .NET framework 类库中,有专门设计用于 UI 应用程序的计时器,如适用于 Windows Forms 的 System.Windows.Forms.Timer 或适用于 Windows Presentation Foundation (WPF) 应用程序的 System.Windows.Threading.DispatcherTimer (请参阅“比较 .NET Framework 类库中的计时器类”(bit.ly/1r0SVic))。这些计时器使用 UI 消息队列,而在单元测试过程中该消息队列并不直接可用。本文开头所示的测试不适用于这些计时器。此测试必须启动消息泵,例如通过使用 WPF DispatcherFrame(请参见代码下载中的示例)。在部署基于 UI 的计时器时,为了保持单元测试简洁明了,您必须在测试过程中替换这些计时器。我将介绍适用于计时器的接口,以便将“实际”计时器替换为专门用于测试的实现。我这样做也是为了基于“线程”的计时器,如 System.Timers.Timer 或 System.Threading.Timer,因为这样一来,我就可以改进所有案例的单元测试。必须将待测试的系统修改为使用此 ITimer 接口:

private readonly ITimer timer;
private readonly Object lockObject = new Object();
public void StartRecurring()
{
  Message = "Init";
  // Set up timer with 1 sec delay and 1 sec interval.
  timer.StartTimer(() => { lock(lockObject){Message += 
    " Poll";} }, new TimeSpan(0,0,0,1));
}

通过引入 ITimer 接口,我可以在测试期间替换计时器行为,如图 5 所示。

SystemUnderTest 中使用 ITimer
图 5 在 SystemUnderTest 中使用 ITimer

定义 ITimer 接口所付出的额外努力会得到回报,因为查看初始化和重复操作结果的单元测试现在可以在几毫秒内快速可靠地运行:

[Test]
public void VeryFastAndReliableTestWithTimer()
{
  var dt = new DeterministicTimer();
  var sut = new SystemUnderTest(dt);
  // Execute code that sets up a timer 1 sec delay and 1 sec interval.
  sut.StartRecurring();
  // Tell timer that some time has elapsed.
  dt.ElapseSeconds(3);
  // Assert that outcome of three executions of the recurring operation is OK.
  Assert.AreEqual("Init Poll Poll Poll", sut.Message);
}

DeterministicTimer 是专门为测试目的编写的。它允许测试控制执行计时器操作的时间点,而无需等待。此操作将在测试所在的线程中执行(有关 DeterministicTimer 实现的详细信息,请参见代码下载)。为了在“非测试”上下文中执行已测试的代码,我必须为现有计时器实现 ITimer 适配器。代码下载包含适用于数个框架类库计时器的适配器示例。可以根据具体情况的需要定制 ITimer 接口,且该接口可以只包含特定计时器全部功能的子集。

通过使用 DeterministicTaskScheduler 或 DeterministicTimer 测试异步代码,您可以在测试期间轻松关闭多线程。此功能将在测试所在的线程上执行。将保留初始化代码和异步代码之间的互操作性,且可以对其进行测试。例如,此类测试可以检查用于初始化计时器的正确时间值。异常将被转发给测试,以便测试直接检查代码的错误行为。

总结

异步代码的有效单元测试具有三个主要优点:降低了测试的维护成本;加快了测试的运行速度;最大程度地减少了不再执行测试的风险。本文提出的解决方案可帮助您实现这一目标。

第一个解决方案,通过谦卑对象将功能从程序的异步方面分离是最通用的方案。此方案适用于所有情况,无论线程如何启动。对于非常复杂的异步方案、复杂功能或这两者的组合,我建议使用这种解决方案。这是关注点分离设计原理的典型示例(请参见 bit.ly/1lB8iHD)。

第二个解决方案,将测试与已测试线程的完成同步,在待测试代码提供诸如 async 和 await 的同步机制时适用。在通知机制先决条件以任何方式完成时,这种解决方案才具有意义。如果可能,请在启动非计时器线程时使用完善的 async 和 await 同步,因为异常会被传播到测试。带有计时器的测试可以使用 await、事件或对依赖对象进行调用。在计时器有较长时间的延迟或间隔时,这些测试可能会运行缓慢。

第三种解决方案使用 DeterministicTaskScheduler 和 DeterministicTimer,因此避免了其他解决方案的大多数限制和缺点。它需要为待测试代码做一些准备工作,但可以实现较高的单元测试代码覆盖率。可以非常快速地执行带有计时器的代码测试,而无需等待延迟和间隔时间。此外,异常会被传播到测试。因此,该解决方案将产生可靠、快速和完善的单元测试套件以及高代码覆盖率。

这三种解决方案可以帮助软件开发人员避免单元测试异步代码的缺陷。可用于创建快速可靠的单元测试套件,并覆盖各种并行编程技术。


Sven Grand是 Philips Healthcare 诊断 X 射线业务部门的质量工程软件架构师。数年前自从他在 2001 年 Microsoft 软件会议上第一次听说测试驱动的开发之后,他就迷上了测试。您可以通过 sven.grand@philips.com 与他取得联系。

衷心感谢以下技术专家对本文的审阅:Stephen Cleary、James McCaffrey、Henning Pohl 和 Stephen Toub