90 Prozent Testabdeckung durch gutes OO-Design (Teil 2)

16.04.2019

Rückblick

Im eher theoretisch gehaltenen Blogbeitrag „90 Prozent Testabdeckung durch gutes OO-Design (Teil 1) wurde erläutert, wie durch gutes Code-Design eine hohe Testabdeckung  erzielt werden kann. Dieser 2. Teil soll nun aufzeigen, wie das in der Praxis aussieht.

Insbesondere Dependency Injection spielt für die Testbarkeit eine große Rolle. Erzeugt oder beschafft ein Objekt sich seine Abhängigkeiten nicht selbst, und werden diese stattdessen von außen hinein „injiziert“, können Unit-Tests das Verhalten der Abhängigkeiten kontrollieren. So wird das Aufsetzen von Testszenarien durch Injektion von Test-Doubles (Stubs, Spies, Mocks, etc.) sehr einfach. Dadurch kann der Test bestimmen, welche Rückgabewerte das Objekt bei Methodenaufrufen auf die Abhängigkeiten erhalten soll. Zudem kann so geprüft werden, ob das Testobjekt korrekt mit seinen Kollaborateur-Objekten interagiert.

Übrigens bedarf es für Dependency Injection nicht unbedingt eines IoC-Containers wie JEE/CDI oder Spring. Das Design Pattern kann auch ganz einfach „manuell“ implementiert werden, z.B. indem ein Objekt seine Abhängigkeiten im Konstruktor übergeben bekommt.

Wie man damit von einer nicht testbaren Codebasis zu testbarem Code mit hoher Testabdeckung kommen kann, demonstriert das GitHub-Projekt „Hohe Testabdeckung durch gutes OO-Design“ in zwei Szenarien.

Szenario 1: „Happy Hour“

Im „Happy Hour″-Szenario existiert ein Service, der Preise für Cocktails liefert, und zwar abhängig von der aktuellen Uhrzeit. Ist gerade „Happy Hour“, sind die Preise niedriger als sonst. Doch wie testet man, ob der Service die korrekten Preise zurückliefert? Die Uhrzeit kann man schließlich nicht beeinflussen – oder? Doch, man kann – wenn man die Zeit als Abhängigkeit sieht, und den Cocktail-Service von der Instanz entkoppelt, die die aktuelle Zeit zurück gibt.

Statt des Aufrufs von LocalTime.now() im Cocktail Service selbst bekommt dieser eine neue Abhängigkeit zur Ermittlung der aktuellen Zeit. Dafür wird ein neues Interface mit Namen  TimeProvider1 und zwei Implementierungen bereit gestellt. Eine für den Produktiveinsatz, die tatsächlich die aktuelle Systemzeit zurückliefert, sowie eine, die ausschließlich für Testzwecke bestimmt ist, und die eine fixe, vom jeweiligen Testfall bestimmte Uhrzeit liefert. So kann man unabhängig von der tatsächlichen Uhrzeit, zu der der Unit-Test läuft, sowohl die Preise innerhalb als auch außerhalb der  Happy Hour testen.

Das GitHub-Projekt enthält den nicht testbaren Ausgangscode des Cocktail Service, sowie eine Schritt-für-Schritt-Anleitung für das notwendige Refactoring und die Implementierung der Unit-Tests.

Szenario 2: „Geldautomat“

Szenario 2 modelliert die Geldabhebung an einem Geldautomaten (engl.: „Automated Teller Machine“ bzw. ATM). Das Szenario ist etwas komplexer und beinhaltet mehrere Komponenten, also Abhängigkeiten, mit denen die Hauptklasse ATM interagiert. Das folgende Sequenzdiagramm zeigt den implementierten Geldabhebevorgang mit den beteiligten Komponenten:

 

Die „Dependencies“ der Geldautomaten-Klasse ATM, also die Komponenten von denen sie abhängig ist, sind:

  • CardReader: repräsentiert einen Kartenleser, also die (Software der) Hardwarekomponente, welche die eingegebene Kunden-PIN verifiziert und die Kontonummer ausliest.
  • AccountingRESTServiceClient: ruft einen REST-Kontoservice auf, über den die Abhebung auf dem Kundenkonto verbucht wird. Nur bei erfolgreicher Buchung darf das Geld ausgegeben werden.
  • MoneyDispenser: repräsentiert die Hardwarekomponente, die das Bargeld enthält und ausgibt.

 

Um das Verhalten einer realen Kartenleser-Hardwarekomponente nachzustellen, liefert die Methode CardReader#readAccountNumber() für jeden Aufruf eine zufällig generierte Kontonummer zurück. Dies soll den Sachverhalt simulieren, dass das Auslesen verschiedener Kundenkarten im Lesegerät in jeweils unterschiedlichen Kontonummern resultiert. Die Methode CardReader#verifyPin() gibt bei einem gewissen Prozentsatz der Anfragen „false″, d.h. „PIN nicht korrekt“ zurück, um die Tatsache zu simulieren, dass Kunden manchmal eine falsche PIN eingeben.

