Um eine stabile und robuste Anwendung zu schreiben, ist es meiner Überzeugung nach notwendig, mit testgesteuerter Programmierung (engl. Test Driven Development, TDD) zu arbeiten. TDD verlangt, dass Sie die Tests vor dem eigentlichen Quellcode schreiben. Offen gesagt schreibe ich erst den Quellcode und dann die Tests. Hauptsächlich, weil dann Intellisense funktioniert und ich eine Vorstellung davon habe, was und wie zu testen ist. Natürlich kann der zeitliche Abstand zwischen dem Verfassen des Quellcodes und dem Test Minuten, Stunden oder auch einen Tag betragen. Eine Lücke von mehr als einem Tag kommt niemals vor.
Bevor Sie in die Details von Mock-Objekten einsteigen, ist ein kurzer Kommentar zur testgesteuerten Programmierung angebracht. Man hört oft die Meinung, dass TDD zusätzliche Arbeit bedeutet, wenn eigentlich gar nicht genügend Zeit vorhanden ist. Fehlende Zeit für ein Projekt ist ein häufiges Problem. Aber zu sagen, dass TDD der Entwicklung Zeit stiehlt, ist genauso, als würde man etwas bauen, ohne vorher darüber nachzudenken. Allgemein lässt sich für die Softwareindustrie feststellen, dass die Testphasen viel zu kurz ausfallen. Wenn der Konstrukteur ein Produkt entwickelt, erstreckt sich das Testen von der Entwurfsphase bis hin zur Produktion. Testen ist notwendig, denn wenn sich ein Entwurfsproblem erst beim Kunden zeigt, kann das sehr teuer werden. Ein normales Produkt, das sich als fehlerhaft oder problematisch herausstellt, erfordert Rückrufaktionen und wird vom Kunden gemieden. Im schlimmsten Fall kommt es zu gerichtlichen Auseinandersetzungen, die den Ruin einer Firma bedeuten können. Es ist schon eigenartig: Die Softwareindustrie scheint sich damit abzufinden, dass Software abstürzt und nicht das tut, was sie soll. Allerdings sehe ich das Ende dieser Mentalität gekommen, da die Ansprüche an die Verlässlichkeit von Software gestiegen sind. Und wenn die Softwarefirmen die Qualität ihrer Produkte nicht verbessern (wollen), wird die Politik regulierend eingreifen.
Wie sieht nicht testbarer Code aus?
Nehmen Sie einmal folgenden Code an:public class Calculator {public int Add( int param1, int param2) {checked {return param1 + param2;}}}
Um die Klasse Calculator zu testen, muss die Methode Add geprüft werden, indem man einige Werte übergibt und sich vom richtigen Rückgabeergebnis überzeugt. Zusätzlich muss man testen, ob eine Ausnahme ausgelöst wird, wenn ein Überlauf oder Unterlauf auftritt. Für das Testen der Klasse Calculator ist ein Mock-Objekt erforderlich. Der folgende Code veranschaulicht, wann ein Mock notwendig ist:
public class Output {public void Message( string message) {Console.WriteLine( "Message (" + message + ")");}}
In diesem Beispiel hat die Klasse Output eine einzelne Methode namens Message. Diese generiert einen Text, der an die Methode Console.WriteLine geschickt wird. Eine derartige Methode verlangt eine Eingabe, liefert aber keine Ausgabe, die ein Programm auf Plausibilität überprüfen könnte. Der gleiche Problemtyp tritt auf, wenn eine Datenbank manipuliert wird, da die Verifizierung ein komplizierterer sekundärer Schritt ist. Diese Beispiele zeigen deutlich das Problem von nicht testfähigem Code: Es ist nicht ohne weiteres möglich, eine Bedingung zu formulieren, die Korrektheit oder Inkorrektheit definiert. Eine Lösung wäre es, den nicht testfähigen Code in testbaren Code umzuwandeln, doch ist das weder wünschenswert noch möglich, wenn der resultierende Code zu kompliziert würde.
Mock-Objekte mithilfe einer Bibliothek erstellen
Es gibt unterschiedliche Ansätze, um das Problem von nicht testfähigem Code zu lösen. Dieser Artikel konzentriert sich allein auf Mock-Objekte. Ein Mock-Objekt fungiert wie das Objekt, das es nachpictureet, verhält sich auch wie dieses, führt aber die Aktionen nicht aus. Beispielsweise ist in einem Datenbankszenario eine bestimmte SQL-Anweisung zu verifizieren. Im Beispiel der nicht testbaren Klasse würde das Mock-Objekt die Output-Klasse nachahmen. Am einfachsten lässt sich die Output-Klasse mithilfe einer Bibliothek wie Nmock (www.nmock.org) oder mit DotNetMock-Objekten (dotnetmock.sourceforge.net/tikiwiki/tiki-index.php) nachahmen. Dieser Artikel stellt die Nmock-Bibliothek vor, da sie die einfachste ist und praktisch genauso wie DotNetMock arbeitet. Nmock verwendet - wie auch DotNetMock - den Ansatz, Objekte und Methoden basierend auf virtuellen Methoden aufzurufen. Sehen Sie sich dazu die modifizierte Klasse Output an:public class OutputToBeMocked {public virtual void Message( string message) {Console.WriteLine( "Message (" + message + ")");}}
Die Klasse OutputToBeMocked ist mit der Klasse Output identisch, außer dass der Methode Message das Schlüsselwort virtual hinzugefügt wurde. Die Mock-Bibliotheken generieren Proxies und Wrapper mithilfe der dynamischen Intermediate Language (IL) und das Schlüsselwort virtual stellt sicher, dass das nachgeahmte Objekt aufgerufen wird. Problematisch bei Mock-Bibliotheken ist, dass sie Funktionalität überschreiben müssen, ohne neue Typen einzuführen. Um diese Aussage deutlich zu machen, zeigt das Beispiel in Listing 1, wie ein Mock-Objekt aufzurufen ist:
Listing 1
[TestFixture]public class NMockFixture {[Test]public void SimpleTest() {NMock.Mock mock = new NMock.DynamicMock( typeof( OutputToBeMocked));mock.Expect( "Message", new NMock.Constraints.IsEqual( "message"));((OutputToBeMocked)mock.MockInstance).Message( "message");}}
- IsEqual: Testet, ob der Parameter oder der Rückgabewert gleich einem bestimmten Wert ist.
- IsNull: Testet, ob ein Parameter oder Rückgabewert ein null-Wert ist.
- IsAnything: Testet, ob ein Parameter oder Rückgabewert einen beliebigen Wert außer null hat.
- IsTypeOf: Testet, ob ein Parameter oder Rückgabewert einen bestimmten Typ hat. Diese Einschränkung ist hilfreich, wenn man Vererbungsstrukturen untersucht.
- IsIn: Testet, ob ein Parameter oder Rückgabewert gleich einem der Werte im Array ist, das an den Konstruktor von IsIn übergeben wird. Die IsIn-Einschränkung entspricht einem Array von IsEqual-Einschränkungen, wobei aber nur ein Element des Arrays übereinstimmen muss.
Mit den folgenden Bezeichnern lassen sich Einschränkungen zu einer größeren logischen Anweisung verketten:
- Not: Definiert eine logische Anweisung, die den Wert des Einschränkungsergebnisses negiert.
- Or: Definiert eine logische Anweisung, die zwei Einschränkungen verknüpft, wobei nur eine der Einschränkungen den Ergebniswert true zurückgeben muss.
- And: Definiert eine logische Anweisung, die zwei Einschränkungen verknüpft, wobei beide Einschränkungen den Ergebniswert true zurückgeben müssen.
- NotEqual: Spezifiziert die Negation einer IsEqual-Einschränkung.
- NotNull: Spezifiziert die Negation einer IsNull-Einschränkung.
- NotIn: Spezifiziert die Negation einer IsIn-Einschränkung.
Darüber hinaus gibt es Einschränkungsmethoden, die wie folgt definiert sind:
- Expect: Wenn die nachgeahmte Methode aufgerufen wird, müssen die Parameter den angegebenen Einschränkungen entsprechen. Die nachgeahmte Methode enthält keine Rückgabewerte.
- ExpectNoCall: Die Methode darf nicht aufgerufen werden. Wird sie dennoch aufgerufen, wird eine Ausnahme ausgelöst.
- ExpectAndReturn (zwei Versionen) : Die nachgeahmte Methode wird wie die Expect-Methode aufgerufen, außer dass die nachgeahmte Methode Rückgabewerte und/oder Ausgabeparameter hat.
- ExpectAndThrow: Die nachgeahmte Methode löst eine Ausnahme aus, wenn die Einschränkungen nicht erfüllt sind.
Nachdem die Einschränkungen definiert sind, lässt sich die nachgeahmte Objektinstanz aufrufen, indem die Instanz aus der Eigenschaft mock.Instance abgerufen wird. Damit sich der nachgeahmte Objekttyp mit vorhandenem Quellcode nutzen lässt, findet eine Typumwandlung in den Typ OutputToBeMocked statt. Von nun an wird jeder Objektmethodenaufruf des Typs OutputToBeMocked an das dynamisch generierte Mock-Objekt umgeleitet. DynamicMock muss man nicht unbedingt verwenden. Der Typ lässt sich auch mithilfe von Methoden manuell beschreiben, was aber umständlich und fehleranfällig ist. Der Typ DynamicMock verwendet Reflection und ist einfacher einzusetzen.
Mock-Objekte mit einem Alias erzeugen
Das hier dargestellte Mock-Objekt ist nützlich, wenn es sich bei der zu testenden Klasse nicht um die Klasse Output handelt. Das Mock-Objekt ist brauchbar, wenn die Klasse Output als Teil eines größeren Framework verwendet wird. Sehen Sie das wie folgt: Wenn das Framework die Klasse Output verwendet, sind die auf höherer Ebene angesiedelten Methoden und Eigenschaften des Framework nicht testbar, weil die Methoden der niederen Ebenen nicht testbar sind. Es ist nicht möglich, einige Typen als untestbar zu belassen und über Kunstgriffe die Typen auf höherer Ebene testfähig zu machen. Mithilfe des Mock-Objekts gilt die Klasseninfrastruktur der höheren Ebene als testbar.Damit bleibt immer noch das Problem, die Output-Klasse zu testen. Die einfachste und einzige Lösung ist, ein Mock-Objekt des Typs zu erzeugen, den die Output-Klasse verwendet. Beachten Sie aber, dass die Implementierung der Klasse Output die Methode Console.WriteLine verwendet. Das ist ein Problem, weil eine Systembibliothek verwendet wird und man für die Systembibliothek keine Mock-Objekte erstellen kann, da wahrscheinlich nicht alle Methoden als virtual markiert sind. Lässt sich vorhandener Code nicht ändern, muss man einen Alias verwenden, der auf ein Mock-Objekt umgeleitet wird. Das Erstellen eines Mock-Objekts für die Klasse System.Console ist ein wenig kompliziert, weil ein Methodenaufruf für die Standard-.NET-Bibliotheken zu ersetzen ist. Die Lösung besteht darin, Namespaces und Aliase zu verwenden. Das Mock-Objekt muss sich in seinem eigenen Namespace befinden. Eine Implementierung sieht wie in Listing 2 aus.
Listing 2
namespace MockObjects {public class Console {public Console() { }public static void WriteLine( string message) {System.Console.WriteLine( "-" + message + "-");}}}
using System;#if _USE_TEST_ALIAS_using Console = MockObjects.Console;#endif
Der Namespace System ist noch eingeschlossen, aber der Typ Console wird auf den Typ MockObjects.Console umgeleitet. Das bedeutet, dass jeder Aufruf von Console das Mock-Objekt aufruft. Das Mock-Objekt MockObjects.Console braucht nicht die gesamte Funktionalität von System.Console zu enthalten, um eine sinnvolle Testumgebung zu bieten. Dem Zweck entsprechend muss MockObjects.Console nur so viel Funktionalität implementieren, dass man die Tests realistisch durchführen kann. In MockObjects.Console fehlt noch eine Rückmeldung der generierten Ausgabe an die Testumgebung. Benötigt wird ein Rückruf zum Typ IntroTests. Der Quellcode in Listing 3 zeigt ein neu formuliertes Mock-Objekt, in das Callback-Funktionen eingebaut sind:
Listing 3
namespace MockObjects {public delegate void FeedbackString( string message);class NoCallbackDefinedException : ApplicationFatalException {public NoCallbackDefinedException() : base( "No callback is defined") { }}public class Callback {private static FeedbackString _feedback;public static FeedbackString CBFeedbackString {get {if( _feedback == null) {throw new NoCallbackDefinedException();}return _feedback;}set {_feedback = value;}}}public class Console {public Console() { }public static void WriteLine( string message) {Callback.CBFeedbackString( message);}}}
Listing 4
[TestFixture]public class IntroTests {private string _strHelloAnybodyThere = "hello anybody there";[Test]public void SimpleBridge() {Intention obj = Factory.Instantiate();MockObjects.Callback.CBFeedbackString =new MockObjects.FeedbackString( this.CallbackSimpleBridge);obj.Message( _strHelloAnybodyThere);}void CallbackSimpleBridge( string message) {string test = "From the console " + _strHelloAnybodyThere;if( message != test) {throw new Exception();}}}
Damit ist die Rückmeldeschleife komplett und man kann praktisch eine Methode testen, die zunächst nicht testfähig zu sein schien. Alles in allem ist der Callback-Mechanismus leicht zu verstehen und zu implementieren. Er bietet ein Verfahren, bei dem die Test-Infrastruktur der höheren Ebene mit den Typen der niederen Ebene interagieren kann. Ohne die Rückmeldeschleife lässt sich nicht ohne weiteres erkennen, ob ein Test erfolgreich verlaufen ist oder nicht. Der Callback-Mechanismus ist sorgfältig zu konzipieren. Das Beispiel hat einen Delegaten verwendet, das ist aber nicht die einzig mögliche Technik. Man kann auch eine benutzerdefiniert codierte Einschränkung verwenden oder ein PropertyBag, der durch das Mock-Objekt manipuliert wird. Für jeden Fall muss ein Weg gefunden werden, über den die von nicht testbarem Code generierten Daten verifizierbar werden.
Entwicklungsstrategien für Mock-Objekte - Team B gegen Team C
In der Regel ist es komplizierter, mit Mock-Objekten zu arbeiten, als einen einfachen Test zu schreiben. Man muss genau abwägen, wann Mock-Objekte wirklich notwendig sind. Mock-Objekte ermöglichen es, einzelne Typen oder Subsysteme in der Isolation zu testen. Man muss Mock-Objekte nicht für jeden Typ schreiben, sondern nur für die Typen der untersten Ebene. Das gilt für die Typen, die man als Teil der Infrastruktur ansehen kann. Nehmen Sie zum Beispiel an, dass der Typ A den Typ B referenziert, der seinerseits auf den Typ C verweist. Für alles, worauf C verweist, ist ein Mock-Objekt erforderlich. Wenn man Typ B testet, ist für den Typ C kein Mock-Objekt notwendig. Es ist davon auszugehen, dass Typ C vor Typ B getestet wird, und folglich ist Typ C programmtechnisch als korrekt zu betrachten. Es gibt aber ein Szenario, in dem ein Mock-Objekt für Typ C sinnvoll bzw. notwendig ist. Wenn zwei verschiedene Teams die Typen B und C entwickeln, dann braucht möglicherweise das Team, das Typ B entwickelt, ein Mock-Objekt vom Typ C. Die für Typ B zuständigen Mitarbeiter werden nämlich nur bezahlt, wenn ihre Implementierung funktioniert. Und sie wollen sich nicht auf Typ C verlassen, weil sie dann einen Typ testen müssten, den sie nicht implementiert haben. Legt sich das C-Team auf die faule Haut und lässt insgeheim das B-Team testen, spart sich das Team für Typ C eine Menge Arbeit. Wenn also das Team für Typ B eigene Mock-Objekte implementiert, ist es nicht vom Code des Teams für Typ C abhängig und kann den gerechten Lohn erhalten, sobald die Arbeit abgeschlossen ist. Summa summarum erstellt man Mock-Objekte für die folgenden Szenarios:- Verweise oder Typen testen, die nicht unter der Kontrolle des Entwicklers stehen. Das ist Teil der internen Qualitätssicherung, um nachzuweisen, dass eine Anwendung nicht richtig arbeitet, weil sie fehlerhaft ist, und nicht, weil die Abhängigkeiten nicht richtig funktionieren.
- Typen isolieren, um das Testen für einen abgeschlossenen Satz von Funktionalität zu vereinfachen. Manchmal sind Teilsysteme so komplex, dass Mock-Objekte den einzigen Weg darstellen, um die Korrektheit der einzelnen Teilsysteme mithilfe von Tests zu verifizieren.
- Die Testumgebung vereinfachen. Zum Beispiel ist es schwierig, den Implementierungstyp ohne Mock-Objekt zu testen, weil es keine direkte Rückmeldung gibt. Nur mithilfe komplizierter Skripts ließe sich eine Rückmeldung erhalten.
- Rekursive Typverweise. Betrachten Sie das Szenario, in dem ein Teilsystem einen Delegaten-Callback verlangt. Die Testumgebung müsste ein Mock-Objekt bereitstellen, um das Teilsystem erfolgreich testen zu können. Oder nehmen Sie folgendes Szenario an: Assembly1.A ruft die Assembly2.B auf, die ihrerseits Assembly1.C aufruft. Es ist nicht möglich, Assembly1 zu testen, ohne gleichzeitig Assembly2 zu testen und umgekehrt. Die einzige Lösung besteht darin, Assembly2.B zu testen und ein Mock-Objekt für Assembly1.C zu erstellen.
Christian Gross ist ein sehr erfahrener Trainer, der sich für alle Aspekte des Software Engineering interessiert, die mit dem Internet, XML, Java, Apache oder .NET zusammenhängen. Sie erreichen ihn unter contact@devspace.com oder www.devspace.com/Wikka/wikka.php?wakka=BloggerJacksHomePage.


