Bei diesem Beitrag handelt es sich um eine überarbeitete Version. Er wurde zuvor bereits bei refactoring-legacy-code.net veröffentlicht.
Dies ist eine Beitragsserie. Die weiteren Beiträge finden Sie hier:
- Die kniffligen Fälle beim Testen – GUI
- Die kniffligen Fälle beim Testen – Ressourcen
- Die kniffligen Fälle beim Testen – Events
- Die kniffligen Fälle beim Testen – Sichtbarkeit
Exceptions Testen mit NUnit und MSTest
Exceptions Testen ist immer wieder eine Herausforderung für Entwickler. Häufig mangelt es an einer klaren Strategie. Und dabei ist es eigentlich ganz einfach…
Im Kontext von automatisierten Tests fallen Exceptions in eine der beiden folgenden Kategorien:
- Eine Methode löst selbst eine Exception aus, wenn sie einen Ausnahmezustand entdeckt.
- Während der Ausführung einer Methode kann eine Exception auftreten, auf die die Methode reagiert.
Alle anderen Fälle von Exceptions sind für automatisierte Tests nicht relevant. Insbesondere der häufigste Fall, dass eine Ausnahme zwar ausgelöst wird, aber nicht weiter behandelt wird, spielt beim Testen keine Rolle. Solche Fälle landen beim globalen Exception Handler der Anwendung. Wenn der Code, der getestet werden soll, eine Ausnahme nicht behandelt, gibt es dazu auch nichts zu testen.
Eine Exception wird erwartet
Liegt der Fall vor, dass im regulären Programmfluss eine Exception auftreten kann, sollte es für diesen Fall einen automatisierten Test geben. Exceptions Testen bedeutet hier, zu überprüfen, ob eine Ausnahme wie erwartet eintritt. Der Test dient einerseits dazu sicherzustellen, dass der Code auch zukünftig wie erwartet eine Ausnahme auslöst. Zusätzlich dient ein solcher Test der Dokumentation. Der Test drückt dann aus, unter welchen Umständen die Exception erwartet wird, so dass Entwickler, die den Test bzw. die zugehörige Implementation später lesen, weniger überrascht sein werden.
Das folgende Beispiel zeigt, wie mit NUnit überprüft werden kann, ob eine Ausnahme ausgelöst wird.
[TestFixture] public class ExceptionTests { [Test] public void Exakte_Erwartung_über_eine_Ausnahme() { Assert.Throws( () => Sut.DoSomethingThatThrows()); } [Test] public void Irgendeine_Ausnahme() { Assert.Catch( () => Sut.DoSomethingThatThrows()); } } public static class Sut { public static void DoSomethingThatThrows() { throw new NotImplementedException("Bumsdi!"); //throw new NullReferenceException(); } }
Der erste Test erwartet, dass eine NotImplementedException ausgelöst wird. Wird irgendeine andere Exception ausgelöst, selbst wenn es eine Ableitung des erwarteten Exceptiontyps sein sollte, schlägt der Test fehl. Der zweite Test erwartet dagegen einen Exceptiontyp, der von Exception abgeleitet ist. Im Beispiel sind beide Tests grün.
ExpectedException Attribut
Es gibt sowohl bei NUnit 2.x als auch bei MSTest eine zweite Variante um auszudrücken, dass eine Exception ausgelöst wird. Durch anotieren des Tests mit dem ExpectedException Attribut wird der Test nur dann als erfolgreich gewertet, wenn die benannte Ausnahme innerhalb der Testmethode ausgelöst wird.
[TestClass] public class ExceptionTests { [TestMethod, ExpectedException(typeof(NotImplementedException), AllowDerivedTypes = false)] public void Exakte_Erwartung_über_eine_Ausnahme() { Sut.DoSomethingThatThrows(); } [TestMethod, ExpectedException(typeof(Exception), AllowDerivedTypes = true)] public void Irgendeine_Ausnahme() { Sut.DoSomethingThatThrows(); } }
Der (kleine) Nachteil: die Exception kann irgendwo innerhalb des Tests auftreten. Die weiter oben gezeigte NUnit Syntax ist da deutlich spezifischer. Sie sollten also in jedem Fall beachten, dass die Testmethode knapp und fokussiert geschrieben wird, damit der Test nicht versehentlich grün ist, obschon die Ausnahme an einer anderen als der erwarteten Stelle ausgelöst wird.
Eine Anmerkung zu MSTest kann ich mir hier nicht verkneifen. Bei Assert.AreEqual steht der erwartete Wert als erster Parameter vorne. Bei StringAssert.StartsWith steht er an zweiter Stelle hinten. Das ist inkonsistent und erschwert den flüssigen Umgang mit MSTest. Wechseln Sie zu NUnit!
Details einer Exception testen
Manchmal ist es wichtig zu überprüfen, ob eine Exception mit den erwarteten Details ausgelöst wird. Vor allem die Eigenschaft Message kommt hier in Frage. Der folgende Test prüft, ob die Message einer Exception dem String „Bumsdi““ entspricht.
[Test] public void Eigenschaften_der_Ausnahme() { var exception = Assert.Catch( () => Sut.DoSomethingThatThrows()); Assert.That(exception.Message, Is.EqualTo("Bumsdi!")); }
Alternativ kann mit Does.StartWith überprüft werden, ob die Message mit dem String „Bum“ beginnt.
Assert.That(exception.Message, Does.StartWith(„Bum"));
Mit MSTest sieht der Test wie folgt aus:
[TestMethod] public void Eigenschaften_der_Ausnahme() { try { Sut.DoSomethingThatThrows(); } catch (Exception exception) { Assert.AreEqual("Bumsdi!", exception.Message); StringAssert.StartsWith(exception.Message, "Bum"); } }
Wie gesagt, wechseln Sie weg von MSTest…
Eine Exception auslösen
Wenn in der Implementation auf Ausnahmen reagiert wird, muss dieser Code natürlich ebenfalls automatisiert getestet werden. Exceptions Testen heißt nun, im Test eine Exception auszulösen. Die Methode verhält sich anders, wenn eine Ausnahme eintritt. Das bedeutet jedoch, dass man im Test die Ausnahme auslösen muss, auf die der zu testende Code reagieren soll. Mit den entsprechenden Werkzeugen ist das kein Problem, wie das folgende Beispiel zeigt. Sehen Sie hier zunächst eine Klasse, die in ihrer FormatContent Methode entweder den von ReadContent gelesenen string zurückgibt oder im Fall einer Ausnahme einen anderen string liefert.
public class SUT { public string FormatContent() { string content; try { content = ReadContent(); } catch (Exception) { return "Eine Ausnahme..."; } return content; } public string ReadContent() { return "Some content..."; } }
Das sogenannte Happy Day Szenario lässt sich leicht testen. Hier geht es um einen Test für die Situation, dass alles glatt läuft.
[Test] public void Happy_day() { Assert.That(new SUT().FormatContent(), Is.EqualTo("Some content...")); }
Interessanter wird der Fall, wenn nun überprüft werden soll, was das Resultat ist, wenn die ReadContent Methode eine Ausnahme auslöst. Ich verwende für diesen Test das Mock Framework TypeMock Isolator.
[TestFixture, Isolated] public class ExceptionsAuslösenTests { [Test] public void Eine_Exception_auslösen() { var sut = new SUT(); Isolate.WhenCalled(() => sut.ReadContent()).WillThrow(new Exception()); Assert.That(sut.FormatContent(), Is.EqualTo("Eine Ausnahme...")); } }
Zunächst erzeuge ich eine Instanz des System Under Test (SUT). Anschließend weise ich TypeMock Isolator mit der Methode Isolate.WhenCalled an, beim Aufruf der ReadContent Methode eine Ausnahme auszulösen. Zuletzt prüfe ich dann, ob der Aufruf der FormatContent Methode nun den erwarteten String liefert.
Fluch oder Segen?
Über den Einsatz solch leistungsfähiger Mock Frameworks wird unter Entwicklern immer wieder diskutiert. Da TypeMock Isolator und andere Produkte dieser Gattung unter Zuhilfenahme der Profiler API realisiert sind, kann man mit ihnen quasi alles durch Attrappen ersetzen. Damit wird es möglich, auch solchen Code automatisiert zu testen, der mit Abhängigkeiten eben nicht sauber umgeht. Aber hey, dieser Blog befasst sich mit der Frage, wie Sie Ihren Legacy Code Schritt für Schritt in Clean Code refaktorisieren können. Da sind leistungsfähige Werkzeuge, mit denen auch Legacy Code unter Test gestellt werden kann, willkommen 🙂 Exceptions Testen ist mit TypeMock Isolator jedenfalls kein Hexenwerk mehr.
Das soll natürlich nicht bedeuten, dass Clean Code Developer Prinzipien und Praktiken über Bord gehen können, wenn Ihre Tools nur ausreichend leistungsfähig sind.
Umgang mit Exceptions
Exceptions sind für Ausnahmefälle vorgesehen, die im regulären Programmfluss nicht vorkommen sollten und dennoch auftreten, weil außerhalb unseres Verantwortungsbereichs etwas schief geht. Beim Lesen einer Datei kann wie aus heiterem Himmel plötzlich eine Exception auftreten, weil das Netzwerk gerade abgeraucht ist. Diesen Fall kann ich als Entwickler allerdings vorhersehen. Beim Umgang mit externen Ressourcen kann es zu Störungen kommen.
Versuche ich aber, einen beliebigen string in einen int zu konvertieren, ist dies kein Fall für eine Exception. Denn mir sollte klar sein, dass dies nicht immer gelingen kann. Sobald ich das Parsen mit der int.TryParse Funktion vornehme, ist alles in Ordnung. Lediglich bei Verwendung von int.Parse kann eine Ausnahme ausgelöst werden. Im regulären Programmfluss sollte ich also das Parsen von string nach int besser mittels int.TryParse lösen, weil dann sofort klar ist, dass es zwei Fälle geben kann.
Den int.Parse Aufruf in ein try-catch einzuwickeln, ist keine gute Idee. Ein try-catch vermittelt dem Leser den Eindruck, dass etwas unvorhergesehenes passieren kann. Dass ein string nicht in jedem Fall in einen int konvertiert werden kann ist aber vorhersehbar. Vorhersehbare Fälle sollten im Programmfluss nicht mit try-catch gelöst werden. Aber wie so oft: Ausnahmen (pun intended) bestätigen die Regel. Manchmal wird der Code besser lesbar, wenn eine Methode Fehler mittels einer Exception meldet. Sie sollten allerdings jeweils prüfen, ob eine Methode ihre Fehlerfälle per Exception mitteilt oder ob es wie im Fall von TryParse eine Variante gibt, die ohne Exceptions auskommt.
Fazit
Exceptions sind für Ausnahmesituationen zu verwenden, die nicht vom Entwickler vorhergesehen wurden. Ferner für solche Fälle, in denen während der Operation etwas schief laufe kann. Meist hat dies mit Ressourcenzugriffen zu tun. Eine Datenbank mag zunächst antworten und dann plötzlich nicht mehr. Da ist es völlig normal, dass dann eine Ausnahme ausgelöst wird. Dieses Verhalten ist allerdings ebenfalls vom Entwickler vorhersehbar. Folglich sollten automatisierte Tests überprüfen, ob auch im Falle einer Exception alles läuft wie es soll.