本文章是由機器翻譯。

非同步程式設計

單元測試非同步程式碼

Stephen Cleary

下載代碼示例

單元測試是現代發展的基石。單元測試專案的好處是很好的理解:單元測試減少數量的 bug,請將時間減少到市場並不鼓勵過度耦合的設計。那些都很好的好處,但有進一步的優點更直接相關的開發人員。當我寫單元測試時,我可以在代碼中有更大的信心。它是容易添加功能或在測試過的代碼,修復 bug,因為單元測試法作為一個安全網,而代碼的變化。

為非同步代碼編寫單元測試帶來了幾個獨特的挑戰。此外,目前狀態的非同步支援在單元測試中和嘲弄框架變化並且仍在不斷發展。這篇文章會考慮 MSTest、 NUnit 和 xUnit,但一般原則適用于任何單元測試框架。大部分的這篇文章中的示例將使用 MSTest 語法,但我會指出沿途的行為中的任何差異。代碼下載內容包含所有三個框架的例子。

在進入細節之前,我將簡要地回顧的概念模型以及如何非同步等待關鍵字的工作。

非同步等待簡而言之

Async 關鍵字做兩件事情:它允許在該方法中,await 關鍵字,它便變成一個狀態機 (類似于 yield 關鍵字將反覆運算器塊轉換成狀態機) 的方法。非同步方法應返回任務或任務 < T > 在可能的情況。它是允許非同步方法返回 void,但它不建議因為它是很難消耗 (或測試) 非同步 void 方法。

從非同步方法返回的任務實例是由狀態機來管理的。狀態機將創建任務實例,若要返回,並將晚些時候完成這項任務。

非同步方法開始執行同步。它是只有當非同步方法達到方法可能成為非同步等待操作員。等待運算子採用一個單一的參數,如一個任務實例"等待"。首先,等待操作員將檢查等待,看看是否它已經完成 ; 如果有,該方法繼續 (同步)。如果等待尚未完成,等待操作員將"暫停"的方法,並恢復操作完成時。第二件事,等待運算子執行的操作是檢索任何結果從等待、 提高例外,如果等待完成,但有錯誤。

任務或任務 < T > 經由非同步方法在概念上表示該方法的執行。在方法完成時,就會完成任務。如果該方法返回一個值,而是任務完成具有此值作為其結果。如果該方法引發異常 (和不抓住它),然後在任務完成與該異常。

有兩個直接的教訓吸取這個簡要的概述。首先,當測試結果的非同步方法,重要的一點是它返回的任務。非同步方法使用其任務完成,結果和例外報告。第二節課是等待操作員具有特殊行為,當其操作已完成。我會稍後討論時考慮非同步存根 (stub)。

不正確地傳遞單元測試

在自由市場經濟中損失是一樣重要的利潤 ; 它迫使他們交出什麼人的公司的失敗將購買行為,鼓勵資源優化配置系統內的作為一個整體。同樣,單元測試的失敗是他們的成功一樣重要的。你必須確保單元測試將失敗時它應該,或它的成功並不意味著什麼。

據說失敗的單元測試 (錯誤地) 將接替它測試錯誤的事情時。這就是為什麼測試驅動開發 (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.");
  }
}

這說明了從等待非同步/概念模型的第一課:若要測試非同步方法的行為,你必須遵守這項任務,它將返回。最好的辦法做到這一點是等待從測試的方法返回的任務。此示例還演示了效益紅/綠/重構測試開發週期 ; 你必須保證測試代碼失敗時,這些測試將失敗。

大多數現代的單元測試框架支援任務返回非同步單元測試。IncorrectlyPassingTest 方法將導致編譯器警告的 CS4014,使用哪些建議等待消費從 SimpleAsync 返回的任務。當單元測試方法改變,等待任務時,最自然的方法是更改的測試方法是非同步任務的方法。這樣可確保測試方法 (正確的) 將會失敗:

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

避免非同步 Void 單元測試

