Automatisches Testen des Modells und Repositories

https://bildung.social/@oerinformatik/

https://oer-informatik.de/sbb04_repository-test

tl/dr; (ca. 11 min Lesezeit): Die zentralen CRUD-Funktionen des Repositories werden getestet. Dazu wird die given-when-then-Methodik verwendet und in jUnit jeweils Testmethoden implementiert. Dieser Artikel ist ein Teil der Artikelserie zu einem Adressbuch-SpringBoot-Projekt. Weiter geht es dann mit der Erstellung eines Controllers für die Address-Klasse. (Zuletzt geändert am 04.09.2023)

Das Testumfeld: Address und AddressRepository

Mit dem Modell und Repository haben wir eine Struktur angelegt, die zunächst recht abstrakt erscheint. Da es ohnehin nötig ist, für diese Klassen Tests zu generieren, nutzen wir diese, um etwas vertrauter zu werden mit unseren beiden neuen Klassen.

Wir testen die @Entity-Klasse Address nicht allein, sondern bereits integriert mit dem AddressRepository. Um einen Überblick zu erhalten, welche Methoden wir testen sollten, werfen wir nochmal einen Blick auf das Klassendiagramm des Repositories:

Die Klassen und Interfaces um das AddressRepository
Die Klassen und Interfaces um das AddressRepository

