CodeGym /Kurse /Spring Data JPA /Testen von Queries und Projections

Testen von Queries und Projections

Spring Data JPA
Level 27, Lektion 3
Verfügbar

1. Der Read-Contract des Repositorys

Wenn wir eine Derived Query, JPQL oder Projection testen, testen wir eigentlich nicht die „Syntax der Abfrage", sondern den Read-Contract: welche Methode welche Daten zurückgeben muss, welche sie nicht zurückgeben darf und in welcher Form sie das Ergebnis liefern soll. Das ist wie die Überprüfung des Filters in einer Kaffeemaschine: uns ist egal, woraus er besteht – wichtig ist, dass in der Tasse Kaffee landet, nicht Kaffee plus die Hälfte der Bohnen. Deshalb beginnt ein guter Query-Test immer mit der Formulierung einer „Auswahlregel", nicht mit assertThat(list).isNotEmpty().

Um die Grenzen besser im Blick zu behalten, hilft es, die drei Arten von Read-Methoden gedanklich zu vergleichen:

Werkzeug Was daran am häufigsten „kaputtgeht" Was der Test beweisen sollte Was der Test nicht beweisen muss
Derived Query falscher Eigenschaftsname, falscher Pfad über eine Assoziation, unerwartetes IgnoreCase/Containing Die Filterung wählt tatsächlich die richtigen Zeilen aus dass das Mapping überhaupt existiert (das wurde bereits in Vorlesung 3 behandelt)
@Query (JPQL) Tippfehler im String, falscher Alias/Pfad, falsche Parameter Die Abfrage wird ausgeführt und liefert die erwarteten Daten alle Felder der Entität und den gesamten Assoziationsgraphen
Projection Nichtübereinstimmung von Alias/Konstruktor, „falsche Felder", vertauschte Parameterreihenfolge Die Form des Ergebnisses (shape) ist korrekt Lifecycle der Entität und Lazy-Navigation

Und noch ein Punkt, den Anfänger oft übersehen: Ein Query-Test ist keine „Ehrlichkeitsprüfung des ORM". Das ORM ist im Großen und Ganzen ehrlich. Es ist eine Überprüfung unserer Erwartungen an die Daten. Wir prüfen, ob die Methode tatsächlich einen Use Case ausdrückt und nicht einfach „irgendwie irgendetwas auswählt".

Zuerst ist es wichtig festzuhalten, wer in die Auswahl fällt und in welcher Form das Ergebnis kommt. Darauf aufbauend entstehen Fälle, in denen der Vertrag zusätzlich von der Reihenfolge, der Seitengröße und der Kombination von Bedingungen abhängt.

Um die Struktur eines solchen Tests visuell zu verankern, kann man sich Folgendes vorstellen:

flowchart TD
  A[Arrange: Fixture erstellen] --> B[Flush + Clear für Fairness]
  B --> C[Act: Repository-Methode aufrufen]
  C --> D[Assert: Read-Contract prüfen]

2. Fixtures für Query-Tests

Ein Fixture für einen Query-Test ist keine „kleine Kopie der Produktion". Es sind speziell ausgewählte Daten, an denen man sehr gut erkennen kann, wer den Filter passieren muss und wer ihn nicht passieren darf. Wenn im Fixture alle Datensätze die Bedingung erfüllen, wird der Test nutzlos: Er beweist nur, dass „die Abfrage manchmal etwas zurückgibt". Und wenn das Fixture riesig ist, wird der Test verrauscht, und man versteht nicht mehr, was genau er prüft.

Für unser shop-data-jpa sieht eine praktische minimale „Kontrastpaarung" so aus: zwei Kategorien (tea, coffee) und mehrere Produkte, von denen eines den Filter erfüllt, ein anderes ähnlich ist, aber ausgeschlossen werden muss. Zum Beispiel haben beide dieselbe Kategorie, aber unterschiedliche Stati (ACTIVE und ARCHIVED), oder denselben Status, aber unterschiedliche Kategorien. Mit einem solchen Satz lässt sich die Filterung sehr einfach nachweisen, ohne den Test zu einem 40‑Seiten‑Roman zu machen.

Ein kleiner Lifehack: Es ist in Ordnung, kleine Factory-Methoden in Tests zu halten; das hilft, nicht in Settern zu ertrinken. Hauptsache, aus dem Test selbst ist ersichtlich, welche Daten genau wir erzeugen.

Ein Beispiel für eine „minimale Factory" (sie macht den Test nicht magisch, sie entfernt lediglich visuelles Rauschen):

import java.math.BigDecimal;

private Product product(String sku, String name, ProductStatus status, Category category) {
    // Wichtig: Eine Factory in Tests dient nicht der "Magie", sondern um identische new/setter über die gesamte Datei zu vermeiden.
    // Hier legen wir explizit einen Standardpreis fest; in Tests ändern wir nur das, was den Contract beeinflusst.
    return new Product(sku, name, status, BigDecimal.TEN, category);
}

Und hier ein Beispiel einer kurzen „kontrastreichen" Datenvorbereitung, bei der der Unterschied mit einem Blick erkennbar ist:

// Kategorie, nach der gefiltert wird
Category tea = categoryRepository.save(new Category("tea", "Tea"));

// Zwei Artikel in derselben Kategorie, aber mit unterschiedlichen Stati: einer muss durchkommen, der zweite nicht
productRepository.save(new Product("SKU-1", "Green Tea", ProductStatus.ACTIVE, BigDecimal.TEN, tea));
productRepository.save(new Product("SKU-2", "Old Tea", ProductStatus.ARCHIVED, BigDecimal.ONE, tea));

// Erzwingen, dass in die DB geschrieben wird, damit der Query wirklich Daten aus der Datenbank liest
entityManager.flush();
// Persistence Context leeren, damit im Assert nicht versehentlich Managed-Objekte aus dem Speicher verwendet werden
entityManager.clear();

flush() und clear() sind hier nur ein technischer Schritt, damit der Query-Test ehrlich aus der Datenbank liest. Die allgemeine Mechanik haben wir bereits im JPA-Slice besprochen; in diesem Block geht es um etwas anderes: Der Read-Contract muss an einem DB-backed Ergebnis geprüft werden, nicht an Managed-Objekten der aktuellen Transaktion.

3. Derived Queries

Derived Queries sehen freundlich aus: Man schreibt eine Methode, Spring Data parst sie, und alles funktioniert … bis es nicht mehr funktioniert. Und das Problem ist, dass der Java-Compiler in dieser Geschichte fast nichts garantiert. Die Methode findByStauts(...) (ja, mit Tippfehler) kann durchaus existieren, die Anwendung stürzt dann aber bereits beim Start des Kontexts ab. Oder die Methode ist „fast richtig", aber die Auswahl wird unerwartet – besonders wenn man über eine Assoziation geht (category.code) oder Containing/IgnoreCase verwendet.

Beginnen wir mit dem einfachsten Test: Filter nach einem gewöhnlichen Feld. Angenommen, wir haben eine solche Methode:

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Contract: Produkte mit dem angegebenen Status zurückgeben (und nur diese)
    List<Product> findByStatus(ProductStatus status);
}

Ein guter Test dazu sollte zwei Dinge beweisen: „Das benötigte Produkt ist enthalten" und „Ein unnötiges Produkt ist nicht enthalten". Ein minimaler Assert auf die Größe ist zu schwach, weil „zufällig übereingestimmt" vorkommen kann. Besser ist, konkrete Identifikatoren (z. B. SKU) zu prüfen:

import static org.assertj.core.api.Assertions.assertThat;

// Act: Lese-Methode aufrufen
List<Product> result = productRepository.findByStatus(ProductStatus.ACTIVE);

// Assert: Contract über einen "semantischen Marker" (SKU) fixieren, nicht über size/isNotEmpty
assertThat(result)
        .extracting(Product::getSku)
        .containsExactly("SKU-1");

Wichtig: Wir behaupten nicht, dass die Liste „nicht leer" ist, sondern dass genau die SKU in der Liste ist, die den Filter passieren muss. Das ist schon eher ein Contract.

Nun eine „realistischere" Derived Query, bei der Anfänger am häufigsten Fehler machen: Filtern nach einem Feld einer verknüpften Entität. Zum Beispiel wollen wir Produkte nach category.code auswählen. In einer Derived Query sieht das so aus (Beachte den Unterstrich, um den Übergang zu einer verschachtelten Eigenschaft explizit zu machen):

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Übergang über die Assoziation: category.code (Unterstrich hilft, das verschachtelte Feld explizit zu zeigen)
    // Contract: Nach Kategorie-Code und Status auswählen
    List<Product> findByCategory_CodeAndStatus(String code, ProductStatus status);
}

Ein Test für so etwas ist besonders wertvoll, weil er sofort drei Ebenen prüft: Korrektheit des Feldnamens, Korrektheit der Assoziation und Korrektheit des Filters – und dabei trotzdem kurz und verständlich bleibt.

import static org.assertj.core.api.Assertions.assertThat;

// Act: Nach verschachteltem Feld + Status filtern
List<Product> result = productRepository
        .findByCategory_CodeAndStatus("tea", ProductStatus.ACTIVE);

// Assert: Ohne Sortierung ist die Reihenfolge nicht garantiert, daher prüfen wir "als Menge"
assertThat(result)
        .extracting(Product::getSku)
        .containsExactlyInAnyOrder("SKU-1");

Ich verwende bewusst containsExactlyInAnyOrder(...), denn wenn man keine explizite Sortierung festgelegt hat, ist die Reihenfolge der Ergebniszeilen kein Teil des Contracts. Viele „brüchige" Tests sterben nicht, weil die Abfrage falsch ist, sondern weil die Datenbank die Zeilen in einer anderen Reihenfolge liefert. Ein Test sollte aus semantischen Gründen fehlschlagen, nicht wegen der Laune des Query-Plans.

Ein weiterer praktischer Fall: Tests mit ContainingIgnoreCase. Hier ist der minimale Kontrastsatz sehr einfach: Ein Name enthält die Teilzeichenfolge, der andere nicht. Dazu kann man noch die Groß-/Kleinschreibungs-Unabhängigkeit prüfen.

import static org.assertj.core.api.Assertions.assertThat;

// Act: Nach Teilzeichenfolge ohne Berücksichtigung der Groß-/Kleinschreibung suchen
List<Product> result = productRepository.findByNameContainingIgnoreCase("tea");

// Assert: Menge der SKUs fixieren, die namentlich übereinstimmen müssen
assertThat(result)
        .extracting(Product::getSku)
        .containsExactlyInAnyOrder("SKU-1", "SKU-2");

Wenn Sie erwarten, dass archivierte Produkte nicht auftauchen sollen – dann brauchen Sie einen anderen Contract: zum Beispiel eine separate Methode findByStatusAndNameContainingIgnoreCase(...). Und genau das ist der Moment, in dem ein Repository-Test hilft, die Anforderungen an die Data-Layer zu präzisieren: Sie raten nicht mehr, sondern legen die Regel fest.

4. JPQL (@Query)

JPQL ist ein großartiges Werkzeug, aber es gibt eine Besonderheit: Es ist ein String. Strings, wie bekannt, refaktorieren sich nicht von selbst. Man kann ein Feld in einer Entität umbenennen, das Package einer Projection ändern oder eine Assoziation leicht umbauen – und die JPQL liegt still da und wartet darauf, dass man endlich zur Laufzeit auf diesen Code stößt. Wenn Sie denken „das werde ich schon nicht kaputt machen", dann herzlichen Glückwunsch: Sie haben gerade die klassische Ursache für Bugs in der Produktion beschrieben.

Angenommen, wir haben eine JPQL-Methode, die aktive Produkte in einer Kategorie auswählt (ja, das ginge auch mit Derived Query, aber uns interessiert hier das Prinzip von @Query):

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import org.springframework.data.jpa.repository.Query;

import java.util.List;

@Query("""
       select p from Product p
       where p.category.code = :code and p.status = :status
       """)