非同步有經驗的使用者知道的避免非同步 void。我描述非同步 void 在 2013 年 3 月文章中,"最佳做法在非同步程式設計"的問題 (bit.ly/1ulDCiI)。非同步 void 的單元測試方法不能提供一個簡單的方法,對其單元測試框架,以檢索測試的結果。儘管這很困難,一些單元測試框架做通過提供他們自己的 SynchronizationCoNtext,其單元測試可以在其中執行支援非同步 void 的單元測試。

提供 SynchronizationCoNtext 是有些爭議的因為它不會更改測試的運行的環境。尤其是,當非同步方法在等待著一項任務,在預設情況下它將會恢復當前的 SynchronizationCoNtext,非同步方法。所以 SynchronizationCoNtext 的存在與否會間接改變被測系統的行為。如果你好奇的 SynchronizationCoNtext 的詳細資訊,請參閱我 MSDN 雜誌文章關於在 bit.ly/1hIar1p

MSTest 不提供 SynchronizationCoNtext。事實上,當MSBuild發現使用非同步 void 單元測試專案中的測試,它將檢測到此,問題警告 UTA007,通知使用者在單元測試方法應該返回任務,而不是虛空。MSBuild將無法運行非同步 void 的單元測試。

NUnit 不支援非同步 void 的單元測試,2.6.2 版本。 NUnit,版本 2.9.6 的下一個重大更新支援非同步 void 的單元測試,但開發商已經決定要在版本 2.9.7 中刪除的支援。 NUnit SynchronizationCoNtext 僅提供非同步 void 的單元測試。

在撰寫本文時,xUnit 打算添加版本 2.0.0 非同步 void 的單元測試的支援。 與 NUnit,不同 xUnit 提供 SynchronizationCoNtext 為它的所有測試方法,都甚至是同步的。然而,隨著 MSTest 不支援非同步 void 的單元測試,和 NUnit 扭轉其先前的決定和刪除的支援,我不會感到驚訝,如果 xUnit 也選擇放棄非同步 void 單元測試支援,在第 2 版發佈之前。

底線是非同步 void 的單元測試是複雜的框架,以支援,需要更改在測試執行環境中,並沒有的造福結束非同步任務的單元測試。此外,支援非同步 void 的單元測試框架和甚至框架版本各不相同。基於這些原因,最好是避免非同步 void 的單元測試。

非同步任務單元測試

返回任務的非同步單元測試有沒有返回 void 的非同步單元測試的問題。返回任務的非同步單元測試享受幾乎所有的單元測試框架的廣泛支援。MSTestVisual Studio2012,NUnit 2.6.2 和 2.9.6,版本中添加了支援和 xUnit 在 1.9 版中。所以,只要您的單元測試框架是小於 3 歲,非同步任務單元測試應該只是工作。

不幸的是,過時的單元測試框架不理解非同步任務單元測試。在撰寫本文時,是不會支援他們的一個主要平臺:Xamarin。Xamarin 使用自訂的較舊版本的 NUnitLite,和它當前不支援非同步任務單元測試。我預期會在不久的將來添加支援。在此期間,我用一種變通方法,效率低下,但工作:在不同的執行緒池執行緒上執行非同步測試邏輯和實際測試完成之前阻止然後 (同步) 的單元測試方法。解決方法代碼使用 GetAwaiter()。GetResult() 而不是等待因為等待會包裡面的 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­屬性。第一個問題是它才可以指望一個單元測試失敗作為一個整體 ; 有是測試的沒有辦法來表明只有特定部分預計會失敗。這不是一個問題非常簡單的測試,但測試變長時,可以有誤導的結果。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(); });
}

但非同步代碼呢?非同步任務單元測試工作完全正常地 MSTest 和 NUnit ExpectedExceptionAttribute (在所有,xUnit 不支援 ExpectedExceptionAttribute)。然而,對非同步準備 ThrowsException 的支援是不均勻的。MSTest 支援非同步 ThrowsException,但只對 Windows 存儲單元測試專案。 xUnit 介紹了非同步 ThrowsAsync xUnit 2.0.0 預發佈版本中。

