本文章是由機器翻譯。

非同步程式設計

單元測試的非同步代碼:更好地測試的三種解決方案

Sven Grand

下載代碼示例

在過去十年期間,非同步程式設計變得越來越重要。是否對於基於 CPU 的並行或 IO 基於併發,開發商正在採用非同步來説明提供最重要的資源,並最終,做更多與少。回應更快的用戶端應用程式和可擴充性更強的伺服器應用程式是所有內部實現的。

軟體發展人員已經學會了很多設計模式有效地建設同步功能,但非同步軟體設計的最佳做法是相對較新,雖然程式設計語言提供的支援和並行和併發程式設計庫已大幅度提高了 Microsoft.NET Framework 4 和 4.5 的釋放。雖然已有不少好的建議,使用新技術 (看"最佳做法在非同步程式設計" bit.ly/1ulDCiI 和"談話:非同步最佳做法", bit.ly/1DsFuMi)、 喜歡非同步和等待最佳做法設計的內部和外部的 Api 為應用程式和庫與語言功能和任務並行庫 (TPL) 是許多開發人員仍然未知。

這種差距影響不只是性能和可靠性,應用程式和庫這樣的開發商正在建設,但他們的解決方案,盡可能多的最佳實踐,説明建立穩健的非同步設計的可測性也使單元測試,以及更加容易。

銘記這種最佳做法,這篇文章將展示更好的可測試性設計和重構代碼的方法,並證明這會怎樣影響我們的測試。解決方案適用于利用非同步代碼,並且等待,以及較低級別從先前的框架和庫的多執行緒機制的基礎的代碼。而且,在過程中,解決方案,將不只會更好地汲取測試,他們會更容易和更有效地消耗由使用者開發的代碼。

我和一起工作的團隊所開發醫用 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);
}

當我考試相當複雜的操作,與幾個不同的代碼分支時,我最終,數量龐大的單獨測試。我測試套件得到越來越慢與每個新的測試。維修費用為這些測試套房增加,由於要花費時間來調查的零星的失敗。此外,緩慢的測試套件往往只有很少執行,因此有更少的好處。在某種程度上,我可能會停止執行這些慢和間歇性一共未能通過測試。

上文所示的測試的兩種也是無法檢測時的操作引發異常。因為在不同的執行緒中運行的操作,異常不會傳播到測試回合程式執行緒。這限制了測試檢查正確的錯誤行為的接受測試的代碼的能力。

我要提出三個一般的解決方案,通過改進的設計進行測試的代碼,避免緩慢、 脆弱的單元測試的看看如何,這使得單元測試,以檢查異常。每個解決方案有優勢,以及缺點或局限性。最後,我會給一些關於哪種解決方案為不同情況下選擇的建議。

這篇文章是關於單元測試。通常情況下,單元測試測試中從系統的其他部分隔離的代碼。系統的這些其他部分之一是作業系統的多執行緒處理功能。標準庫類和方法用於調度非同步工作,但多執行緒處理方面應排除在單元測試中,應集中于以非同步方式運行的功能。

非同步代碼的單元測試有意義,當代碼包含在一個執行緒中運行的功能塊和單元測試應驗證資料塊都按預期方式工作。單元測試已證明是正確的功能,它使意義上使用額外的測試策略來發現併發問題。有幾種方法進行測試和分析,發現這類問題的多執行緒的代碼 (看,例如,"工具和技術對確定併發問題," bit.ly/1tVjpll)。壓力測試,例如,可以把整個的系統或負荷下系統很大一部分。這些戰略是明智的補充單元測試,但超出了本文的範圍。這篇文章中的解決方案將顯示如何使用單元測試在功能測試中分離時排除的多執行緒處理的部分。

解決方案 1:分離功能從多執行緒處理

最簡單的解決方案,用於單元測試的非同步作業中所涉及的功能是將該功能從多執行緒處理。Gerard Mezaros 描述這種方法在謙卑的物件模式在他的書,"xUnit 測試模式"(艾迪生-Wesley,2007年)。要測試的功能提取到一個單獨的新類和多執行緒的一部分停留在謙卑的物件,調用新的類中 (請參閱圖 1)。

 的謙卑的物件模式
圖 1 的謙卑的物件模式

下面的代碼演示後重構,提取的功能,它是純粹同步代碼:

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

之前的重構,功能相結合非同步代碼,但感動它到功能類。此類現在可以通過簡單的單元測試測試,因為它不包含任何多執行緒了。請注意這種重構是重要在一般情況下,不只是用於單元測試:元件不應該暴露本質上同步操作的非同步包裝,並應該相反把它留給調用方確定是否要卸載該操作的調用。在單元測試中,我選擇不去,但消費應用程式可能決定這樣做,理由的回應能力或並存執行。更多的資訊,請參閱StephenToub 的博客,"我是否應公開同步方法的非同步包裝?"(bit.ly/1shQPfn)。

在這種模式的一個羽量級的變異,SystemUnderTest 類某些私有方法可公開允許測試來直接調用這些方法。在這種情況下,沒有額外的類需要為測試功能而不必創建多執行緒處理。

分離通過卑微的物件模式的功能很簡單,不僅是一次,立即調度非同步工作的代碼,使用計時器的代碼可以完成。在這種情況下處理計時器保持謙卑的物件而重複執行的操作被移動到功能類或一個公共方法。此解決方案的優點是測試可以直接檢查由測試代碼引發的異常。卑微的物件模式可以不論用於調度非同步工作的技術應用。此解決方案的缺點是卑微的物件本身中的代碼沒有經過測試和測試代碼已被修改。

解決方案 2:同步測試

如果測試能夠檢測到下測試,以非同步方式運行,操作的完成它可以避免的兩個缺點,脆弱和緩慢。儘管在測試回合多執行緒的代碼,它可以可靠、 快速測試同步與操作計畫由被測試的代碼時。測試集中在功能上,而非同步執行的負面影響降至最低。

在最好的情況下,測試的方法返回將在操作完成時發出信號的類型的實例。任務類型,自問世以來在.NET Framework 版本 4,很好,滿足這一需要並等待非同步/功能可用以來.NET 框架 4.5 輕鬆撰寫任務:

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

這種重構表示一般的最佳實踐,説明在這兩個單元測試用例和一般消費公開非同步功能。通過返回一個表示非同步作業的任務,一個消費者是代碼的能夠很容易地確定當非同步作業已完成,是否它失敗,出現異常,和它是否返回一個結果。

這使得單元測試單元測試一種同步方法一樣簡單的非同步方法。現在很容易就測試與被測試的代碼只需通過調用目標方法和等待返回任務完成同步。這等待可以同步進行 (阻塞調用執行緒) 通過任務等方法,或它可以以非同步方式完成 (使用延續來避免阻塞調用執行緒) 使用 await 關鍵字之前檢查非同步作業的結果 (見圖 2)。

同步通過非同步和等待
圖 2 同步通過非同步和等待

為了在使用等待在單元測試方法,測試本身不得不用非同步在其簽名中聲明。沒有睡眠的語句是不再需要的:

[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 — — 支援非同步和等待測試 (請參閱Stephen Cleary的博客在 bit.ly/1x18mta)。他們測試跑步者可以應付非同步任務測試和等待的執行緒完成,在他們開始評估 assert 語句之前。如果運行測試的單元測試框架不能應付與非同步任務測試方法簽名,測試至少可以調用的等待在任務上的方法返回從被測試的系統。

此外,基於計時器的功能可以增強 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);
}

不幸的是,有時接受測試的代碼不能使用非同步和等待時,例如當您要測試代碼,已經售出和正在測試的方法的簽名不能更改的重大更改原因。在這種情況下,在同步已與其他技術實施。如果所測試的類呼叫事件或調用一個依賴的物件,在操作完成時,可以實現同步。下面的示例演示如何執行測試時調用依賴的物件:

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

基於事件的同步的另一個例子是在代碼下載中。

測試現在同步與非同步作業,當依賴物件取而代之在測試時的存根 (stub) (見圖 3)。

同步通過依賴物件的存根 (stub)
圖 3 同步通過依賴物件的存根 (stub)

測試有存根 (stub) 配備一個執行緒安全的通知機制,因為存根 (stub) 代碼在另一個執行緒中運行。在下面的測試代碼示例中,使用 ManualResetEventSlim 和存根 (stub) 將生成帶有 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);

此解決方案將與測試執行緒同步測試可以應用於具有某些特性的代碼:接受測試的代碼有一個通知機制,喜歡非同步並等待或平原的事件或代碼調用一個依賴的物件。

非同步一大優勢,並等待同步是能夠傳播任何種類的結果返回給調用客戶機。一種特殊是結果的一個例外。所以測試可以顯式地處理異常。其他的同步機制只能識別有缺陷的成果是通過間接的失敗。

與基於計時器的功能的代碼可以利用非同步/等待、 事件或對依賴物件的調用,以允許測試與計時器操作進行同步。每次重複執行的操作完成後,該測試通知,並可以檢查結果 (請參閱示例代碼下載中的)。

不幸的是,計時器使單元測試慢,甚至當你使用一個通知。重複執行的操作,您想要測試通常只有特定的延遲後開始。測試速度會減慢,並且至少需要延遲的時間。這是另外的缺點,在通知的前提條件。

現在我來看看一個解決方案,它避免了一些兩個以前的限制。

解決方案 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

下面的測試可以在測試本身相同的執行緒中執行預定的操作。測試注入確定性函數­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 實現的詳細資訊)。在同步單元測試,存根 (stub) 可以用於集中在一段時間只是一個單一功能單元。

使用計時器的代碼有問題不只是因為測試是脆弱和緩慢。當代碼使用一個計時器,它不輔助執行緒上運行時,單元測試變得更加複雜。在.NET framework 類庫,有專門用來在 UI 的應用程式如 Windows 表單的 System.Windows.Forms.Timer 或 System.Windows.Threading.DispatcherTimerWindows Presentation Foundation(WPF) 應用程式中使用的計時器 (看"比較計時器類的.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

使用 ITimer 在 SystemUnderTest
圖 5 使用 ITimer 在 SystemUnderTest

定義介面 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)。

第二個解決方案,與測試執行緒完成同步測試,當被測試的代碼提供同步機制 (如非同步可應用和等待。此解決方案通知機制的先決條件無論如何履行時有意義。如果可能的話,使用優雅的非同步和等待同步,當非計時器執行緒已啟動,因為異常被傳播到測試。與計時器測試可以使用等待事件或調用到依賴物件。當計時器有長時間的延遲或間隔,這些測試可能會很慢。

第三種解決方案使用 DeterministicTaskScheduler 和 DeterministicTimer,從而避免大多數的缺點和不足的其他的解決辦法。它需要一些努力編寫的代碼進行測試,但可以達到高的單元測試的代碼覆蓋率。針對與計時器代碼的測試可以執行非常快是因為不用等待時間延遲和間隔時間。此外,異常被傳播到測試。所以這種解決方案將導致結合高代碼覆蓋率的魯棒的、 快速的、 優雅的單元測試套件。

這三個解決方案可以説明軟體發展人員避免單元測試非同步代碼的陷阱。它們可以用於創建快速和魯棒性的單元測試套件和覆蓋廣泛的並行程式設計技術。


Sven Grand 是品質工程軟體架構師飛利浦醫療診斷 x 射線事業部。他得到了"測試感染"幾年前,當他第一次聽到關於測試驅動開發 Microsoft 軟體會議于 2001 年。 聯繫到他在 sven.grand@philips.com

衷心感謝以下技術專家對本文的審閱:Stephen Cleary,JamesMcCaffrey、 亨甯 · 波爾和StephenToub