Im Ausgangszustand des Beispielcodes auf GitHub („Szenario: Geldautomat“) werden die Objekte CardReader, AccountingRESTServiceClient und MoneyDispenser mit dem Schlüsselwort new von der ATM-Klasse selbst erzeugt. Das Testen der Klasse ATM auf korrekte Funktionalität ist somit nicht möglich.

Ein „unmöglicher“ Testfall

Ein zu implementierender Testfall beispielsweise sei die Prüfung, dass der Methode AccountingRESTServiceClient#withdrawAmount(…) immer genau die Kontonummer übergeben wird, die von  CardReader#readAccountNumber() zurückgeliefert wurde. Unsere CardReader-Implementeriung liefert stets zufällige Kontonummern zurück. Es ist also unmöglich zu wissen, welche Kontonummer zur Verifizierung verwendet werden soll. Und selbst wenn diese bekannt wäre, wäre es nicht möglich zu testen, ob diese der withdrawAmount()-Metode korrekt übergeben wird, da von außen kein Zugriff auf die AccountingRESTServiceClient-Instanz existiert. Zusätzliche Schwierigkeiten macht die verifyPin()-Methode, die bezüglich korrekter PIN-Eingabe meistens „true″, aber ab und zu auch „false“ zurückliefert.

Zur Realisierung des genannten Testfalls müssten wir die von ATM verwendete CardReader-Instanz kontrollieren können, und zwar hinsichtlich

  1. der Kontonummer, die von readAccountNumber() zurückgeliefert wird
  2. dem Aufruf von verifyPin(), der auf jeden Fall „true“ ergeben soll (bei „false“ würde withdrawAmount(…) erst gar nicht aufgerufen werden, was zu gelegentlichem Fehlschlagen des Tests führen würde)

 

Zudem ist Zugriff auf die AccountingRESTServiceClient-Instanz erforderlich, um den korrekten Aufruf von withdrawAmount(…) zu verifizieren.

Refactoring zum Erfolg

Also benötigen wir die Möglichkeit, die CardReader– und AccountingRESTServiceClient-Instanzen durch Test-Doubles ersetzen zu können. Dazu führen wir ein Refactoring durch mit dem Ziel, beim Erzeugen der ATM-Instanz die Abhängigkeiten via Konstruktor2 zu übergeben. Im Produktivcode werden nach wie vor mit new erzeugte Instanzen von CardReader und AccountingRESTServiceClient3 verwendet, mit dem Unterschied, dass diese nicht mehr in, sondern außerhalb der Klasse ATM instanziert werden.

Im Unit-Test wird ATM dagegen mit entsprechenden Mock-Objekten4 instanziert. Über den CardReader-Mock kann nun pro Testfall definiert werden, welche konkrete Kontonummer dieser beim Aufruf von readAccountNumber() zurückgeben soll, und wie die Antwort von verifyPin() lauten soll.

Ein AccountingService-Mock kann nun als „Spy“ verwendet werden, also zur Überprüfung, ob die withdrawAmount(…)-Methode mit der korrekten Kontonummer aufgerufen wurde.

Die Schritt-für-Schritt-Anleitung für das Refactoring und die Implementierung der Testfälle finden sich in der GitHub-README.

Fazit

Für die Testbarkeit von Code ist Dependency Injection das ultimative Design Pattern. Durch Refactoring hin zur Constructor Injection haben wir es schließlich geschafft, die Klassen ATM und CocktailService testbar zu machen, und die oben geschilderten Testfälle, wie auch weitere, zu realisieren.

Sind alle Testfälle implementiert, wird eine Messung der Test Coverage für die Klassen ATM und CocktailService jeweils 100% ergeben.


1 Dies ist im Übrigen ganz im Sinne des „Single Responsibility Principle“. Es gehört nicht zur Verantwortlichkeit des Cocktail Service, die Zeit zu ermitteln. ^

2 sog. „Constructor Injection“ ^

3 Grundsätzlich sollten keine Abhängigkeiten zu Implementierungsdetails existieren. Im Rahmen des Refactorings führen wir daher gleich noch ein neues Interface namens AccountingService ein, das vom AccountingRESTServiceClient implementiert wird. ATM ist anschließend abhängig vom Interface, was zu einer loseren Kopplung führt. Das macht es einfacher, die REST-basierte Implementierung später durch eine andere zu ersetzen. Die Klasse ATM muss dann nicht mehr angepasst werden. ^

4 das Demoprojekt nutzt hierfür das Mocking-Framework „Mockito″. ^

 

Zurück zur Übersicht

Kommentar verfassen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

*Pflichtfelder

*