NUnit 是更複雜的。在撰寫本文時,NUnit 支援非同步代碼它驗證方法 (如 Assert.Throws。 然而,為了得到這個工作,NUnit 提供 SynchronizationCoNtext,介紹了非同步 void 單元測試相同的問題。此外,語法是目前脆,如在圖 2 顯示。NUnit 已經打算放棄支援非同步 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/投擲的支援不是很好的。在我自己的單元測試代碼,我用一種非常類似于在 AssertEx 圖 3。這種類型是相當簡單,它只是拋出光禿的異常物件,而不是做的斷言,但這相同的代碼在所有主要的單元測試框架工作。

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

這允許非同步任務單元測試使用更現代的 ThrowsAsync 而不是 ExpectedExceptionAttribute,像這樣:

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

非同步存根 (stub) 和類比

在我看來,只有最簡單的代碼,可以測試沒有某種形式的存根 (stub)、 類比、 假或其他此類設備。在這篇介紹性的文章,我只是將所有這些測試的助理稱為類比。使用類比,時,有助於程式的介面,而不是實現。非同步方法很好處理介面 ; 中的代碼圖 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 演示如何測試的三個主要的存根 (stub) 的案件:非同步成功、 失敗非同步和同步成功。非同步成功和失敗都是測試非同步代碼中,主要的兩個方案,但它也是重要的是測試這種同步的情況。這是因為等待運算子的行為如果其操作已完成。中的代碼圖 5 使用最小起訂量的嘲弄框架來生成的存根 (stub) 實現。

圖 5 為非同步代碼的的存根 (stub) 實現

[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 存根 (stub)) 將預設為引發異常 ; 其他人 (如最小起訂量) 將返回預設值。當非同步方法返回任務 < T > 時,天真預設行為將返回預設 (任務 < T >),換句話說,空的任務,這將導致舉出。

這種行為是不可取的。非同步方法更合理的預設行為將返回 Task.FromResult­(default (t)) — — 那就是,任務完成,預設值為 t。 這使下測試使用返回的任務的系統。最小起訂量最小起訂量 4.2 版在實施這種風格的非同步方法的預設行為。據我所知,在撰寫本文時,它是唯一的嘲弄庫那樣使用非同步友好的預設值。

總結

非同步等待著有推出以來,各地的Visual Studio2012 年,足夠長的時間出現一些最佳做法。單元測試框架和助手元件 (如嘲笑圖書館正在融合對非同步友好的一貫支援。非同步單元測試今天已經是一個現實,和它在未來會更好。如果你沒做過所以最近,現在是很好的時間來更新您的單元測試框架和嘲弄庫,可確保你有最好的非同步支援。

單元測試框架正在融合停止非同步 void 的單元測試,並非同步任務單元測試。如果你有任何非同步 void 的單元測試,我建議你將它們今天更改為非同步任務單元測試。

我期望在未來的幾年你會看到更好的支援,用於在非同步單元測試中測試失敗的案例。直到你的單元測試框架具有很好的支援,我建議你使用在這篇文章中提到的 AssertEx 類型或類似的東西,更符合您特定的單元測試框架。

適當非同步單元測試是非同步故事的重要組成部分,我很高興看到這些框架和庫採用非同步。我第一次的閃電會談之一就是關於非同步單元測試幾年前,當非同步仍然在社區技術預覽,並且它是很容易做這些天 !


Stephen Cleary 是丈夫、 父親和住在北密歇根的程式師。他曾與多執行緒和非同步程式設計的 16 年裡和自在 Microsoft.NET 框架以來第一次社區技術預覽使用非同步支援。他是"併發在 C# 食譜"(O'Reilly 媒體,2014年) 的作者。他的主頁,包括他的博客,是在 stephencleary.com

感謝以下的微軟技術專家對本文的審閱:James McCaffrey