Zum Hauptinhalt springen

AB 03 – Die Datenbank allein testen

Die Situation

Zurück zum Anfang dieses Tutorials — zu dem Fehler, der drei Wochen unentdeckt blieb:

„Plötzlich tauchten Einträge in zwei Jahren gleichzeitig auf."

In Tutorial 2 stand dazu eine Behauptung. findByDateBetween wäre falsch, hieß es, weil Between beide Grenzen einschließt — ein Eintrag exakt am 1. Januar um 00:00 Uhr gehörte dann zu zwei Jahren.

Geprüft hat das nie jemand. Es klang einleuchtend, also stand es so da.

Heute machst du aus der Behauptung einen Beweis.

Lernziele

Nach Bearbeitung dieses Arbeitsblatts kannst du:

  • mit @DataJpaTest nur die Datenzugriffsschicht starten
  • mit dem TestEntityManager Daten für einen Test vorbereiten
  • erklären, warum diese Tests deine echten Daten nicht anfassen
  • eine abgeleitete Abfrage an ihrem Grenzwert prüfen
  • an einem Test, der sich nicht schreiben lässt, einen Entwurfsfehler erkennen
  • begründen, warum schlechte Testbarkeit meist ein Hinweis auf schlechten Entwurf ist

Der dritte Slice

Zwei Schichten sind abgesichert, eine fehlt:

SchichtGeprüft vonWomit ersetzt
Web@WebMvcTestService → Doppel
Fachlichkeitgewöhnlicher Unit-TestRepository → Doppel
Datenfehlt

In beiden bisherigen Tests war die Datenbank durch ein Doppel ersetzt. Das war Absicht — aber es heißt eben auch: Ob deine Abfragen richtig sind, hat noch nie jemand nachgesehen. Ein Doppel liefert zurück, was der Test ihm sagt. Es kann kein falsches SQL erzeugen.

@DataJpaTest startet den Gegenpart: Entitäten, Repositories, eine echte Datenbank — aber nichts vom Web.

Zwei Dinge nimmt dir @DataJpaTest ab

1. Eine eigene Datenbank. Es schaltet auf eine Datenbank im Hauptspeicher um. Deine Datei unter data/ wird nicht angefasst — deine echten Einträge sind sicher, und jeder Test startet mit einer leeren Tabelle.

2. Aufräumen. Jeder Test läuft in einer Transaktion, die danach zurückgerollt wird. Was ein Test anlegt, ist nach ihm wieder weg. Deshalb können Tests einander nicht beeinflussen, und ihre Reihenfolge spielt keine Rolle.

Aufgaben

Aufgabe 1 – Das Gerüst

src/test/java/de/szut/gaestebuch/repository/GuestbookEntryRepositoryTest.java
@DataJpaTest
@DisplayName("GuestbookEntryRepository (Datenbank-Schicht)")
class GuestbookEntryRepositoryTest {

@Autowired
private TestEntityManager entityManager;

@Autowired
private GuestbookEntryRepository repository;

@Test
@DisplayName("fängt mit einer leeren Datenbank an")
void startsEmpty() {
assertThat(repository.count()).isZero();
}
}
Auch hier die neuen Import-Pfade
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;

In jeder älteren Anleitung steht org.springframework.boot.test.autoconfigure.orm.jpa.…. Dieses Paket gibt es in Spring Boot 4 nicht mehr.

Antwort

Weil es nicht deine Datenbank ist. @DataJpaTest hat die Verbindung aus application.properties durch eine leere Datenbank im Hauptspeicher ersetzt.

Das ist mehr als Bequemlichkeit: Ein Test, der auf echten Daten arbeitet, hängt davon ab, was zufällig darin steht. Er wird grün oder rot, je nachdem, wer zuletzt etwas angelegt hat. Solche Tests wirft man irgendwann weg, weil ihnen niemand mehr glaubt.

Aufgabe 2 – Daten für einen Test vorbereiten

Um den Jahresfilter zu prüfen, brauchst du Einträge aus verschiedenen Jahren.

/** Legt einen Eintrag mit einem ausdrücklichen Datum an. */
private GuestbookEntry entryDated(LocalDateTime moment, String title) {
GuestbookEntry entry = new GuestbookEntry();
entry.setTitle(title);
entry.setComment("Kommentar zu " + title);
entry.setAuthor("Anna");
entry.setDate(moment);
return entityManager.persistAndFlush(entry);
}
TestEntityManager — warum nicht repository.save(...)?

Beides speichert. Der Unterschied liegt in der Absicht.