List<Product> findActiveInCategory(String code, ProductStatus status);
// Wichtig: Hier schützen wir uns mit einem Test vor Fehlern im JPQL-String (Alias, Pfade, Parameter)

Was muss der Test beweisen? Dass die Abfrage ausgeführt wird, Parameter eingesetzt werden und der Filter auf das verschachtelte Feld (category.code) wirklich funktioniert.

import static org.assertj.core.api.Assertions.assertThat;

// Act: JPQL mit Parametern ausführen
List<Product> result = productRepository.findActiveInCategory("tea", ProductStatus.ACTIVE);

// Assert: derselbe Contract – "welche SKUs müssen zurückkommen"
assertThat(result)
        .extracting(Product::getSku)
        .containsExactly("SKU-1");

Das sieht dem Derived Query Test ähnlich – und das ist normal. Der Unterschied liegt nicht in der Form des Tests, sondern darin, wogegen wir uns absichern. Eine Derived Query bricht aufgrund des Methodennamens und der Parsing-Phase. JPQL bricht aufgrund des Abfragetextes: Alias, Pfad, Parameter, vergessenes Feld, zufälliger Tippfehler.

JPQL-Tests sind besonders nützlich, wenn die Abfrage keine Entitäten, sondern „Skalare" (Zahlen, Summen, Counts) zurückgibt. Dort kann man sich sehr leicht logisch irren: falscher Join, falscher Filter, die sum wird über eine andere Tabelle berechnet. Beispiel einer hypothetischen Abfrage „Anzahl aktiver Produkte in einer Kategorie zählen":

import org.springframework.data.jpa.repository.Query;

@Query("""
       select count(p)
       from Product p
       where p.category.code = :code and p.status = com.example.shopdatajpa.catalog.entity.ProductStatus.ACTIVE
       """)
long countActiveInCategory(String code);
// Wichtig: Der Test fängt hier nicht nur "die Abfrage kompiliert", sondern auch, dass die Zähllogik tatsächlich korrekt ist

Der Test zu einer solchen Methode sollte möglichst „arithmetisch" sein: Wir haben genau N passende Datensätze – also muss die Antwort N sein.

import static org.assertj.core.api.Assertions.assertThat;

// Act: Anzahl der passenden Datensätze holen
long count = productRepository.countActiveInCategory("tea");

// Assert: exakten Wert erwarten, nicht "größer Null"
assertThat(count).isEqualTo(1L);

Und ja, man kann das als „Kleinigkeit" ansehen. Aber genau solche „Kleinigkeiten" werden später zu „Warum zeigt das Dashboard 0 aktive Produkte, obwohl es sie gibt?". Die Abfrage wurde ausgeführt? Ja. Kompilierungsfehler? Nein. Logik falsch? Ja. Und das ist genau der Verantwortungsbereich von Tests.

5. Projections

Projections sind Ihre Möglichkeit zu sagen: „Ich brauche nicht die ganze Entität, ich brauche genau diesen Datenausschnitt für einen bestimmten Use Case." Und hier gibt es bei Anfängern zwei klassische Verzerrungen. Die erste: Projection testen, als sei sie eine Entität: id prüfen, Beziehungen prüfen, Lazy prüfen. Die zweite: Gar nichts prüfen, weil „da sind ja nur zwei Felder". Tatsächlich testet man Projections sehr praktisch: Wir stellen fest, dass die Felder korrekt gefüllt sind und dem Contract entsprechen.

Beginnen wir mit einer Interface-basierten Projection. Für den Katalog reichen uns zum Beispiel SKU und Name:

public interface ProductSkuNameView {
    // Contract der Projection: Welche Felder sind für den aufrufenden Code garantiert verfügbar
    String getSku();
    String getName();
}

Und das Repository kann sie sogar aus einer Derived Query zurückgeben:

import java.util.List;

// Wichtig: Hier ist der Contract die "Form" des Ergebnisses, nicht die gesamte Entität
List<ProductSkuNameView> findByCategory_Code(String code);

Der Test dazu sollte möglichst „geradlinig" sein: Wir erstellen Daten, rufen die Methode auf und vergewissern uns, dass die View die richtigen Werte enthält.

import static org.assertj.core.api.Assertions.assertThat;

// Act: erste Zeile der Projektion nehmen (im echten Test kann man zusätzlich die Listengröße prüfen)
ProductSkuNameView view = productRepository.findByCategory_Code("tea").get(0);

// Assert: Werte der Projection-Felder prüfen (shape)
assertThat(view.getSku()).isEqualTo("SKU-1");
assertThat(view.getName()).isEqualTo("Green Tea");

Die zweite Form von Projections – Record-/Klassen-basierte Projection über Constructor Expression in JPQL. In unserem Projekt könnte das ProductCatalogRow sein. Zum Beispiel ein Record:

package com.example.shopdatajpa.catalog.query;

// Read-Model: minimaler Datenausschnitt für ein bestimmtes Lese-Szenario
public record ProductCatalogRow(String sku, String name) {
}

Und im Repository eine JPQL mit select new ... (ja, der vollqualifizierte Klassenname in JPQL sieht lang aus, aber das ist die ehrliche Realität):

import com.example.shopdatajpa.catalog.query.ProductCatalogRow;
import org.springframework.data.jpa.repository.Query;

import java.util.List;

@Query("""
       select new com.example.shopdatajpa.catalog.query.ProductCatalogRow(p.sku, p.name)
       from Product p
       where p.category.code = :code
       order by p.name
       """)
List<ProductCatalogRow> findCatalogRows(String code);
// Wichtig: Mit dem Test sichern wir die Übereinstimmung des Konstruktors und die Parameterreihenfolge (p.sku, p.name) ab

Ein Test für eine Record-Projection prüft üblicherweise zwei Dinge. Erstens: dass der Record überhaupt konstruiert wird (der Konstruktor stimmt also). Zweitens: dass die Felder in der richtigen Reihenfolge und mit den richtigen Werten gefüllt sind.

import static org.assertj.core.api.Assertions.assertThat;

// Act: erste Zeile des Katalogs holen (hier ist die Reihenfolge durch order by p.name vorgegeben)
ProductCatalogRow row = productRepository.findCatalogRows("tea").get(0);

// Assert: Prüfen, dass die Felder "nicht vertauscht" sind und korrekt gefüllt sind
assertThat(row.sku()).isEqualTo("SKU-1");
assertThat(row.name()).isEqualTo("Green Tea");

Wenn Sie jemals die Parameterreihenfolge in der Abfrage vertauschen (z. B. p.name, p.sku auswählen), wird der Test schnell und ehrlich fehlschlagen – nicht erst eine Woche später in der Produktion, wenn jemand merkt, dass im UI „die SKU plötzlich der Name ist".

Wichtig zu bedenken: Eine Projection ist ein Read-Model. Sie muss kein „reiches Domänenobjekt" sein. Sie muss eine präzise Antwort auf eine Frage sein. Deshalb testen wir sie auch wie eine Antwort auf eine Frage und nicht wie eine Entität „an sich".

6. Assertions in Query-Tests

Die unangenehmste Art von Test ist der, der scheinbar „alles prüft", aber bei jeder Kleinigkeit rot wird. Und die zweite unangenehme Art ist der, der „immer grün" ist, weil er fast nichts prüft. Bei Query-Tests liegt die goldene Mitte normalerweise darin: Wir prüfen nur die Felder, die den Contract der Methode ausmachen, und das tun wir ausdrucksstark, aber kurz.

AssertJ hilft sehr, wenn Sie extracting(...) verwenden. Das erlaubt, die „semantischen Marker" des Ergebnisses (SKU, Code, Status) zu prüfen, ohne die Objekte als Ganzes vergleichen zu müssen.

Zum Beispiel: Wenn eine Methode genau die aktiven Produkte zurückgeben soll, dann sind Status und SKU ideale Felder für den Contract:

import static org.assertj.core.api.Assertions.assertThat;

// Assert: Alle Elemente des Ergebnisses müssen einen Teil des Contracts erfüllen (Status ACTIVE)
assertThat(result)
        .allMatch(p -> p.getStatus() == ProductStatus.ACTIVE);

