Dieses Projekt enthält Quellcode, mit dem gezeigt wird, wie objektorientierter Java-Code so designt werden kann, dass der Code gut mit Unit-Tests getestet werden kann. Als "Nebeneffekt" entsteht ein besseres Code-Design, in dem die Klassen von ihren Abhängigkeit entkoppelt sind. Das macht den Code besser verständlich und leichter wartbar.
Der Schlüssel hierfür ist "Dependency Injection" (DI) - das heißt, ein Objekt erzeugt bzw. erschafft sich seine Abhängigkeiten nicht mehr selbst; stattdessen werden diese von außen in das Objekt hinein gegeben. Dafür ist nicht unbedingt ein DI-Framework wie Spring oder JEE/CDI notwendig. Hier verwenden wir "manuelle DI" mittels Constructor Injection: die abhängigen Module werden dem Objekt im Konstruktor übergeben.
Dependency Injection fördert gleichzeitig das "Single Responsibility Principle": Objekte werden von der zusätzlichen Verantwortlichkeit befreit, ihre Abhängigkeiten selbst zu beschaffen, und können sich ausschließlich ihrer eigentlichen Aufgabe widmen.
Für das Testen bedeutet das, dass die Abhängigkeiten in Tests einfach durch Mock-Objekte ersetzt werden können, was das unabhängige Testen der einzelnen Klassen ermöglicht.
- Git
- Java 11
- Eine IDE mit Funktion zur Messung der Testabdeckung, z.B.
- IntelliJ IDEA (Community oder Ultimate Edition) - oder
- Eclipse mit EclEmma- und M2Eclipse-Plugins
- Grundlegende Kenntnisse in den folgenden Themengebieten:
- objektorientierte Programmierung mit Java (Klasse, Instanz bzw. Objekt, Interface, Konstruktor)
- JUnit
java.time
API- Mockito
- JUnit 5 - zum Implementieren und Ausführen von Unit-Tests.
- Mockito - zum Ersetzen von Abhängigkeiten durch Testdoubles.
- AssertJ - für sprechendere Asserts
- Apache Maven - Zur Verwaltung der Dependencies (Bibliotheken).
-
Klonen Sie dieses Projekt von GitHub.
-
Führen Sie im obersten Verzeichnis des Projekts auf einer Kommandozeile folgenden Befehl aus:
Linux:
$ mvnw clean install
Windows:
> mvnw.cmd clean install
Der Build wird fehlschlagen, da die Unit-Tests noch nicht funktionsfähig sind. Ihre Aufgabe wird nun sein, die Testfälle "grün" zu machen.
Die Klasse de.doubleslash.workshops.oodesign.happyhour.PriceService
liefert Preise für Cocktails -
und zwar unterschiedlich, je nachdem ob gerade Happy Hour ist oder nicht. Das wiederum hängt von der aktuellen Uhrzeit ab.
Zum Testen dieser Funktionalität wurde die Testklasse de.doubleslash.workshops.oodesign.happyhour.PriceServiceTest
angelegt. Sie enthält Methoden zum Testen der Preise innerhalb sowie außerhalb der Happy Hour. Da zur gleichen Zeit immer nur eins
von beiden der Fall sein kann, werden immer zwei der vier Testmethoden fehlschlagen.
Der PriceService
muss von der Abhängigkeit "Zeit" entkoppelt werden, damit das Testen des Service zu "unterschiedlichen Uhrzeiten" möglich wird.
Die Abhängigkeit entsteht durch die Nutzung von LocalTime.now()
im Programmcode.
-
Starten Sie den UnitTest
CocktailPriceServiceTest
. Sehen Sie wie zwei der vier Testmethoden fehlschlagen. -
Fügen Sie dem Package
de.doubleslash.workshops.oodesign.happyhour
ein neues Interface namensTimeProvider
hinzu, mit der MethodeLocalTime getCurrentTime()
. -
Schreiben Sie eine Klasse
CurrentTimeProvider
, die das Interface implementiert. Die MethodegetCurrentTime()
gibtLocalTime.now()
zurück. -
Fügen Sie dem Konstruktor der Klasse
CocktailPriceService
einen Parameter vom Interface-TypTimeProvider
hinzu. Speichern Sie das Argument in einer Instanzvariable namenstimeProvider
. -
Ersetzen Sie den Ausdruck
LocalTime.now()
in der KlasseCocktailPriceService
durchtimeProvider.getCurrentTime()
. -
Die
Main
-Klasse im Packagehappyhour
kompiliert nicht mehr, da der Konstruktor vonCocktailPriceService
nun einen Parameter vom TypTimeProvider
erwartet. Übergeben Sie im Konstruktor eine neue Instanz der KlasseCurrentTimeProvider
. -
Auch der
CocktailPriceServiceTest
kompiliert nicht mehr, ebenfalls aufgrund des geänderten Konstruktors. Hier soll allerdings nicht derCurrentTimeProvider
verwendet werden, da wir in den Tests kontrollieren möchten, welche Zeit vomTimeProvider
zurückgegeben wird.Schreiben Sie eine weitere Implementierung von
TimeProvider
eigens für den Unit-Test, und nennen Sie sieTestTimeProvider
. Die Klasse bekommt einen Konstruktor, der einenLocalTime
-Parameter entgegen nimmt. Die dort übergebene Instanz wird von dergetCurrentTime()
-Methode zurückgegeben. -
In der Methode
priceServiceAtTime
vonCocktailPriceServiceTest
übergeben Sie dem Konstruktor vonCocktailPriceService
nun einTestTimeProvider
-Objekt, das mit einerLocalTime
-Instanz mit der Uhrzeit aus den Methodenargumentenhour
undminute
initialisiert wird. Tipp: Verwenden SieLocalTime.of(...)
.=> Der
CocktailPriceServiceTest
sollte jetzt erfolgreich durchlaufen.
Die Klasse de.doubleslash.workshops.oodesign.atm.ATM
(Automatic Teller Machine) repräsentiert
einen Geldautomaten, mit dem Kunden einer Bank sich Geld auszahlen lassen können.
Die Klasse ATM
hat folgende Abhängigkeiten:
CardReader
: repräsentiert eine Hardwarekomponente, die die vom Benutzer eingegebene PIN verifiziert und die Kontonummer ausliest.AccountingRESTServiceClient
: ruft einen REST-Service auf, über den die Abhebung auf dem Konto des Kunden verbucht wird.MoneyDispenser
: repräsentiert die Hardwarekomponente, die das Bargeld enthält und ausgibt.
Folgender Prozess ist in ATM
implementiert:
Offensichtlich wurde die Klasse ATM
nicht testgetrieben entwickelt. Denn sie ist so gestaltet,
dass sie ihre Abhängigkeiten selbst in ihrem Konstruktor erzeugt. Das macht die Klasse untestbar, denn
es ist nicht möglich, ihre Abhängigkeiten durch Testdoubles auszutauschen (diesen Vorgang nennt man
"mocken"; hierfür wird üblicherweise ein Mocking-Framework wie Mockito verwendet).
Darüber hinaus ist die Klasse de.doubleslash.workshops.oodesign.atm.AccountingRESTServiceClient
als
Singleton implementiert. Dadurch kann in der laufenden Anwendung nur eine einzige Instanz existieren,
was das Testen zusätzlich erschwert.
Die Klasse AccountingRESTServiceClient
loggt alle Verbuchungen der Geldabhebungen.
Da die Bank Audit-Verpflichtungen hat, soll ein Test sicherstellen, dass der Logaufruf tatsächlich passiert.
Dafür wurde das Testgerüst de.doubleslash.workshops.oodesign.atm.AccountingRESTServiceClientTest
angelegt.
Das Problem hierbei ist, dass die Logmethoden der Klasse AuditLog
static
sind
und nicht ohne weiteres gemockt werden können.
-
Zunächst sorgen Sie dafür, dass die Klasse
de.doubleslash.workshops.oodesign.atm.AccountingRESTServiceClient
kein Singleton mehr ist. Entfernen Sie dazu die MethodegetInstance()
sowie die statische Klassenvariableinstance
, und machen Sie den Konstruktorpublic
. -
Ändern Sie die Log-Methoden
info
,warn
underror
der Klassede.doubleslash.workshops.oodesign.atm.log.AuditLog
so dass diese nicht mehrstatic
sind. -
Fügen Sie dem Konstruktor von
AccountingRESTServiceClient
einen Parameter vom TypAuditLog
hinzu, und speichern Sie das übergebene Argument als Instanzvariable mit Namenlog
. -
Ersetzen Sie alle statischen Log-Aufrufe in der Klasse durch Aufrufe auf die neue
log
-Variable. -
Die Klasse
ATM
kompiliert nun nicht mehr. Ändern Sie die Initailisierung der VariableaccountingService
, indem Sie die KlasseAccountingRESTServiceClient
über ihren Konstruktor initialisieren, dem Sie eine neue Instanz vonAuditLog
mitgeben. -
Tun Sie dasselbe in
AccountingRESTServiceClientTest
bei der Initialisierung vontestee
, damit auch die Testklasse wieder kompiliert. -
Nun soll die Klasse
AccountingRESTServiceClient
von ihrer Abhängigkeit zuAuditLog
entkoppelt werden. Erstellen Sie dazu ein neues Interface namensLog
mit den Methodeninfo
,warn
underror
wie sie inAuditLog
definiert sind, und lassen SieAuditLog
das Interface implementieren. Das EnumLogLevel
wandert vonAuditLog
in dasLog
-Interface.Tipp: Hierfür können Sie das "Extract Interface"-Refactoring Ihrer IDE nutzen.
-
Ersetzen Sie im
AccountingRESTServiceClient
alle Referenzen zuAuditLog
durch das InterfaceLog
. Jetzt ist derAccountingRESTServiceClient
nicht mehr abhängig von der konkretenAuditLog
-Implementierung. -
Um das Logging des
AccountingRESTServiceClient
testen zu können, erstellen Sie nun eine weitere Implementierung desLog
-Interfaces namensTestLog
, die ausschließlich fürs Testen gedacht ist. Daher landet die Klasse unterhalb vonsrc\test\java\...
, d.h. im selben Verzeichnis wie dieAccountingRESTServiceClientTest
-Klasse. -
Implementieren Sie die
info(...)
-Methode derTestLog
-Klasse so, dass sie alle dort geloggten Nachrichten in einerList
namensinfoMessages
speichert.Tipp: nutzen Sie hierfür die Methode
String#format(...)
, wie sie auch inAuditLog
verwendet wird. -
Fügen Sie
TestLog
eine MethodegetInfoMessages()
hinzu, die die Liste zurückgibt. -
Im
AccountingRESTServiceClientTest
verwenden Sie nun stattAuditLog
eineTestLog
-Instanz. Diese muss als Klassenattribut gespeichert werden, damit die Testmethode darauf zugreifen kann. -
Jetzt können Sie die Testmethode
accountingServiceShouldLogTransaction()
fertig implementieren, indem Sie die Liste der mitinfo(...)
geloggten Nachrichten vonTestLog
abfragen und in der lokalen VariablenloggedMessages
speichern.=> Der
AccountingRESTServiceClientTest
sollte nun erfolgreich durchlaufen.
Für die Geldautomat-Funktionalität in ATM
sollen nachträglich Unit-Tests geschrieben werden.
Verschiedene Szenarien sollen getestet werden, z.B. dass keine Auszahlung erfolgt, wenn die PIN falsch eingegeben wurde bzw. die Abhebung nicht verbucht werden konnte.
Dafür existiert bereits die UnitTest-Klasse de.doubleslash.workshops.oodesign.atm.ATMTest
mit
entsprechenden Testmethoden, die noch ausimplementiert werden müssen. Damit die Klasse ATM
getestet werden kann, muss sie zunächst einem Refactoring unterzogen werden. Danach können die Testmethoden fertiggestellt werden.
-
Schauen Sie sich die Klasse
CardReader
an. Diese simuliert ein Hardware-Modul; daher liefern die MethodenverifyPin()
undreadAccountNumber()
nicht immer dieselben Ergebnisse. Jede Kundenkarte liefert eine andere Kontonummer, und ab und zu kommt es vor dass ein Kunde seine PIN falsch eingibt. Dieses "zufällige" Verhalten (hier simuliert anhand von zufällig generierten Werten) erschwert das Testen der KlasseATM
, würde diese im Test die "echte"CardReader
-Implementierung nutzen. -
Schauen Sie sich die Klasse
ATMTest
und die darin definierten Testfälle an. -
Der Konstruktor von
ATM
erzeugt seine Abhängigkeiten selbst (CardReader
,AccountingRESTServiceClient
undMoneyDispenser
). Ändern Sie dies, indem Sie den Konstruktor vonATM
so erweitern, dass er Objekte dieser drei Klassen als Parameter entgegen nimmt. Speichern Sie die Argumente aus dem Konstruktor anstelle der mitnew
erzeugten Instanzen in den vorhandenen Klassenvariablen. -
Die Methode
Main
kompiliert nicht mehr. Korrigieren Sie dies, indem Sie die erwarteten Konstruktor-Parameter bei der Instanzierung vonATM
hinzufügen (new CardReader()
etc.). -
Die Testklasse
ATMTest
kompiliert ebenfalls nicht mehr. Bei der Instanzierung vontestee
(d.h. "Testkandidat") verwenden Sie jedoch keine echten Instanzen der abhängigken Klassen, sondern sogenannte Mock-Objekte. Hierzu verwenden Sie das Mocking-Framework Mockito.Ein Mock-Objekt für die Klasse
CardReader
wird beispielsweise folgendermaßen erzeugt:
@Mock private CardReader cardReaderMock;
Die Mock-Objekte müssen als Instanzvariablen der Testklasse definiert werden, damit die Testmethoden darauf zugreifen können.
Hinweis: Die Annotation
@ExtendWith(MockitoExtension.class)
über dem Klassenheader sorgt dafür, dass alle mit@Mock
annotierten Felder der Klasse automatisch mit Mockobjekten initialisiert werden. -
Jetzt können die Testmethoden in
ATMTest
fertig implementiert werden. Beginnen Sie mit der ersten MethodeaccountingServiceShouldBeCalledWithCorrect...
.Standardmäßig geben Mock-Objekte
0
,false
odernull
zurück wenn eine ihrer Methoden aufgerufen wird. MitMockito.when(...).thenReturn(...);
kann man dafür sorgen, dass ein Mock für einen bestimmten Methodenaufruf einen definierten Wert zurückgibt. DerCardReader
-Mock soll z.B. die Kontonummer 4711 zurückliefern, wenn seineradAccountNumber()
-Methode aufgerufen wird. Dies erreichen Sie mitMockito.when(cardReaderMock.readAccountNumber()).thenReturn(4711);
. Weiterhin müssen Sie dafür sorgen, dass dieverifyPin()
-Methode von CardReadertrue
zurückgibt. Tun Sie das analog mitMockito.when(...).thenReturn(...)
.Nach dem Aufruf der zu testenden Methode
testee.withdrawMoney(1234, 100.0)
soll geprüft werden, obATM
die MethodewithdrawAmount(...)
vonAccountingRestServiceClient
aufgerufen hat, und zwar mit den korrekten Werten - also dem Betrag austestee.withdrawMoney(...)
sowie der Kontonummer, die voncardReaderMock
geliefert wurde.Hierfür gibt es
Mockito.verify(...)
. Ersetzen Sie dasfail(...)
-Statement am Ende der Testmethode durch:Mockito.verify(accountingServiceMock).withdrawAmount(100.0, 4711);
.=> Wenn Sie alles richtig gemacht haben, sollte diese Testmethode jetzt erfolgreich durchlaufen.
-
Implementieren Sie die restlichen Testmethoden nach dem gleichen Schema, bis die ganze Testklasse "grün" ist.
Mockito-Tipps:
- Es ist auch möglich zu verifizieren, dass eine bestimmte Methode (z.B.
xyz(...)
) auf einem Mock (z.B.myMock
) nicht aufgerufen wurde:Mockito.verify(myMock, Mockito.never()).xyz(...);
- Wenn bei Methodenaufrufen auf Mock-Objekte der Wert der Argumente egal ist, können Sie statt konkreten Werten
auch
Mockito.anyInt()
,Mockito.anyDouble()
etc. übergeben. - Es ist auch möglich, einen Mock beim Aufruf einer bestimmten Methode eine Exception werfen zu lassen, z.B.:
Mockito.when(myMock.methodCall()).thenThrow(new SomeException());
- Sollte etwas mit den Mockito-Methoden nicht wie erwartet funktionieren, versichern Sie sich bitte ob die Klammern korrekt gesetzt sind.
- Das JavaDoc von Mockito gibt Auskunft über die Verwendung der Methoden des Frameworks.
(Unabhängig von Mockito: ein Blick ins JavaDoc lohnt sich immer! ;-)
Aufgabe 2.3: Entkopplung der Klasse ATM
von der konkreten AccountingRESRServiceClient
-Implementierung
Ein Makel im Code-Design existiert immer noch. Die Klasse ATM
ist abhängig von einer konkreten Account-Service-Implementierung,
nämlich vom AccountingRESRServiceClient
.
Der Klasse ATM
sollte es allerdings egal sein, ob der verwendete Service technisch als REST-Client oder anderweitig realisiert ist.
Sollte der Service tatsächlich einmal durch eine andere Implementierung ersetzt werden, müsste ATM
in ihrem jetzigen Zustand ebenfalls angepasst werden.
Zudem wäre es möglich, dass ATM
Methoden von AccountingRESTServiceClient
aufruft, die eigentlich Implementierungsdetails sind
(z.B. REST-spezifisch). In dem Fall wäre der Änderungsaufwand beim Austausch der Accounting-Service-Implementierung noch größer,
da diese implementierungsspezifischen Aufrufe entfernt werden müssen.
Hier können Sie Abhilfe schaffen, indem Sie ATM
von der konkreten Accounting-Service-Implementierung entkoppeln:
- Erstellen Sie ein neues Interface mit Namen
AccountingService
und der Methodeboolean withdrawAmount(double amount, int bankAccountNumber);
. - Lassen Sie den
AccountingRESRServiceClient
das Interface implementieren. - Ersetzen Sie in
ATM
alle Referenzen aufAccountingRESRServiceClient
durch das InterfaceAccountingService
. - Gehen Sie in
ATMTest
ebenso vor. DerATMTest
sollte nach wie vor erfolgreich durchlaufen.
=> ATM
ist nun unabhängig von konkreten AccountService
-Implementierungen. Der AccountingRESRServiceClient
kann nun
durch eine andere Klasse ausgetauscht werden, ohne dass ATM
oder deren Test angefasst werden müssen. Aufgrund des Interfaces
ist es auch nicht mehr möglich, dass ATM
implementationsspezifische Methoden des verwendeten AccountService verwendet.
ATM
ist nun lose an den AccountingService
gekoppelt.
Die Wartbarkeit des Codes wurde dadurch beträchtlich gesteigert.
Wenn Sie alle Aufgaben gelöst haben und die Testklassen erfolgreich durchlaufen, starten Sie die Tests mit der Funktion zur
Coverage-Messung in Ihrer IDE. Sie werden feststellen, dass die Testabdeckung der Klassen, für die es Unit-Tests gibt
(also PriceService
, AccountingRESTServiceClient
und ATM
), jeweils 100% ist!
Aus den zuvor nicht testbaren Klassen haben Sie Klassen mit maximaler Testabdeckung gemacht. Herzlichen Glückwunsch! :-)
P.S.: die Lösung ist im Branch solution
eingecheckt.