persistAndFlush schreibt sofort in die Datenbank, statt auf das Ende der Transaktion zu warten. Das ist wichtig, weil sonst die anschließende Abfrage noch gar nichts finden könnte.

Faustregel: Vorbereiten mit dem TestEntityManager, prüfen mit dem Repository. Dann prüft der Test das Repository — und nicht sich selbst.

Aufgabe 3 – Der Test, der sich nicht schreiben lässt

@Test
@DisplayName("der Jahresfilter liefert nur Einträge des gesuchten Jahres")
void yearFilterReturnsOnlyThatYear() {
entryDated(LocalDateTime.of(2025, 6, 1, 12, 0), "aus 2025");
entryDated(LocalDateTime.of(2026, 6, 1, 12, 0), "aus 2026");
entryDated(LocalDateTime.of(2027, 6, 1, 12, 0), "aus 2027");

Page<GuestbookEntry> hits = repository.findByDateGreaterThanEqualAndDateLessThan(
LocalDateTime.of(2026, 1, 1, 0, 0),
LocalDateTime.of(2027, 1, 1, 0, 0),
PageRequest.of(0, 10));

assertThat(hits.getTotalElements()).isEqualTo(1);
assertThat(hits.getContent().get(0).getTitle()).isEqualTo("aus 2026");
}

Er ist rot. Und diesmal ist die Ursache eine andere als in Arbeitsblatt 01.

@Test
@DisplayName("das gesetzte Datum bleibt erhalten")
void givenDateIsKept() {
LocalDateTime moment = LocalDateTime.of(2020, 5, 17, 12, 0);

GuestbookEntry saved = entryDated(moment, "Alt");
entityManager.clear();

GuestbookEntry loaded = repository.findById(saved.getId()).orElseThrow();
assertThat(loaded.getDate()).isEqualTo(moment);
}
Was die Fehlermeldung zeigt
expected: 2020-05-17T12:00
but was: 2026-08-26T18:10:29.805345

Statt des 17. Mai 2020 steht der heutige Zeitpunkt dort. Dein Datum wurde beim Speichern überschrieben.

Der Grund steht in der Entität — eine Zeile, die aus Tutorial 2 stammt:

@CreationTimestamp
@Column(name = "date_of_entry", nullable = false, updatable = false)
private LocalDateTime date;

@CreationTimestamp setzt beim ersten Speichern den aktuellen Zeitpunkt. Immer. Ohne nachzusehen, ob schon ein Wert da ist.

Damit lässt sich kein Eintrag mit einem Datum aus der Vergangenheit anlegen — nicht im Test und auch sonst nicht.

Hier steht eine Entscheidung an

Der Test lässt sich nicht schreiben. Zwei Auswege bieten sich an:

  1. Um das Hindernis herumbauen. Man könnte das Datum nach dem Speichern per SQL geradebiegen. Der Test würde grün.
  2. Fragen, warum es das Hindernis gibt.

Nimm den zweiten Weg. Denn dieselbe Einschränkung, die den Test blockiert, blockiert auch etwas Fachliches:

Die Marketingabteilung möchte die 200 Einträge aus dem alten Gästebuch übernehmen — mit ihren ursprünglichen Datumsangaben.

Mit @CreationTimestamp ist das unmöglich. Alle 200 Einträge bekämen das Importdatum.

Der Test hat keinen Testfehler gefunden, sondern einen Entwurfsfehler. Das ist kein Zufall: Was sich schlecht testen lässt, ist meist schlecht entworfen. Der Test ist bloß der erste Aufrufer, der die Einschränkung bemerkt — vor der Marketingabteilung.

Aufgabe 4 – Den Entwurf korrigieren

Die Regel soll sein: Wenn kein Datum gesetzt ist, nimm den aktuellen Zeitpunkt. Wenn eines gesetzt ist, lass es stehen.

src/main/java/de/szut/gaestebuch/model/GuestbookEntry.java
@Column(name = "date_of_entry", nullable = false, updatable = false)
private LocalDateTime date;

/**
* Setzt das Datum beim ersten Speichern - aber nur, wenn keines gesetzt ist.
*/
@PrePersist
void setDateIfMissing() {
if (date == null) {
date = LocalDateTime.now();
}
}
vorher: @CreationTimestampjetzt: @PrePersist
Wannbeim ersten Speichernbeim ersten Speichern
Wassetzt immer den aktuellen Zeitpunktsetzt ihn nur, wenn keiner da ist
Neuer Eintrag über die APIbekommt den aktuellen Zeitpunktbekommt den aktuellen Zeitpunkt
Eintrag mit eigenem Datumwird überschriebenbleibt erhalten
Im Betrieb ändert sich nichts

Ein Eintrag, der über POST /api/v1/guestbook-entries hereinkommt, hat kein date im Rumpf — das Feld ist null, und @PrePersist setzt den aktuellen Zeitpunkt. Genau wie vorher.

updatable = false gilt unverändert weiter: Das Datum lässt sich nachträglich nicht ändern. Nur beim Anlegen darf es jetzt vorgegeben werden.

@Test
@DisplayName("ohne Datum wird der aktuelle Zeitpunkt gesetzt")
void withoutDateTheCurrentMomentIsSet() {
GuestbookEntry entry = new GuestbookEntry();
entry.setTitle("Neu");
entry.setComment("Neu");
entry.setAuthor("Anna");

GuestbookEntry saved = entityManager.persistAndFlush(entry);

assertThat(saved.getDate()).isNotNull();
assertThat(saved.getDate()).isAfter(LocalDateTime.now().minusMinutes(1));
}
Warum isAfter(now().minusMinutes(1)) und nicht isEqualTo(now())?

Weil zwischen dem Speichern und dem Prüfen Zeit vergeht — Millionstel Sekunden, aber genug. isEqualTo wäre so gut wie immer rot.

Solche Tests prüfen deshalb ein Zeitfenster, keinen Zeitpunkt. Die Aussage lautet: „vor weniger als einer Minute gesetzt". Das reicht, um „gesetzt" von „nicht gesetzt" und von „irgendein alter Wert" zu unterscheiden.

Aufgabe 5 – Der eigentliche Beweis

Jetzt lässt sich prüfen, was in Tutorial 2 nur behauptet wurde.

GreaterThanEqual … LessThanJahr 2026Jahr 2027Lücke: der Grenzwert gehört nur nach rechtsgenau 1 TrefferBetweenJahr 2026Jahr 2027Überlappung: beide Bereiche schließen den Grenzwert ein2 Treffer — derselbe Eintrag zweimalder Eintrag: 01.01.2027, 00:00:00 Uhr
@Test
@DisplayName("ein Eintrag exakt um Mitternacht am 1. Januar gehört genau EINEM Jahr")
void midnightBelongsToExactlyOneYear() {
LocalDateTime turnOfYear = LocalDateTime.of(2027, 1, 1, 0, 0, 0);
entryDated(turnOfYear, "Punkt Mitternacht");

Page<GuestbookEntry> in2026 = repository.findByDateGreaterThanEqualAndDateLessThan(
LocalDateTime.of(2026, 1, 1, 0, 0),
LocalDateTime.of(2027, 1, 1, 0, 0),
PageRequest.of(0, 10));

Page<GuestbookEntry> in2027 = repository.findByDateGreaterThanEqualAndDateLessThan(
LocalDateTime.of(2027, 1, 1, 0, 0),
LocalDateTime.of(2028, 1, 1, 0, 0),
PageRequest.of(0, 10));

assertThat(in2026.getTotalElements()).isZero();
assertThat(in2027.getTotalElements()).isEqualTo(1);
}
Was passiert — und warum das der Punkt ist

mitternachtAmJahreswechselGehoertGenauEinemJahr wird rot: Der Eintrag erscheint in beiden Jahren, weil Between beide Grenzen einschließt.

Die anderen Tests bleiben grün. Der Eintrag vom 1. Juni liegt tief im Jahr; ihn stört es nicht, ob die Grenze mitgezählt wird.

Genau so verhält sich der Fehler auch im Betrieb: Er zeigt sich nicht. Nicht bei der Abnahme, nicht in der Testphase, nicht im ersten halben Jahr. Erst wenn irgendwann ein Eintrag auf die Sekunde genau am Jahreswechsel entsteht.

Aus der Behauptung in Tutorial 2 ist damit ein überprüfbarer Sachverhalt geworden — einer, der jedes Mal neu geprüft wird, wenn jemand mvnw test aufruft.

Nimm die Änderung anschließend zurück.

Aufgabe 6 – Testfälle

IDBeschreibungVorbedingungTestschritteErwartetes ErgebnisErgebnis
TF-01Alle Datenbank-Tests laufen durchAufgaben 1 bis 5 bearbeitet
  1. GuestbookEntryRepositoryTest ausführen
Alle Tests grün, sechs Stück.
TF-02Die echten Daten bleiben unberührtIn der Datei-Datenbank stehen Einträge
  1. Anzahl der Einträge über GET /api/v1/guestbook-entries notieren
  2. Alle Tests ausführen
  3. Erneut abrufen
Dieselbe Anzahl wie vorher. Die Tests haben nichts angelegt und nichts gelöscht.
TF-03Die Reihenfolge der Tests ist egalAlle Tests grün
  1. Die Testmethoden in der Datei umsortieren
  2. Erneut ausführen
Weiterhin alle grün — jeder Test wird nach seinem Lauf zurückgerollt.
TF-04Grenzwert: Mitternacht gehört zum neuen JahrAufgabe 5 bearbeitet
  1. mitternachtAmJahreswechselGehoertGenauEinemJahr ausführen
Grün: 0 Treffer für 2026, genau 1 Treffer für 2027.
TF-05Between ist nachweislich falschAlle Tests grün
  1. Auf findByDateBetween umstellen
  2. Tests ausführen
  3. Änderung zurücknehmen
Genau der Mitternachts-Test wird rot. Die Tests mit Datumsangaben mitten im Jahr bleiben grün.
TF-06Import mit eigenem Datum ist jetzt möglichAufgabe 4 bearbeitet, Anwendung läuft
  1. POST mit einem "date"-Feld im Rumpf, z. B. "2019-03-01T10:00:00"
Status 201, und die Antwort enthält genau dieses Datum — nicht das heutige.
TF-06 ist ein Nebeneffekt, über den man nachdenken sollte

Der Umbau hat eine Tür geöffnet: Jeder Aufrufer kann jetzt ein beliebiges Datum mitschicken, auch eines in der Zukunft.

Für einen Import ist das gewollt. Für ein öffentliches Gästebuch ist es das vermutlich nicht — dort möchte man den Import erlauben und den anonymen Besuchern verbieten.

Das ist eine fachliche Entscheidung und gehört damit an die Stelle, die du in Arbeitsblatt 01 geschaffen hast: in den Service. Ein Prüfschritt „das Datum darf nicht in der Zukunft liegen" wäre eine Zeile — mit einem Unit-Test, der drei Werte prüft: gestern, jetzt, morgen.

Zusammenfassung

Das hast du gelernt
  • @DataJpaTest startet nur die Datenzugriffsschicht — Entitäten, Repositories, Datenbank.
  • Es schaltet auf eine eigene Datenbank im Hauptspeicher um und rollt jeden Test danach zurück. Deine echten Daten bleiben unberührt, und die Reihenfolge der Tests spielt keine Rolle.
  • Vorbereitet wird mit dem TestEntityManager, geprüft mit dem Repository.
  • Erst dieser Slice prüft, ob die abgeleiteten Abfragen wirklich das richtige SQL erzeugen — mit Doppeln geht das nicht.
  • Ein Test, der sich nicht schreiben lässt, ist ein Befund. @CreationTimestamp verhinderte nicht nur den Test, sondern auch den Import von Altdaten.
  • @PrePersist mit einer Prüfung auf null setzt den Zeitpunkt nur, wenn keiner vorgegeben ist.
  • Bei Zeitstempeln prüft man ein Zeitfenster, keinen Zeitpunkt.
  • Grenzwerte sind auch hier die Fundstelle: Between und GreaterThanEqual … LessThan unterscheiden sich in genau einem Wert.

Selbstkontrolle

  1. Warum ist repository.count() zu Beginn eines @DataJpaTest null, obwohl deine Datenbank Einträge enthält?
  2. Warum können zwei Tests einander nicht beeinflussen?
  3. Wozu dient der TestEntityManager, wenn das Repository doch auch speichern kann?
  4. Welchen Fehler hat der nicht schreibbare Test aufgedeckt — und wen hätte er sonst getroffen?
  5. Warum bleiben beim Wechsel auf Between fast alle Tests grün?
Antworten
  1. Weil @DataJpaTest die Verbindung austauscht: Statt der Datei unter data/ wird eine leere Datenbank im Hauptspeicher benutzt.
  2. Weil jeder Test in einer eigenen Transaktion läuft, die danach zurückgerollt wird. Was ein Test anlegt, existiert nur während seiner Laufzeit.
  3. Um Daten vorzubereiten. persistAndFlush schreibt sofort, damit die anschließende Abfrage etwas findet. Und es trennt Vorbereitung von Prüfung: Der Test prüft das Repository, statt sich auf dessen eigenes Speichern zu verlassen.
  4. @CreationTimestamp überschreibt jedes gesetzte Datum. Getroffen hätte es den Import der 200 Altdaten aus dem früheren Gästebuch — alle hätten das Importdatum bekommen, und aufgefallen wäre es erst danach.
  5. Weil sich Between und GreaterThanEqual … LessThan nur an der oberen Grenze unterscheiden. Alle Testdaten mitten im Jahr verhalten sich identisch. Nur der Eintrag exakt um Mitternacht liegt auf der Grenze — und nur sein Test wird rot.