Und wenn Sie nachweisen wollen, dass „ein bestimmter Satz von SKUs durchgekommen ist", dann:

import static org.assertj.core.api.Assertions.assertThat;

// Assert: Konkreter Satz von "Markern" (SKU) – ein guter Contract für einen Filtertest
assertThat(result)
        .extracting(Product::getSku)
        .containsExactlyInAnyOrder("SKU-1");

Eine weitere wichtige Disziplin: Verlassen Sie sich nicht auf die Reihenfolge, wenn Sie keine festgelegt haben. Wenn die Reihenfolge wichtig ist, muss sie Teil der Abfrage (order by) oder ein Parameter (Sort/Pageable) sein. Wenn die Reihenfolge aber nicht festgelegt ist, sollte der Test genau die Menge der Elemente prüfen, nicht die Sequenz.

Und das Letzte: Vermeiden Sie „Bonus-Überprüfungen". Wenn Sie die Filterung nach Status testen, müssen Sie nicht im selben Test „nebenbei das Category-Mapping" oder „nebenbei equals/hashCode" prüfen. Solche „nebenbei" machen Tests lang und die Fehlerursache unklar. Lieber zehn kurze Tests mit klarem Sinn als ein einzelner „integrativer Epos", den man kaum anzufassen wagt.

7. Typische Fehler in Query-Tests

Fehler Nr. 1: Ein Fixture, bei dem alle Datensätze den Filter erfüllen.
Das ist die häufigste „Selbsttäuschung". Sie speichern zwei Produkte, beide ACTIVE, rufen dann findByStatus(ACTIVE) auf und freuen sich, dass zwei zurückkommen. Ein solcher Test beweist keine Filterung – er beweist nur, dass das Repository irgendetwas aus der Datenbank lesen kann. Halten Sie immer mindestens einen Datensatz vor, der NICHT durchkommen darf.

Fehler Nr. 2: Assertions auf der Ebene von „nicht leer" oder „Größe größer Null".
assertThat(list).isNotEmpty() – das ist fast immer ein Test, der selbst bei einem schwerwiegenden logischen Fehler grün bleibt. Er fixiert keinen Contract. Viel nützlicher ist es, konkrete Marker des Ergebnisses zu prüfen: SKU, Code, Status, OrderNumber – das, was wirklich „richtig" von „falsch" unterscheidet.

Fehler Nr. 3: flush()/clear() dort ignorieren, wo ein DB-backed Ergebnis benötigt wird.
Das ist hier keine separate Theorie, sondern eine einfache Absicherung: Ein Query-Test muss die Arbeit der Abfrage anhand der Daten in der Datenbank bestätigen, nicht versehentlich auf den aktuellen Persistence Context angewiesen sein.

Fehler Nr. 4: Vermischung von Erwartungen an Entitäten und Projections.
Eine Projection ist keine Entität. Sie hat keinen Lifecycle, man muss nicht prüfen, „ob sie eine id hat" oder „ob ihre Lazy-Beziehungen geladen werden". Wenn eine Methode ProductCatalogRow zurückgibt, testen Sie sku() und name(), und versuchen Sie nicht, aus der Projection „zurück" in das Domänenmodell zu gelangen.

Fehler Nr. 5: Ein Test, der gleichzeitig Derived Query, JPQL und Projection prüft.
Das passiert, wenn man „alles auf einmal abdecken" will. Aber dann verliert man die Diagnose: Der Test wird rot – und man versteht nicht, wo das Problem liegt. Im Methodennamen? Im JPQL-String? Im Projection-Mapping? Trennen Sie: Ein Test – ein Contract – eine Fehlerursache.

1
Aufgabe
Spring Data JPA, Level 27, Lektion 3
Gesperrt
Test der Derived Query für aktive Produkte
Test der Derived Query für aktive Produkte
1
Aufgabe
Spring Data JPA, Level 27, Lektion 3
Gesperrt
JPQL-Projektion für Katalogzeile
JPQL-Projektion für Katalogzeile
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION