Asynchrone Programmierung

Komponententests von asynchronem Code: Drei Lösungen für bessere Tests

Sven Grand

Laden Sie die Codebeispiele herunter

Asynchrone Programmierung ist im Lauf des letzten Jahrzehnts zunehmend wichtiger geworden. Ob aus Gründen parallel arbeitender CPU-Kerne oder wegen paralleler E/A-Vorgänge, Entwickler setzen verstärkt Asynchronität ein, um die verfügbaren Ressourcen optimal zu nutzen und, letztlich, mit weniger Aufwand mehr zu erreichen. Reagiblere Clientanwendungen und besser skalierende Serveranwendungen sind so in Sichtweite.

Software-Entwickler haben viele Entwurfsmuster für das effektive Erstellen synchroner Funktionalität gelernt, bewährte Verfahren für das Entwerfen asynchroner Software sind jedoch relativ neu, obwohl sich die von den Programmiersprachen und Bibliotheken bereitgestellte Unterstützung für parallele Programmierung mit der Veröffentlichung von Microsoft .NET Framework 4 und 4.5 dramatisch verbessert hat. Zwar ist bereits eine Menge an gutem Rat für den Einsatz der neuen Techniken verfügbar (z. B. "Bewährte Verfahren bei der asynchronen Programmierung" unter bit.ly/1ulDCiI und "Talk: Async Best Practices” unter bit.ly/1DsFuMi), bewährte Verfahren für das Entwerfen der internen und externen APIs für Anwendungen und Bibliotheken mit Sprachfeatures wie "async" und "await" und der TPL (Task Parallel Library) sind vielen Entwicklern jedoch weiterhin nicht bekannt.

Diese Lücke wirkt sich nicht nur auf die Leistung und Zuverlässigkeit der Anwendungen und Bibliotheken aus, die von diesen Entwicklern erstellt werden, sondern auch auf die Testbarkeit ihrer Lösungen, da viele der bewährten Methoden, die die Erstellung von robusten asynchronen Entwürfen ermöglichen, zugleich Komponententests erleichtern.

Im Hinblick auf derartige bewährte Verfahren stellt dieser Artikel Verfahren zum Entwerfen und Refaktorieren von Code mit verbesserter Testbarkeit vor und veranschaulicht, wie sich das auf die Tests auswirkt. Die Lösungen sind auf Code anwendbar, der "async" und "await" nutzt, sowie auf Code, der auf Multithreadingmechanismen auf niederer Ebene aus früheren Frameworks und Bibliotheken basiert. Und in diesem Prozess werden die Lösungen nicht nur besser für Tests faktoriert, sie können auch leichter und effizienter von Benutzern des entwickelten Codes genutzt werden.

Das Team, mit dem ich arbeite, entwickelt Software für medizinische Röntgengeräte. Auf diesem Fachgebiet ist entscheidend, dass unsere Abdeckung durch Komponententests immer auf hohem Niveau ist. Vor Kurzem hat mich ein Entwickler gefragt: "Sie drängen uns dauernd, Komponententests für unseren gesamten Code zu erstellen. Aber wie kann ich sinnvolle Komponententests erstellen, wenn mein Code einen neuen Thread startet oder einen Timer verwendet, der später einen Thread startet und ihn mehrfach ausführt?"

Das ist in der Tat eine wichtige Frage. Angenommen, ich habe diesen Code zum Testen:

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

Mein erster Versuch, einen Test für diesen Code zu erstellen, war nicht sehr viel versprechend:

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

Ich erwarte von Komponententests, dass sie schnell ausgeführt werden und vorhersagbare Ergebnisse liefern, aber der Test, den ich geschrieben hatte, war fragil und langsam. Die Methode "StartAsynchronousOperation" löst den zu testenden Vorgang in einem anderen Thread aus, und der Test soll das Ergebnis des Vorgangs überprüfen. Die erforderliche Zeit zum Starten eines neuen Threads oder zum Initialisieren eines im Threadpool vorhandenen Threads und zum Ausführen des Vorgangs ist nicht vorhersagbar, da sie von anderen Prozessen, die auf dem Testcomputer ausgeführt werden, abhängt. Der Test kann von Zeit zu Zeit fehlschlagen, wenn die Ruhephase zu kurz ist und der asynchrone Vorgang noch nicht abgeschlossen ist. Ich habe also die Wahl zwischen der Hölle und dem Fegefeuer: Entweder versuche ich, die Wartezeit so knapp wie möglich zu halten, auf die Gefahr hin, dass der Test fragil wird, oder ich erhöhe die Ruhezeit, um den Test robuster zu machen, aber damit verlangsame ich den Test noch mehr.

Das Problem ist ganz ähnlich, wenn ich Code teste, der einen Timer verwendet:

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

Und dieser Test bekommt es wahrscheinlich mit den gleichen Problemen zu tun:

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

Wenn ich einen ausreichend komplexen Vorgang mit mehreren verschiedenen Codeverzweigungen teste, lande ich bei einer riesigen Anzahl von Einzeltests. Meine Testsuiten werden mit jedem neuen Test langsamer und langsamer. Die Wartungskosten für diese Testsuiten nehmen zu, da ich Zeit für die Untersuchung der sporadischen Fehler aufwenden muss. Außerdem werden langsame Testsuiten tendenziell weniger häufig ausgeführt und bieten daher weniger Vorteile. Ab einem bestimmten Punkt würde ich diese langsamen und gelegentlich fehlschlagenden Tests wahrscheinlich ganz einstellen.

Die zwei zuvor gezeigten Tests können ferner nicht erkennen, wenn der Vorgang eine Ausnahme ausgibt. Da der Vorgang in einem anderen Thread ausgeführt wird, werden Ausnahmen nicht an den Thread weitergeleitet, der den Test ausführt. Dies schränkt die Möglichkeiten von Tests ein, das ordnungsgemäße Fehlerverhalten des zu testenden Codes zu überprüfen.

Ich stelle drei allgemeine Lösungsansätze zum Vermeiden von langsamen, wenig stabilen Komponententests durch Verbessern des Entwurfs des getesteten Codes vor und zeige, wie dadurch das Überprüfen von Ausnahmen durch Komponententests möglich wird. Jede der vorgestellten Lösungen bietet Vorteile, hat aber auch Nachteile oder Einschränkungen. Am Ende gebe ich Empfehlungen, welche Lösung für bestimmte Situationen gewählt werden sollte.

Dieser Artikel befasst sich mit Komponententests. Normalerweise testen Komponententests isoliert von anderen Teilen des Systems. Einer dieser anderen Teile des Systems ist die Multithreadingfähigkeit des Betriebssystems. Standardbibliotheksklassen und -methoden werden verwendet, um asynchrone Arbeit zu planen, der Multithreadingaspekt sollte in Komponententests aber ausgeschlossen werden, die sich auf die asynchron ausgeführte Funktionalität konzentrieren sollten.

Komponententests für asynchronen Code sind sinnvoll, wenn der Code Teile der Funktionalität enthält, die in einem Thread ausgeführt werden, und die Komponententests sollen überprüfen, dass die Teile erwartungsgemäß arbeiten. Wenn die Komponententests gezeigt haben, dass die Funktionalität richtig ist, ist es sinnvoll, weitere Teststrategien zum Entdecken von Problemen mit der Parallelität hinzuzufügen. Es gibt eine Reihe von Ansätzen für das Testen und Analysieren von multithreadfähigem Code, um diese Art von Problemen zu erkennen (beispielsweise beschrieben in "Tools und Verfahren zum Identifizieren von Parallelitätsproblemen" unter bit.ly/1tVjpll). Beispielsweise können Stresstests ein gesamtes System oder einen großen Teil des Systems unter Last setzen. Diese Strategien sind zur Ergänzung von Komponententests sinnvoll, sprengen aber den Rahmen dieses Artikels. Die Lösungen in diesem Artikel zeigen, wie die für Multithreading zuständigen Teile ausgeschlossen werden, während die Funktionalität in Komponententests isoliert getestet wird.

Lösung 1: Trennen der Funktionalität vom Multithreading

Die einfachste Lösung für Komponententests der Funktionalität bei asynchronen Vorgängen besteht darin, die Funktionalität vom Multithreading zu trennen. Gerard Meszaros hat diesen Ansatz im Humble Object-Muster ("Einfaches Objekt") in seinem Buch "xUnit Test Patterns" (Addison-Wesley, 2007) beschrieben. Die zu testende Funktionalität wird in eine separate neue Klasse extrahiert, und der Multithreadingteil verbleibt im Humble Object, das die neue Klasse aufruft (siehe Abbildung 1).

Das Humble Object-Muster
Abbildung 1 Das Humble Object-Muster

Der folgende Code zeigt die extrahierte Funktionalität nach der Refaktorierung, bei der es sich um rein synchronen Code handelt:

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

Vor dem Refaktorieren war die Funktionalität mit dem asynchronen Code vermischt, der wurde aber in die Klasse "Functionality" ausgelagert. Diese Klassen kann jetzt mithilfe einfacher Komponententests getestet werden, da sie kein Multithreading mehr enthält. Beachten Sie, dass diese Refaktorierung allgemein wichtig ist, nicht nur für Komponententests: Komponenten sollten inhärent synchronen Vorgängen keine asynchronen Wrapper verfügbar machen und stattdessen dem Aufrufer überlassen, zu bestimmen, ob der Aufruf des betreffenden Vorgangs abgeladen werden soll. Im Fall eines Komponententests entscheide ich mich dagegen, die nutzende Anwendung kann das aber anders handhaben, entweder um verbesserte Reaktion zu erreichen oder parallele Ausführung zu ermöglichen. Weitere Informationen finden Sie in Stephen Toubs Blogbeitrag "Should I Expose Asynchronous Wrappers for Synchronous Methods?" (bit.ly/1shQPfn).

In einer aufs Notwendige beschränkten Variation dieses Musters können bestimmte private Methoden der Klasse "SystemUnderTest" als öffentlich definiert werden, um Tests das direkte Aufrufen dieser Methoden zu ermöglichen. In diesem Fall muss keine weitere Klasse erstellt werden, um die Funktionalität ohne Multithreading zu testen.

Das Trennen der Funktionalität mithilfe des Humble Object-Musters ist einfach und kann nicht nur für Code erfolgen, der asynchrone Arbeit sofort einmalig plant, sondern auch für Code, der Timer verwendet. In diesem Fall verbleibt die Timerverarbeitung im Humble Object, und der wiederholte Vorgang wird in die Klasse "Functionality" oder eine öffentliche Methode verschoben. Ein Vorteil dieser Lösung liegt darin, dass Tests direkt Ausnahmen testen können, die vom getesteten Code ausgelöst werden. Das Humble Object-Muster kann ohne Berücksichtigung der für das Planen der asynchronen Arbeit verwendeten Techniken angewendet werden. Die Nachteile dieser Lösung sind, dass der Code im Humble Object selbst nicht getestet wird und der zu testende Code geändert werden muss.

Lösung 2: Synchronisieren von Tests

Wenn der Test imstande ist, den Abschluss des getesteten Vorgangs, der asynchron ausgeführt wird, zu erkennen, können die zwei Nachteile der schlechten Stabilität und der Langsamkeit vermieden werden. Obwohl der Test multithreadingfähigen Code testet, kann er schnell und zuverlässig sein, wenn der Test mit dem Vorgang synchronisiert wird, der von dem getesteten Code geplant wird. Der Test kann sich auf die Funktionalität konzentrieren, während die negativen Auswirkungen der asynchronen Ausführung minimiert werden.

Im besten Fall gibt die getestete Methode eine Instanz eines Typs zurück, der signalisiert wird, wenn die Ausführung abgeschlossen wurde. Der Typ "Task", der in .NET Framework seit Version 4 verfügbar ist, entspricht dieser Anforderung bestens, und das Feature "async"/"await", das seit .NET Framework 4.5 verfügbar ist, macht das Zusammenstellen von Tasks einfach:

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

Diese Refaktorierung stellt ein allgemeines bewährtes Verfahren dar, das sowohl im Fall von Komponententests als auch bei der allgemeinen Nutzung der verfügbar gemachten asynchronen Funktionalität hilfreich ist. Durch Zurückgeben eines Tasks, der den asynchronen Vorgang darstellt, kann ein Verbraucher des Codes auf einfache Weise bestimmen, wenn der asynchrone Vorgang abgeschlossen wurde, ob er mit einer Ausnahme fehlgeschlagen ist und ob er ein Ergebnis zurückgegeben hat. 

Dadurch werden Komponententests von asynchronen Methoden so einfach wie Komponententests von synchronen Methoden. Nun ist es einfach, den Test mit dem zu testenden Code zu synchronisieren, indem einfach die Zielmethode aufgerufen und der Abschluss des zurückgegebenen Tasks abgewartet wird. Dieses Warten kann synchron (durch Blockieren des aufrufenden Threads) mithilfe der Task Wait-Methoden oder asynchron (mithilfe von Fortsetzungen, um das Blockieren des aufrufenden Threads zu verhindern) mithilfe des Schlüsselworts "await" erfolgen, bevor das Ergebnis des asynchronen Vorgangs überprüft wird (siehe Abbildung 2).

Synchronisierung mithilfe von "Async" und "Await"
Abbildung 2 Synchronisierung mithilfe von "Async" und "Await"

Um "await" in einer Komponententestmethode zu verwenden, muss der Test selbst mit "async" in der Signatur deklariert werden. Es ist jedoch keine Ruheanweisung mehr erforderlich:

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

Glücklicherweise unterstützen die neuesten Versionen der größeren Komponententestframeworks – MSTest, xUnit.net und NUnit – die Tests mit "async" und "await" (weitere Informationen dazu in Stephen Clearys Blog unter bit.ly/1x18mta). Ihre Ausführungsmodule für Tests können asynchrone Task-Tests bewältigen und den Abschluss des Threads mit "await" abwarten, bevor sie mit der Auswertung der Assertanweisungen beginnen. Wenn das Ausführungsmodul des Komponententestframeworks die Signaturen von asynchronen Task-Tests nicht verarbeiten kann, kann der Test zumindest die Wait-Methode für den vom getesteten System zurückgegebenen Task aufrufen.

Darüber hinaus kann die timerbasierte Funktionaität mithilfe der Klasse "TaskCompletionSource" verbessert werden (Details dazu finden Sie im Codedownload). Der Test kann dann auf den Abschluss bestimmter Wiederholungsvorgänge warten:

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

Unglücklicherweise kann der zu testende Code im manchen Fällen "async" und "await" nicht verwenden, etwa, wenn Sie Code testen, der bereits veröffentlicht wurde und aus Gründen fehlerhafter Änderung (Breaking Change) die Signatur der getesteten Methode nicht geändert werden kann. In derartigen Situationen muss die Synchronisierung mit anderen Techniken implementiert werden. Synchronisierung kann realisiert werden, wenn die getestete Klasse ein Ereignis oder ein abhängiges Objekt aufruft, wenn der Vorgang abgeschlossen ist. Das folgende Beispiel veranschaulicht, wie der Test implementiert wird, wenn ein abhängiges Objekt aufgerufen wird:

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

Ein weiteres Beispiel für ereignisbasierte Synchronisierung findet sich im Codedownload.

Der Test kann jetzt mit dem asynchronen Vorgang synchronisiert werden, wenn das abhängige Objekt während des Testens durch einen Stub ersetzt wird (siehe Abbildung 3).

Synchronisierung durch einen abhängigen Objektstub
Abbildung 3 Synchronisierung durch einen abhängigen Objektstub

Der Test muss den Stub mit einem threadsicheren Benachrichtigungsmechanismus ausstatten, da der Stubcode in einem anderen Thread ausgeführt wird. Im folgenden Testbeispiel wird ein "ManualResetEventSlim" verwendet, und der Stub wird mit dem Mocking-Framework RhinoMocks generiert:

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

Der Test kann jetzt den asynchronen Vorgang ausführen und auf die Benachrichtigung warten:

// 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);

Die Lösung, den Test mit den getesteten Threads zu synchronisieren, kann auf Code mit bestimmten Charakteristika angewendet werden: Der zu testende Code verfügt über einen Benachrichtigungsmechanismus, wie "async" und "await" oder ein einfaches Ereignis, oder der Code ruft ein abhängiges Objekt auf.

Ein großer Vorteil der Synchronisierung mithilfe von "async" und "await" besteht darin, dass jede Art von Ergebnis an den aufrufenden Client zurück übergeben werden kann. Eine besondere Art von Ergebnis stellt eine Ausnahme dar. Der Test kann also explizit Ausnahmen verarbeiten. Die anderen Synchronisierungsmechanismen können Fehler nur indirekt anhand des fehlerhaften Ergebnisses erkennen.

Code mit timerbasierter Funktionalität kann "async"/"await", Ereignisse oder Aufrufe an abhängige Objekte nutzen, um die Synchronisierung von Tests mit den Timervorgängen zu ermöglichen. Jedes Mal, wenn der wiederholte Vorgang abgeschlossen wird, wird der Test benachrichtigt und kann das Ergebnis überprüfen (siehe dazu die Beispiele im Codedownload).

Leider werden Komponententests durch Timer auch dann langsam, wenn Sie eine Benachrichtigung einsetzen. Der zu testende wiederholte Vorgang startet im Allgemeinen erst nach einer gewissen Verzögerung. Der Test wird verlangsamt und nimmt mindestens die Verzögerungszeit in Anspruch. Dies ist ein weiterer Nachteil, zusätzlich zur Benachrichtigungsbedingung.

Jetzt betrachte ich eine Lösung, die einige der Einschränkungen der zwei vorangegangenen vermeidet.

Lösung 3: Test in einem Thread

Für diese Lösung muss der zu testende Code so vorbereitet werden, dass der Test die Ausführung der Vorgänge später im gleichen Thread wie der Test selber auslösen kann. Dies ist eine Übersetzung des Ansatzes des jMock-Teams für Java (weitere Informationen dazu in "Testing Multithreaded Code" unter jmock.org/threads.html).

Das zu testende System verwendet im folgenden Beispiel ein eingeführtes Taskplanerobjekt zum Planen der asynchronen Arbeit. Um die Fähigkeiten der dritten Lösung zu verdeutlichen, habe ich einen zweiten Vorgang hinzugefügt, der gestartet wird, wenn der erste Vorgang abgeschlossen ist:

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

Das zu testende System wird so geändert, dass es einen separaten TaskScheduler verwendet. Während des Tests wird der "normale" TaskScheduler durch einen "DeterministicTaskScheduler" ersetzt, der das synchrone Starten der asynchronen Vorgänge ermöglicht (siehe Abbildung 4).

Verwenden eines separaten TaskSchedulers in "SystemUnderTest"
Abbildung 4 Verwenden eines separaten TaskSchedulers in "SystemUnderTest"

Der folgende Test kann die geplanten Vorgänge im gleichen Thread wie den Test selbst ausführen. Der Test injiziert den "Deterministic­TaskScheduler" in den zu testenden Code. Der "DeterministicTaskScheduler" startet nicht sofort einen neuen Thread sondern stellt geplante Tasks lediglich in die Warteschlange ein. In der nächsten Anweisung führt die Methode "RunTasksUntil­Idle" synchron die zwei Vorgänge aus:

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

Der DeterministicTaskScheduler setzt TaskScheduler-Methoden außer Kraft, um die Planungsfunktionalität bereitzustellen und fügt unter anderem speziell für den Test die Methode "RunTasksUntilIdle" hinzu (Implementierungsdetails können Sie dem Codedownload für DeterministicTaskScheduler entnehmen). Wie bei synchronen Komponententests können Stubs verwendet werden, um jeweils nur eine einzelne Funktionseinheit zu untersuchen.

Code, der Timer verwendet, ist nicht nur deshalb problematisch, weil er Tests wenig stabil und langsam macht. Komponententests werden noch komplizierter, wenn der Code einen Timer verwendet, der nicht in einem Arbeitsthread ausgeführt wird. In der Klassenbibliothek von .NET Framework gibt es Timer, die speziell für den Einsatz in UI-Anwendungen ausgelegt wurden, wie etwa "System.Windows.Forms.Timer" für Windows-Formulare oder "System.Windows.Threading.DispatcherTimer" für WPF-Anwendungen (Windows Presentation Foundation) (weitere Informationen dazu in "Comparing the Timer Classes in the .NET Framework Class Library" unter bit.ly/1r0SVic). Diese verwenden die UI-Nachrichtenwarteschlange, die bei Komponententests nicht direkt zur Verfügung steht. Der am Anfang dieses Artikels gezeigte Test funktioniert für diese Timer nicht. Der Test muss die Nachrichtenpumpe auf Touren bringen, etwa durch Verwendung von "WPF DispatcherFrame" (siehe dazu das Beispiel im Codedownload). Um die Komponententests beim Einsatz von UI-basierten Timern einfach und klar zu halten, müssen diese Timer während der Tests ersetzt werden. Ich stelle eine Schnittstelle für Timer vor, um das Ersetzen der "echten" Timer durch eine speziell für die Tests geschaffene Implementierung zu ermöglichen. Ich tue das ebenso für "threadbasierte" Timer wie "System.Timers.Timer" oder "System.Threading.Timer", da ich auf diese Weise die Komponententests in allen Fällen verbessern kann. Das zu testende System muss für die Verwendung dieser ITimer-Schnittstelle geändert werden:

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

Durch Einführen der ITimer-Schnittstelle kann ich das Timerverhalten während der Tests ersetzen, wie in Abbildung 5 gezeigt.

Verwenden von "ITimer" in "SystemUnderTest"
Abbildung 5 Verwenden von "ITimer" in "SystemUnderTest"

Der zusätzliche Aufwand für die Definition der Schnittstelle "ITimer" rentiert sich, da ein Komponententest, der das Ergebnis der Initialisierung und des wiederholten Vorgangs überprüft, jetzt sehr schnell und zuverlässig innerhalb von Millisekunden ausgeführt werden kann:

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

Der "DeterministicTimer" wird speziell für Testzwecke erstellt. Er ermöglicht dem Test, den Zeitpunkt zu bestimmen, an dem die Timeraktion ausgeführt wird, ohne Wartezeit. Die Aktion wird im gleichen Thread wie der Test selbst ausgeführt (Implementierungsdetails zu "DeterministicTimer" finden sich im Codedownload). Für die Ausführung des getesteten Codes in einem nicht testbezogneen Kontext muss ein ITimer-Adapter für einen vorhandenen Timer implementiert werden. Der Codedownload enthält Beispiele von Adaptern für mehrere der Timer der Framework-Klassenbibliothek. Die "ITimer"-Schnittstelle kann für die Anforderungen der konkreten Situation maßgeschneidert werden und ggf. nur eine Teilmenge der gesamten Funktionalität bestimmter Timer enthalten.

Tests von asynchronem Code mit einem "DeterministicTaskScheduler" oder einem "DeterministicTimer" ermöglichen auf einfache Weise das Deaktivieren von Multithreading während des Tests. Die Funktionalität wird im gleichen Thread wie der eigentliche Test ausgeführt. Das Zusammenwirken von Initialisierungscode und asynchronem Code bleibt erhalten und kann getestet werden. Ein Test dieser Art kann beispielweise die richtigen Zeitwerte überprüfen, die zum Initialisieren eines Timers verwendet werden. Ausnahmen werden an Tests weitergeleitet, so dass sie direkt das Fehlerverhalten des Codes überprüfen können.

Zusammenfassung

Effektive Komponententests von asynchronem Code weisen drei hauptsächliche Vorteile auf: Die Wartungskosten für Tests werden reduziert, Tests werden schneller ausgeführt, und die Gefahr, Tests zu vermeiden, wird minimiert. Die in diesem Artikel vorgestellten Lösungen können Ihnen helfen, dieses Ziel zu erreichen.

Die erste Lösung, das Trennen der Funktionalität von den asynchronen Aspekten eines Programms mithilfe eines Humble Objects ist die allgemeinste. Sie kann auf alle Situationen angewendet werden, unabhängig von der Weise, wie die Threads gestartet werden. Ich empfehle diese Lösung für sehr komplexe asynchrone Szenarien, komplexe Funktionalität oder eine Kombination aus beidem. Es ist ein gutes Beispiel für das Entwurfsprinzip der Separation of Concerns (SoC, Trennung von Belangen) (siehe dazu auch bit.ly/1lB8iHD).

Die zweite Lösung, die den Test mit dem Abschluss des getesteten Threads synchronisiert, kann angewendet werden, wenn der getestete Code einen Synchronisierungsmechanismus wie "async" und "await" bereitstellt. Diese Lösung ist sinnvoll, wenn die Vorbedingungen zum Benachrichtigungsmechanismus ohnehin erfüllt sind. Verwenden Sie möglichst die elegante Synchronisierung mittels "async" und "await", wenn nicht Timern unterstehende Threads gestartet werden, da Ausnahmen an den Test weitergegeben werden. Tests mit Timern können "await", Ereignisse oder Aufrufe an abhängige Objekte verwenden. Diese Tests sind möglicherweise langsam, wenn Timer lange Verzögerungen oder Intervalle aufweisen.

Die dritte Lösung verwendet den "DeterministicTaskScheduler" und den "DeterministicTimer" und vermeidet so die meisten Einschränkungen und Nachteile der anderen Lösungen. Sie erfordert eine gewisse Mühe, um den zu testenden Code vorzubereiten, es kann so jedoch eine hohe Abdeckung durch Komponententests erreicht werden. Die Tests für Code mit Timern können sehr schnell ausgeführt werden, ohne Wartezeiten wegen der Verzögerungen und Intervalle von Timern. Außerdem werden Ausnahmen an die Tests weitergeleitet. Diese Lösung führt also zu robusten, schnellen und eleganten Komponententestsuiten in Kombination mit hoher Abdeckung des Codes.

Diese drei Lösungen können Software-Entwickler helfen, die Fallstricke bei Komponententests von asynchronem Code zu vermeiden. Sie können verwendet werden, um schnelle und robuste Komponententestsuiten zu erstellen und eine große Bandbreite von Techniken der parallelen Programmierung abzudecken.


Sven Grand ist Softwarearchitekt in der Qualitätssicherung für die Geschäftseinheit "Diagnostisches Röntgen" von Philips Healthcare. Er wurde vor Jahren "testinfiziert", als er 2001 bei einer Microsoft-Softwarekonferenz zum ersten Mal von testgesteuerter Entwicklung hörte. Sie erreichen ihn unter sven.grand@philips.com.

Unser Dank gilt den folgenden technischen Experten für die Durchsicht dieses Artikels: Stephen Cleary, James McCaffrey, Henning Pohl und Stephen Toub