Es sollten folgende Tests generiert werden:

  • jedes Attribut der Entity sollte einmal gelesen und geschrieben werden: id, firstName, lastName

  • im CrudRepoistory sollten wir Create/Read/Update/Delete jeweils einmal testen,

    • create: save(new Address())

    • read: findById()

    • update: save(existingAddress)

    • delete: delete(existingAddress)`

  • Darüber hinaus könnte noch interessant sein:

    • existsById()

    • findById()

    • count()

Die Testklasse erstellen und vorbereiten

Das händische Testen ist wichtig, aber umständlich. Künftig sollte dieser Test automatisch erfolgen. Dazu muss zunächst eine jUnit-Testklasse erzeugt werden. Im Kontextmenü, das beim Klick auf den Repositorynamen erscheint, “Go to Test” auswählen…

VSCodium-Kontextmenü-Eintrag “Go to Test”
VSCodium-Kontextmenü-Eintrag “Go to Test”

…im Aufpoppenden Menü “Generate Test” wählen…

Eingabemaske für Testnamen
Eingabemaske für Testnamen

…den Namen übernehmen oder ggf. abändern…

eingetragener Testname in der Eingabemaske
eingetragener Testname in der Eingabemaske

Und es wird am richtigen Ort eine ziemlich leere Klasse erstellt:

Zunächst müssen wir die Integration der Klasse in das Spring-Framework mit @ExtendWith(SpringExtension.class) ergänzen und die Testmöglichkeiten der JPA mit @DataJpaTest aktivieren. Dazu müssen die beiden Annotationen an der Klasse ergänzt werden:

Wir nutzen für die Tests den TestEntityManager (für die erwarteten Ergebnisse) und das AddressRepository (für die tatsächlichen Ergebnisse) - von beidem werden per @Autowired automatisch konfigurierte Instanzen verknüpft.

Darüber hinaus sollten wir in Testklassen vier Methodenrümpfe vorbereiten (einige Frameworks legen diese auch direkt an):

Sie dienen zur Durchführung von Aktionen vor und nach allen Tests (@BeforeAll / @AfterAll) bzw. vor und nach jedem einzelnen Test (@BeforeEach / @AfterEach). Derzeit benötigen wir diese Funktionalität nicht, wir können sie daher zunächst leer belassen.

Aufbau der einzelnen Testmethode

Jede Testmethode wird mit @Test annotiert, damit jUnit sie findet und im Rahmen der Tests ausführt. Sie besteht aus drei Operationen, die explizit in Abschnitten ausgewiesen sein können, oder in einer Operation zusammengefasst werden können:

  • Vorbedingungen festlegen,

  • das tatsächliche Ergebnis des Testaufrufs bestimmen,

  • das erwartetes Ergebnis festlegen, mit dem Ergebnis des Testaufrufs vergleichen und das Testresultat ausgeben.

Die Lesbarkeit von Tests erhöht sich deutlich, wenn man diese drei Operationen in jeder Testmethode klar gegliedert implementiert:

Test-Vorbereitung (Preparation, given):

Hier wird alles vorbereitet:

  • Die Vorbedingungen für den Aufruf der zu testenden Methode werden erfüllt: die nötigen Parameter und die nötigen Instanzen werden erstellt.

Je nach Testumfang kann dieser Abschnitt relativ groß werden. Er wird häufig mit \\given markiert.

Test-Ausführung (Execution, when):

Hier findet lediglich der eigentliche Aufruf der getesteten Methode statt. Soweit nötig wird der Rückgabewert ermittelt. An diesem Abschnitt kann man leicht erkennen, was dieser Test überprüft. Dieser Bereich umfasst häufig nur eine Zeile und kann zur Verdeutlichung mit \\when markiert werden.

Nachweis des Ergebnisses (Verification, then):

Das erwartete Ergebnis wird hier erzeugt oder festgelegt. Mit Hilfe von Zusicherungsmethoden (z.B. assert*()) wird dann das erwartete Ergebnis mit dem tatsächlichen Ergebnis der zu testenden Methode verglichen. Es kann eine oder mehrere Zusicherungen abgefragt werden. Wird eine Zusicherung nicht erfüllt, wirft die genutzte Zusicherungsmethode einen AssertionError - daran merkt jUnit, dass der Test gescheitert ist. (Wer mag, kann das direkt ausprobieren, und mit Hilfe von throw new AssertionError("Jetzt scheitert der Test vorsätzlich!"); den Test einmal probehalber scheitern lassen.) Es ist verbreitet, diesen Abschnitt mit \\then zu markieren.

Beispielhafter Aufbau

Create-Test (save(new Address())):

Um die Erzeugung und das Speichern einer Adresse testen zu können wird zunächst eine Address-Instanz erzeugt…

… durch das Repository gespeichert…

… und mit dem TestEntityManager überprüft, ob diese Address-Instanz dieselbe ist, wie die gespeicherte Instanz.

Bei der Schreibweise der letzten Zeile fällt auf, dass sie wie ein Text zu lesen ist. Man nennt derart verkettete Aufrufe eine FluentAPI. Jeder Methodenaufruf ändert das bestehende Objekt und gibt es selbst als Rückgabewert zurück. Dadurch können weitere verkettete Aufrufe darauf vorgenommen werden. Häufig wird der Import für diese genutzte Bibliothek nicht direkt gefunden. Es müsste also oben, im Import-Bereich ergänzt werden:

Wir nutzen die Klasse TestEntityManager, um herauszufinden, welcher Eintrag sich im Repository befindet (Aufruf: entityManager.find(Address.class, addressId);). Die Klasse bietet eine Reihe weiterer Methoden, mit denen wir unsere Persistenzschicht ansprechen können, ohne direkt auf das Repository zuzugreifen. Einige werden wir später noch benötigen, daher hier ein kleiner (in eine UML-Klassendiagramm gewandelter) Auszug aus dem zugehörigen JavaDoc.

Klassendiagramm der TestEntityManager-Klasse
Klassendiagramm der TestEntityManager-Klasse

Im ganzen könnte der Test z.B. wie im folgenden abgedruckt aussehen. Der Testmethodenname benennt zum einen die getestete Methode (save), zum anderen, was er macht (createAddress). Es wurden noch einige Ausgaben angefügt, die natürlich funktional nicht notwendig sind, aber zum Verständnis dienen sollen:

Falls es Probleme beim automatischen finden der Imports gibt: die Liste müsste insgesamt lauten:

Die Tests aufrufen

Wir haben verschiedene Möglichkeiten, die Ausführung dieses Tests zu starten:

  • Im Projektexplorer im Abschnitt “Java Projekts” gibt es ein Play-Symbol neben dem Java-Filenamen des Tests:
Test starten über Play-Taste unter “Java Projects”
Test starten über Play-Taste unter “Java Projects”

Die Debugging-Infos sind hier im Fenster “Debugging Konsole” zu lesen (Menü: Anzeigen / Debugging Konsole).

  • Wir rufen das entsprechende Maven-Goal auf: führe alles aus bis inklusive der Unittestphase mit allen Tests:
mvn test -f ".\pom.xml"

Es gibt noch viele andere Möglichkeiten. Alle haben gemein, dass das zusammengefasste Testergebnis etwa so auf der Konsole erscheint:

[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  29.110 s
[INFO] Finished at: 2022-09-06T08:33:25+02:00
[INFO] ------------------------------------------------------------------------

Ein bisschen ausführlicher ist das Ergebnis in der Darstellung unter Menü: Anzeigen/Testen:

Test starten über Play-Taste unter “Java Projects”
Test starten über Play-Taste unter “Java Projects”

Irgendwo, tief in den Texten des Terminals verborgen, finden sich auch unsere Debugging-Ausgaben (gemischt mit den Debugging-Infos von Hibernate, dem DB-Framework):

Teste das Erzeugen und Speichern von Adresseinträgen
Folgendes Objekt wird gespeichert: Address [city=Berlin, country=Deutschland, firstName=Hannes, id=null, lastName=Stein, postalCode=10435, street=Musterstraße, streetAdditional=123]
Hibernate: call next value for hibernate_sequence
Vergebene ID: 2
Hibernate: insert into adressen (city, country, first_name, last_name, postal_code, street, street_additional, id) values (?, ?, ?, ?, ?, ?, ?, ?)
Hibernate: select address0_.id as id1_0_, address0_.city as city2_0_, address0_.country as country3_0_, address0_.first_name as first_na4_0_, address0_.last_name as last_nam5_0_, address0_.postal_code as postal_c6_0_, address0_.street as street7_0_, address0_.street_additional as street_a8_0_ from adressen address0_
Danach im RePo vorhanden: [Address [city=Berlin, country=Deutschland, firstName=Hannes, id=2, lastName=Stein, postalCode=10435, street=Musterstraße, streetAdditional=123]]

Wer detailliertere Infos zum Testergebnis benötigt, der findet die erzeugten Protokolle u.a. als XML-Dateien im Projektverzeichnis unter target/surefire-reports/. Stürzen wir uns auf den nächsten Test:

Read-Test (findById())

Um den Lesevorgang zu testen benutzen wir die Methode findById() des Repositories. Diese liefert ein Objekt der Klasse Optional<Address>. Optional ist generisch (also für alle Klassen nutzbar), wird aber hier für die Klasse Address parametrisiert. Optional liefert den Wert des enthaltenen Address-Objekts zurück - oder eben NULL.

Klassendiagramm Optional
Klassendiagramm Optional

Um diesem Optional das Address-Objekt zu entlocken, müssen wir die get() Methode aufrufen. Darüber hinaus kann beim Testen die Methode isPresent() interessant sein, die prüft, ob ein Objekt existiert.

Ich gehe wieder im Dreischritt given-when-thenvor:

given: Es muss zunächst eine Address-Instanz erzeugt werden…

… durch den entityManager am Respository vorbei gespeichert werden …

when: Wir wollen es mit dem Repository auslesen:

then: …und prüfen, ob das mit dem Repository gelesen Objekt dem entspricht, was wir zu lesen glauben:

Der Import für Optional muss dann oben noch ergänzt werden:

Beispielhaft - und wieder mit Ausgaben erweitert - könnte das im Ganzen so aussehen:

Läuft der Test? Im Terminal mvn test -f ".\pom.xml" ausführen und die Ergebnisse unter Menü: Anzeigen/Testen bewundern!

Update-Test (save(Address address))

Hierbei wird die gleiche Methode getestet, wie bei dem create-Test: die save()-Methode des Repositories. Einziger Unterschied ist, dass das Address-Objekt diesmal bereits im Repository enthalten ist und nur aktualisiert werden soll. Der Ablauf ähnelt demnach auch dem des create-Tests:

  • Vorbedingungen (given): Es wird eine Address-Instanz erzeugt:

… durch den EntityManager gespeichert und die id ausgelesen…

  • Ausführung (when): Die Attribute werden verändert…

und über das Repository gespeichert (geupdatet)…

  • Abgleich (then): Es wird geprüft, ob das erwartete Ergebnis dem gespeicherten entspricht:

Beispielhaft könnte das so aussehen:

Auch den Test testen wir erst einmal im Terminal: mvn test -f ".\pom.xml". Alles ok?

Delete-Test

Auch hier kann ähnlich vorgegangen werden:

  • Vorbedingungen (given): Es wird eine Address-Instanz erzeugt:

… durch den entityManager gespeichert…

  • Ausführung (when): Wir löschen die betreffende Address-Instanz mit Hilfe der Repository-Methode…
  • Abgleich (then): Abschließend prüfen wir, ob im Repository wirklich kein Objekt mehr mit der id verfügbar ist:

isNull() wäre natürlich auch erfüllt, wenn bereits das Speichern gescheitert wäre. Diesen Fall fangen wir jedoch mit dem vorherigen Testfall (für save()) ab. Um dennoch sicherzugehen, dass der Eintrag vorher vorhanden und erst dann gelöscht wurde fügen wir eine weitere Zusicherung ein, die diesen Fall ausschließt:

Im given-Teil vor dem Löschen kopieren wir das unter der ID gespeicherte Objekt:

… und vergleichen im then-Teil, ob das aktuell unter der ID gespeicherte Objekt (also nichts mehr) davon abweicht:

Beispielhaft könnte das so aussehen:

Überblick über die Testklasse

Vier Tests sind abgeschlossen, wir schauen uns noch einmal nach Ausführen des Maven-Test-Goals (mvn test -f ".\pom.xml") die Ergebnisse unter Menü: Anzeigen/Testen an:

Testergebnisse
Testergebnisse

Das sieht doch ganz gut aus.

Im Ganzen sieht die Klasse mit vier implementierten und vier nicht implementierten Methoden etwa so aus:

Nächste Schritte

Dieser Artikel ist ein Teil der Artikelserie zu einem Adressbuch-SpringBoot-Projekt.

Modell und das Repository können noch nicht per Webrequest angesprochen werden. Um das zu realisieren muss als nächstes ein Controller die HTTP-Endpunkt für Create, Read, Update und Delete anbieten: POST, GET, PUT, DELETE.

Weiter geht es also mit der Erstellung eines Controllers für die Address-Klasse.


Hinweis zur Nachnutzung als Open Educational Resource (OER)

Dieser Artikel und seine Texte, Bilder, Grafiken, Code und sonstiger Inhalt sind - sofern nicht anders angegeben - lizenziert unter CC BY-SA 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: Automatisches Testen des Modells und Repositories” von Hannes Stein, Lizenz: CC BY-SA 4.0. Der Artikel wurde unter https://oer-informatik.de/sbb04_repository-test veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/java-springboot/Backend. Stand: 04.09.2023.

[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]

Kommentare gerne per Mastodon, Verbesserungsvorschläge per gitlab issue (siehe oben). Beitrag teilen: