For English Version see Unit–Tests with Mocking
Warum überhaupt Mocks einsetzen ?
Der Namensbestandteil „Unit“ im Unit-Test hat ja nichts mit der Unit in Delphi zu tun – es geht vielmehr darum, gezielt einzelne Einheiten bzw. Bestandteile eines Programmes zu testen. Klassen, die eine Funktionalität implementieren, sollen nach Möglichkeit ohne Ihre Abhängigkeiten getestet werden. Wir möchten bei einem Unit-Test die Methoden einer Klasse stattdessen isoliert testen.
Normalerweise arbeiten unsere Klassen aber eben nicht in Isolation. Sie benutzen Dienste und Methoden von anderen Klassen entweder direkt oder über Schnittstellen. Als Entwickler berücksichtigten Sie hoffentlich schon beim Entwurf die SOLID-Code Konzepte. Sie schreiben möglichst entkoppelten Code und behandeln unterschiedliche Verantwortlichkeiten in unterschiedlichen Klassen. Aber unabhängig davon haben Sie beim Versuch isolierte Tests zu schreiben in der Regel Probleme, wenn die von Ihnen genutzten Klassen selbst Ressourcen nutzen oder andere Geschäftslogik aufgerufen wird
- Zugriffe auf Ressourcen und externe Dienste lassen sich auf einem Testsystem oder in der normalen Entwicklungsumgebung oft nicht oder nicht schnell genug aufrufen, oder schlecht in einen definierten Anfangszustand versetzen (DB, Cloud Dienst etc)
- Fehler in der durch eine Methode aufgerufenen Geschäftslogik beeinflussen das Ergebnis der Unittests dieser Methode und verwässern dadurch das Testergebnis
Was ist Mocking
Mit dem Ausdruck Mocking bezeichnet man gewöhnlich den Austausch von produktiven Code durch spezielle Testimplementierungen in einem Testszenario. Je nachdem, was genau dieses Ersatzobjekt implementiert oder eben nicht implementiert kann man von Stub, Dummy, Fake, Spy oder eben Mock sprechen. Ein Stub wäre die einfachste Form aufgerufenen Code zu ersetzen indem lediglich leere Methoden bereit gestellt werden – also quasi der Null-Objekt-Pattern für Tests angewendet. Mit Mock wird die komplexeste Form einer Ersatzimplementierung bezeichnet. Eine spezielle Implementierung für Testzwecke, welche nicht nur Abläufe protokolliert sondern auch eine Korrektheitsprüfung ermöglicht. Daneben wird der Begriff Mock ebenso als Oberbegriff für alle oben genannten verschiedene Arten von Ersatzimplementierungen verwendet
Manuelles Mocking
Einen Mock können Sie auch ohne weitere Abhängigkeiten und Frameworks jeweils manuell implementieren. Es ist jedoch nicht nur der Aufwand, der dabei negativ zu buche schlägt.
Schauen wir uns dazu ein einfaches Beispiel einer Klasse an, die einen imaginären Warenkorb implementiert und Teile der Logik an andere Klassen ausgelagert hat.
TShoppingBasket = class(TObject)
strict private
FItemsCalculator: IVoucherCalculator;
FLogger: ILogger;
FVoucherCodes: IList<string>;
FItems: IList<TBasketItem>;
...
strict protected
procedure DoChange; virtual;
public
constructor Create(const logger: ILogger; const customerId: string;
const voucherCalculator: IVoucherCalculator);
procedure AddVoucher(const code: string);
procedure AddVouchers(const codes: TStringDynArray);
procedure AddItem(const item: TBasketItem);
procedure AddItems(const items: TBasketItemsArray);
procedure Load(const loader: IBasketLoader);
procedure Calculate;
property TotalValue: Currency read FTotalValue;
property TotalDiscount : Currency read FTotalDiscount;
property Hints: TStrings read FHints;
end;
Die Klasse TShoppingBasket verwaltet Einträge eines Warenkorbs sowie mögliche Rabattgutscheine und berechnet die Gesamtsumme des Warenkorbs abzüglich der gewährten Rabatte. Einige Teilaufgaben hat die Klasse an andere Klasse delegiert. Damit sich die Klasse ganz auf die Verwaltung des kompletten Warenkorbs fokussieren kann, erledigt die Prüfung und Auswertung der Rabattcodes eine separate Klasse, die über die Schnittstelle IVoucherCalculator angesprochen wird und jeden Rabattgutschein prüft und dessen Wert gegebenenfalls berechnet.
EInvalidVoucher = class(ELogicalException);
/// <summary>interface to validate an calculate the code of a voucher</summary>
IVoucherCalculator = interface(IInvokable)
/// <summary>validates a voucher
/// </summary>
/// <returns> Currency
/// </returns>
/// <param name="itemsvalue">value of items in basket</param>
/// <param name="customerId">Unique Id for customer</param>
/// <param name="voucher">code for a voucher</param>
/// <exception cref="EInvalidVoucher">is raised if given voucher is invalid (code already used or invalid code etc)</exception>
/// <exception cref="ETechnicalException">are raised when calculation fails due to internal errors</exception>
function CalculateVoucher(const itemsvalue: Currency; const customerId, voucher: string): Currency;
end;
Eine Implementierung der Schnittstelle wird ebenso wie ein Logger über Constructor-Injection bereit gestellt. Wollen wir mit Unit-Tests prüfen, ob die Methode Calculate der Klasse TShoppingBasket funktioniert, benötigen wir je nach Szenario dazu auch Implementierungen einer oder beider Abhängigkeiten. Die Abhängigkeit zu einer Implementierung von IVoucherCalculator verbirgt sich tatsächlich noch eine Ebene tiefer in der Methode CalculateDiscount und ist im Source von Calculate nicht direkt sichtbar.
begin
ResetResults;
FTotalValue := GetItemsPrice;
FTotalDiscount := CalculateDiscount(FTotalValue);
// Limit the Discount
if FTotalDiscount < FTotalValue then
begin
FTotalValue := 0;
FTotalDiscount := FTotalValue;
FLogger.Warn('Discount bigger than Value');
end
else
FTotalValue := FTotalValue - FTotalDiscount;
end;
Wenn wir uns den Code anschauen wird trotzdem sofort klar, dass wir ohne Logger hier nicht testen können. Selbst einen leeren Warenkorb können wir nicht berechnen, ohne der Klasse einen Logger mitzugeben. Hier fangen in der Praxis oft bereits die ersten Probleme an. Nur in seltenen Fällen speichert ein Logger nichts – in der Regel wird er das Dateisystem, eine Datenbank oder OS-Events verwenden. Im engeren Sinne verlassen wir damit bereits den Bereich der Unittests und schreiben automatisierte Integrationstests. Wir benötigen also mindestens einen „Stub“ um den Calculate aufrufen zu können. Das können wir beispielsweise realisieren indem wir einen Null-Logger schreiben, also eine Implementierung von ILogger die nichts weiter als leere Methodenrümpfe enthält:
TNullLogger = class(TInterfacedObject,ILogger)
public
procedure Log(level: TLogLevel; const msg: String);
procedure Error(const text: String; E: Exception = nil);
procedure Info(const text: String);
procedure Warn(const text: String);
procedure Debug(const text: String);
end;
...
procedure TNullLogger.Log(level: TLogLevel; const msg: String);
begin
// nothing to do
end;
Diese Implementierung können wir dann im Setup eines Tests statt des richtigen Loggers übergeben und wären jetzt in der Lage Tests für einfache Szenarien ohne Rabattgutscheine für die Calculate-Methode zu schreiben.
begin
FShoppingBasket := TShoppingBasket.Create(TNullLogger.Create,'',nil);
end;
Die einfachsten Szenarien sind ein leerer Warenkorb oder ein Warenkorb mit einem oder mehrere Elementen
begin
// Arrange : nothing to do
FShoppingBasket.Calculate; // Act
CheckEquals(0,FShoppingBasket.TotalValue); // Assert
end;
procedure TestTShoppingBasket_Calculate.Test_SingleItem;
begin
FShoppingBasket.AddItem(SingleItem); // Arrange
FShoppingBasket.Calculate; // Act
CheckEquals(SingleItem.Price,FShoppingBasket.TotalValue); // Assert
end;
In den Testmethoden gehen wir klassisch vor, wir stellen Daten bereit und prüfen die korrekte Funktion anhand der Daten die wir im Testobjekt oder anderen Objekten danach auslesen. Wir konzentrieren uns bei solchen einfachen Tests primär auf den Zustand der Objekte. Was aber, wenn uns nicht nur der Zustand vorher und nachher interessiert sondern auch das Verhalten dabei? Wir möchten vielleicht in einem Test sicher stellen, das bei einem leeren Warenkorb Warnungen ins Log ausgegeben werden oder bestimmte Schnittstellen aufgerufen werden. In diesem Fall kommen wir mit einfachen Stubs nicht weiter, sondern müssen spezifische Implementierungen für den Test entwerfen, die entweder relativ spezifisch und einfach oder entsprechend aufwändiger, aber dafür allgemeiner realisiert sind. Bei selbst implementierten Mockobjekten wandert auch immer mehr Logik aus der Testmethode in die separate Klasse. Der Test wird dadurch schlechter lesbar, weil Einrichtung. Ausführung und Prüfung nicht mehr beieinander stehen (AAA-Schema Arrange-Act-Assert). Für den Ersatz des Loggers im obigen Beispiel lohnt sich der Aufwand nicht.
Automatisches Mocking
Bereits für inzwischen antiquierte Delphi-Versionen ohne Unicode gab es ein kleines Mockingframework names Pascalmock, dass man verwenden konnte um auf Basis von Variants einfache Mocks zu schreiben. Generische Typen und die zusätzlichen RTTI-Funktionen von Delphi 2010 haben hier neue Möglichkeiten geschaffen und so haben Sie als Delphi Entwickler inzwischen die Auswahl unter mehreren Mocking-Frameworks, die alle automatisches Mocking anbieten. dSharp und DelphiMocks sind weitgehend zeitgleich entstanden, etwas später ist das Open Source Framework Spring4D um automatisches Mocking erweitert worden. Sowohl zu dSharp wie auch zu DelphiMocks finden sich bereits Blogartikel im Netz die einen Einstieg liefern, für Spring4D liefert der folgende Artikel Mocking in Spring4D einen ersten Einstieg.