UnitTests 7: Laufzeitverhalten

9. Juli 2012

Zwei typische Fragen zu UnitTests begegnen mir immer wieder:

1. Meine UnitTests laufen in unterschiedlichen Threads. Worauf muss ich achten und kann ich das nutzen?
2. Beeinflussen die Daten in den Membern der Testklasse den Folgetest? Welcher ist das?

Die kurze Antwort: Nein. Nichts. Nein. Undefiniert. Winking smile

Aber das geht auch ausführlicher…

Letztlich beziehen sich beide Fragen auf das Laufzeitverhalten von UnitTests und die Abarbeitung der Testmethoden.

 

Das Laufzeitverhalten im Normalfall

Ziehen wir das gleich grundsätzlich auf. Eine UnitTest-Klasse mit Initialisierungen, drei Testmethoden und Bereinigung:

   1: [TestClass]

   2: public class UnitTest1 : IDisposable

   3: {

   4:     public TestContext TestContext { get { /* ... */ } set { /* ... */ } }

   5:  

   6:     public UnitTest1() 

   7:     {

   8:         Trace.WriteLine("[ctor] " + Thread.CurrentThread.ManagedThreadId);

   9:     }

  10:  

  11:     [ClassInitialize]

  12:     public static void ClassInitialize(TestContext testContext) { /* ... */ }

  13:  

  14:     [ClassCleanup]

  15:     public static void ClassCleanup() { /* ... */ }

  16:  

  17:     [TestInitialize]

  18:     public void TestInitialize() { /* ... */ }

  19:  

  20:     [TestMethod]

  21:     public void TestMethod1() { /* ... */ }

  22:  

  23:     [TestMethod]

  24:     public void TestMethod2() { /* ... */ }

  25:  

  26:     [TestMethod]

  27:     public void TestMethod3() { /* ... */ }

  28:  

  29:     public void Dispose() { /* ... */ }

  30: }

Das deckt (mit Ausnahme von ein paar Sonderfällen) die üblichen Dinge ab. In den Methoden erfolgt lediglich die Ausgabe der Methode und des aktuelle Threads als Trace-Ausgabe.

Und hier ist das Resultat:

   1: [ClassInitialize] 13

   2:  

   3: [ctor] 13

   4: [TestContext.set] 13

   5: [TestInitialize] 13

   6: [TestMethod1] 13

   7: [TestCleanup] 13

   8: [Dispose] 13

   9:  

  10: [ctor] 18

  11: [TestContext.set] 18

  12: [TestInitialize] 18

  13: [TestMethod3] 18

  14: [TestCleanup] 18

  15: [Dispose] 18

  16:  

  17: [ctor] 20

  18: [TestContext.set] 20

  19: [TestInitialize] 20

  20: [TestMethod2] 20

  21: [TestCleanup] 20

  22: [Dispose] 20

  23:  

  24: [ClassCleanup] 8

Daran kann man nun einige Dinge deutlich machen…

1. Threads

Jede Testmethode wird auf einem anderen Thread abgearbeitet, aber sequentiell nacheinander.

Wozu dann eigene Threads? Testmethoden können mit einem TimeoutAttribut markiert werden. Ich kann zwar nur spekulieren, aber das wäre eine gute Erklärung dafür, den eigentlichen Aufruf in einen anderen Thread zu verlagern.

Fakt: TestMethoden werden sequentiell abgearbeitet; es gibt keine Grund sich über Synchronisierung Gedanken machen zu müssen.

Und wenn ich doch parallele Verarbeitung möchte? Wozu habe ich schließlich 16 Cores im Buildserver?

Microsoft bietet einen Weg an, das zu ermöglichen. Dummerweise hängt das wieder an der für die Solution globale .testSettings-Datei, und man kommt dann natürlich nicht darum herum, sämtliche Tests und den getesteten Code auf Multithreading zu trimmen… . Ob das den Aufwand wert ist?

2. Reihenfolge

Wer sich die Ausgabe genauer anschaut wird feststellen, dass die Testmethoden in der Reihenfolge TestMethod1 – TestMethod3 – TestMethod2 aufgerufen werden:

   1: ...

   2: [TestMethod1] 13

   3: ...

   4: [TestMethod3] 18

   5: ...

   6: [TestMethod2] 20

   7: ...

Und nein, das ist kein Fake! Diese Reihenfolge hat sich ohne mein Zutun so eingestellt!

Fakt: Die Reihenfolge, in der Testmethoden aufgerufen werden ist undefiniert! Jede Annahme darüber (z.B. in einem Test eine Datei zu produzieren und im folgenden Test auszuwerten) funktioniert bestenfalls zufällig.

Nebenbei: Die ausgeführten Testmethoden können sogar über Klassen und Assemblies wechseln, nachzulesen hier.

Und wenn ich das partout brauche? Dann gibt es Ordered Tests, auf die ich aber hier nicht weiter eingehen will.

3. Testklasse und Kontext

Die Ausgabe zeigt auch sehr schön, was alles beim Aufruf einer Testmethode passiert:

   1: ...

   2: [ctor] 13

   3: [TestContext.set] 13

   4: [TestInitialize] 13

   5: [TestMethod1] 13

   6: [TestCleanup] 13

   7: [Dispose] 13

   8: ...

Für jeden(!) Aufruf einer Testmethode wird eine neue Instanz der Klasse angelegt und im Nachgang sauber (inklusive Dispose) wieder entsorgt.

Testdaten in Instanz-Membern vorzuhalten und an den nächsten Test zu übergeben geht alleine aus diesem Grund schon nicht.

Trotzdem sollte man statt Konstruktor und Dispose besser zwei Methoden mit TestInitializeund TestCleanup-Attribut verwenden. TestInitialize wird notwendig wenn man den TestContext benötigt, der vorher – aber erst nach dem Konstruktor – von der Laufzeitumgebung gesetzt wird.

Dieses Vorgehen stellt sicher, dass keine in Membern vorgehaltenen Testdaten in den nächsten Test “hineinbluten” – solange man sich von statischen Daten fernhält.

An dieser Stelle auch die kurze Warnung vor dem gerne gemachten Fehler, den TestContext in einer mit ClassInitialize markierten Methode zu sichern. Der TestContext ändert sich von Testmethode zu Testmethode, die mit ClassInitialize markierte Methode wird aber nur ein mal aufgerufen.

 

Das Laufzeitverhalten in Sonderfällen

Nach meiner Erfahrung sind in der Praxis oft zwei Sonderfälle von Belang…

1. Asynchronität und Parallelität

In diesen Fällen ist die Antwort einfach und in Teilen unbefriedigend: Das UnitTest-Framework weiß davon nichts und ist darauf nicht vorbereitet. Wenn man asynchrones Verhalten testen will – zum Beispiel eine Komponente die IAsyncResult implementiert – muss man diesen Code synchronisieren bzw. auf Threads warten.

Hinweis: Das neue async keyword habe ich mir in diesem Zusammenhang allerdings noch nicht angeschaut, ebenso wenig die Änderungen im kommenden Visual Studio 2012.

2. Fehlerfall

Läuft der Testcode in eine Endlosschleife oder gar einen Stack Overflow, dann reißt er üblicherweise den Prozess in dem die Tests abgearbeitet werden (QTAgent32.exe) mit in den Tod.

Dummerweise ist Visual Studio darauf nicht wirklich vorbereitet. Man muß diese Situation selbst erkennen und kommt dann i.d.R. nicht um einen Neustart der IDE herum.

Zu beachten ist bei diesen beiden Aussagen, dass sie sich auf Visual Studio 2010 beziehen. Microsoft hat mit Visual Studio 2012 das UnitTest-System neu geschrieben (das war notwendig um C++ sauber zu unterstützen), und ich hatte noch keine Gelegenheit, vergleichbare Erfahrungen damit zu sammeln.

 

Soweit zum Laufzeitverhalten von UnitTests.