Abfragen schreiben und testen: Repository-Integrationstests mit @DataJpaTest
https://bildung.social/@oerinformatik/
https://oer-informatik.de/spring-boot-repository-integrationstest
Integrationstest, DataJpaTest, Spring Data JPA, Abfragemethoden, JPQL, Query, TestEntityManager, Transaktion, Rollback, H2, MariaDB, GitLab CI
tl/dr; (ca. 26 min Lesezeit): Die Repositories aus Artikel 06 können bisher nur, was sie von
JpaRepositorygeerbt haben. Jetzt bekommen sie eigene Abfragen: aus dem Methodennamen abgeleitet oder mit@Queryin JPQL geschrieben. Ob diese Abfragen das Richtige finden, prüfen Integrationstests mit@DataJpaTestgegen eine echte Datenbank: H2 beim täglichen Testlauf, MariaDB in der Pipeline. Nebenbei beantwortetGET /angeboteendlich Suchanfragen [AI-Assisted]. (Zuletzt geändert am 11.11.2026)
Dieser Artikel ist Teil einer Artikelreihe über ein REST-Backend mit Spring Boot: Überblick und Fahrplan / 01 Anforderungen und API-Vertrag / 02 Projekt anlegen / 03 Codegenerierung / 04 Fachlogik und Komponententest / 05 Build-Qualität und CI / 06 Persistenz mit JPA / 07 Repository-Integrationstest / Übungsaufgaben zu 07
Schon fit in diesem Thema? Nutze die Kann-Liste und die Leitfragen unter dem Artikel als Checkliste, ob Du die relevanten Kompetenzen bereits hast.
Dieser Artikel baut auf der Kann-Liste der Artikel Persistenz mit JPA, Teststrategie und SQL: Selektion und Projektion auf.
Als Grundlagen zu Abfragen und Integrationstests kann ich nach Durchlesen des Artikels…
… erklären, was ein Integrationstest der Persistenzschicht prüft und wie er sich vom Komponententest aus Artikel 04 unterscheidet.
… Abfragemethoden aus dem Methodennamen ableiten lassen (
findBy…And…OrderBy…,existsBy…) und den Namen in die erzeugte SQL-Abfrage übersetzen.… eine Abfrage mit
@Queryin JPQL schreiben und benannte Parameter mit@Paramübergeben.… einen Test mit
@DataJpaTestaufbauen, Testdaten mit demTestEntityManageranlegen und das Ergebnis mit AssertJ prüfen.… die Tests mit Gradle und Maven ausführen und Fehlermeldungen beim Start (unbekanntes Attribut im Methodennamen oder in JPQL) deuten.
Darüber hinaus kann ich als Expertenwissen zu Repository-Tests…
… begründen, warum jeder Test mit einem Rollback endet und warum Tests trotzdem keine festen IDs voraussetzen dürfen.
… mit
saveAndFlushundDataIntegrityViolationExceptionprüfen, ob eine Eindeutigkeitsbedingung der Datenbank greift.… dieselben Tests gegen MariaDB ausführen, lokal mit Docker und in der GitLab-Pipeline mit einem Dienst (
services).
Nach Artikel 06 liegen die Daten des Mitfahrlabors in einer Datenbank, aber die Repositories können nur das, was jedes Repository kann: alles laden, eins über die ID laden, speichern. Die wichtigste Frage der Schülerinnen und Schüler beantwortet das Backend noch nicht: Welche Fahrten gibt es morgen früh ab Herford zur Schule? GET /angebote antwortet weiterhin mit 501 Not Implemented.
Dafür braucht es eigene Abfragen. Und weil eine Abfrage erst zusammen mit Hibernate und einer Datenbank zeigt, ob sie stimmt, kommen die passenden Tests gleich mit. Im V-Modell steigt die Reihe damit auf die Ebene der Integrationstests: Geprüft wird das Zusammenspiel des eigenen Codes mit Spring Data, Hibernate und der Datenbank.
Ausgangspunkt ist das Projekt aus Artikel 06. Das Ergebnis findest Du im Ordner src/07-repository-integrationstest des Repositorys. Wie man Testebenen und Testfälle plant, erklärt der Artikel Teststrategie; die SQL-Grundlagen zu WHERE und ORDER BY stehen in SQL: Selektion und Projektion und Sortieren und Begrenzen.
Was ein Integrationstest hier prüft
Die Fachregeln aus Artikel 04 sind reines Java. Ihre Komponententests brauchen weder Spring noch eine Datenbank und laufen in Millisekunden. Eine Abfragemethode im Repository lässt sich so nicht testen: Sie hat gar keinen Code, den man aufrufen könnte. Spring Data erzeugt die Implementierung erst beim Start, Hibernate übersetzt sie in SQL, und erst die Datenbank liefert das Ergebnis. Fehler können an jeder dieser Stellen entstehen:
| Fehlerquelle | Beispiel | Wer bemerkt es? |
|---|---|---|
| Methodenname | Attribut falsch geschrieben (…OrderByAbfahrzeit) |
Spring Data beim Start des Testkontexts |
JPQL in @Query |
Spaltenname statt Attributname | Hibernate beim Start des Testkontexts |
| Logik der Abfrage | falscher Vergleich, fehlender Filter, falsche Sortierung | nur ein Test mit passenden Daten |
| Bedingungen der Tabelle | doppelte Buchung trotz Eindeutigkeitsbedingung | nur ein Test, der wirklich schreibt |
| Datenbanksystem | H2 und MariaDB vergleichen oder zählen unterschiedlich | nur ein Test gegen das Zielsystem |
Ein Integrationstest prüft genau dieses Zusammenspiel. Er ist langsamer als ein Komponententest, weil er einen Teil von Spring startet und eine Datenbank anspricht, aber deutlich schneller als ein Test der ganzen Anwendung. Spring Boot bringt dafür Test-Slices mit: Annotationen, die nur die Teile der Anwendung starten, die für eine Schicht nötig sind. Für die Persistenzschicht heißt diese Annotation @DataJpaTest.
Testabhängigkeit ergänzen
Seit Spring Boot 4 stecken die Test-Slices in eigenen Starter-Modulen, passend zu den Startern der Anwendung. Zu spring-boot-starter-data-jpa gehört spring-boot-starter-data-jpa-test; der Spring Initializr trägt ihn ein, sobald man Spring Data JPA auswählt.
dependencies {
// ... wie bisher
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}Die Zeile kommt in build.gradle.kts zu den übrigen Testabhängigkeiten.
Der Starter bringt die Annotation @DataJpaTest und den TestEntityManager mit. JUnit, AssertJ und Mockito kommen wie bisher über den allgemeinen Test-Starter ins Projekt.
Abfragen aus dem Methodennamen
Die einfachste Art, eine Abfrage zu schreiben, ist ein gut gewählter Methodenname. Spring Data zerlegt ihn beim Start in Bausteine und erzeugt daraus die Abfrage:
public interface AngebotRepository extends JpaRepository<Angebot, Long> {
/** Abfrage aus dem Methodennamen: Angebote mit einem Status an einem Datum, nach Abfahrt sortiert. */
List<Angebot> findByStatusAndDatumOrderByAbfahrt(Angebotsstatus status, LocalDate datum);
}Der Name liest sich von links nach rechts: findBy leitet die Bedingung ein, Status und Datum sind Attribute der Entity Angebot, And verknüpft beide, OrderByAbfahrt sortiert aufsteigend nach der Abfahrtszeit. Die Parameter werden in der Reihenfolge der Attribute im Namen eingesetzt. Hibernate schreibt dazu folgendes SQL ins Log:
Hibernate: select a1_0.id,a1_0.abfahrt,a1_0.datum,a1_0.fahrer_id,a1_0.freie_plaetze,a1_0.haltepunkt_id,a1_0.kostenbeitrag_eur,a1_0.richtung,a1_0.status from angebot a1_0 where a1_0.status=? and a1_0.datum=? order by a1_0.abfahrt
Die Bausteine lassen sich kombinieren. Die wichtigsten zeigt die Tabelle; die vollständige Liste steht in der Referenzdokumentation von Spring Data JPA.
| Baustein im Namen | Bedeutung in SQL | Beispiel |
|---|---|---|
findBy…, existsBy…, countBy… |
Liste bzw. Objekt, boolean, Anzahl |
existsByAngebotIdAndMitfahrerId |
And, Or |
and, or |
findByStatusAndDatum |
GreaterThanEqual, LessThan, Between |
>=, <, between |
findByDatumGreaterThanEqual |
IsNull, IsNotNull |
is null, is not null |
findByEinwilligungElternIsNull |
Containing, StartingWith, IgnoreCase |
like '%…%', like '…%', upper(…) |
findByPseudonymIgnoreCase |
AngebotId (verschachtelt) |
Attribut eines verbundenen Objekts | angebot.id → Spalte angebot_id |
OrderBy…Asc, OrderBy…Desc |
order by … asc/desc |
OrderByDatumAscAbfahrtAsc |
Parameter Limit |
fetch first ? rows only (bzw. limit) |
Limit.of(5) |
Damit lassen sich die Abfragen schreiben, die der Service in Artikel 08 und der Flurmonitor in Artikel 09 brauchen werden. Das BuchungRepository bekommt zwei Methoden: eine Liste der Buchungen eines Angebots und eine Prüfung für Fachregel 5 (höchstens eine Buchung je Person und Angebot):
public interface BuchungRepository extends JpaRepository<Buchung, Long> {
/** Alle Buchungen eines Angebots in der Reihenfolge der Anfragen. */
List<Buchung> findByAngebotIdOrderByAngefragtAm(Long angebotId);
/** Fachregel 5: Hat diese Person für dieses Angebot schon eine Buchung? */
boolean existsByAngebotIdAndMitfahrerId(Long angebotId, Long mitfahrerId);
}AngebotId gibt es in der Klasse Buchung nicht als Attribut. Spring Data findet aber das Attribut angebot und darin dessen id, und weil diese ID schon in der Spalte angebot_id steht, braucht die Abfrage keinen Join. Für den Flurmonitor reicht ebenfalls ein Methodenname, wenn auch ein langer: Er zeigt offene Angebote ab einem Tag, zeitlich sortiert und auf eine Anzahl begrenzt.
List<Angebot> findByStatusAndDatumGreaterThanEqualOrderByDatumAscAbfahrtAsc(Angebotsstatus status,
LocalDate ab, Limit limit);Der Parameter vom Typ Limit begrenzt die Zahl der Treffer; aufgerufen wird die Methode zum Beispiel mit Limit.of(5). H2 übersetzt das in fetch first ? rows only, MariaDB in limit ?.
Was passiert bei einem Tippfehler? Heißt die erste Methode versehentlich findByStatusAndDatumOrderByAbfahrzeit, kompiliert das Projekt problemlos, denn für den Compiler ist es nur ein Methodenname. Erst beim Start bricht Spring ab (gekürzt):
Caused by: org.springframework.data.repository.query.QueryCreationException: Cannot create query for method [AngebotRepository.findByStatusAndDatumOrderByAbfahrzeit(de.dbh.mitfahrlabor.domain.Angebotsstatus,java.time.LocalDate)]; No property 'abfahrzeit' found for type 'Angebot'
Das ist der erste Grund für Repository-Tests: Schon ein einziger Test, der den Testkontext startet, findet solche Fehler vor der Auslieferung und nicht erst beim Start auf dem Server.
Eigene Abfragen mit @Query
GET /angebote hat laut API-Vertrag aus Artikel 01 drei Filter: datum, richtung und haltepunkt. Alle drei sind freiwillig. Mit Methodennamen bräuchte man für jede Kombination eine eigene Methode, bei drei Filtern also acht. Einfacher ist eine selbst geschriebene Abfrage. Die Annotation @Query nimmt sie in JPQL (Jakarta Persistence Query Language) entgegen:
/** GET /angebote: offene Angebote; jeder Filter, der null ist, wird ignoriert. */
@Query("""
select a from Angebot a
where a.status = de.dbh.mitfahrlabor.domain.Angebotsstatus.OFFEN
and (:datum is null or a.datum = :datum)
and (:richtung is null or a.richtung = :richtung)
and (:haltepunktId is null or a.haltepunkt.id = :haltepunktId)
order by a.datum, a.abfahrt
""")
List<Angebot> sucheOffene(@Param("datum") LocalDate datum, @Param("richtung") Fahrtrichtung richtung,
@Param("haltepunktId") Long haltepunktId);JPQL sieht aus wie SQL, arbeitet aber mit Entities und Attributen statt mit Tabellen und Spalten: Angebot ist die Klasse, a.haltepunkt.id das Attribut id des verbundenen Haltepunkts. Der Textblock (""") hält die Abfrage lesbar über mehrere Zeilen. Die benannten Parameter :datum, :richtung und :haltepunktId werden über @Param mit den Methodenparametern verbunden. Der Trick für freiwillige Filter steckt in (:datum is null or a.datum = :datum): Ist der Parameter null, ist die Bedingung immer erfüllt und der Filter wirkungslos. Für den Wert einer Aufzählung braucht JPQL den vollständigen Namen der Konstanten.
Hibernate übersetzt das in SQL, der Status landet dabei als fester Text in der Abfrage:
Hibernate: select a1_0.id,a1_0.abfahrt,a1_0.datum,a1_0.fahrer_id,a1_0.freie_plaetze,a1_0.haltepunkt_id,a1_0.kostenbeitrag_eur,a1_0.richtung,a1_0.status from angebot a1_0 where a1_0.status='OFFEN' and (? is null or a1_0.datum=?) and (? is null or a1_0.richtung=?) and (? is null or a1_0.haltepunkt_id=?) order by a1_0.datum,a1_0.abfahrt
Ein typischer Fehler ist, in JPQL aus Gewohnheit den Spaltennamen zu schreiben: a.haltepunkt_id statt a.haltepunkt.id. Auch das zeigt sich beim Start, weil Spring Data jede @Query beim Erzeugen des Repositorys prüfen lässt (gekürzt):
Caused by: org.hibernate.query.sqm.PathElementException: Could not resolve attribute 'haltepunkt_id' of 'de.dbh.mitfahrlabor.domain.Angebot'
Wann welcher Weg? Methodennamen sind kurz und typsicher, solange die Bedingung einfach bleibt. Sobald der Name länger wird als die Abfrage selbst, freiwillige Filter dazukommen oder Joins und Funktionen nötig sind, ist @Query die bessere Wahl. Mit nativeQuery = true nähme @Query sogar echtes SQL an; dann hängt die Abfrage aber vom Datenbanksystem ab, und genau das soll hier vermieden werden.
Der erste Test mit @DataJpaTest
@DataJpaTest startet einen schlanken Spring-Kontext: Entities, Repositories, Hibernate und eine Datenbank, aber keine Controller, keinen Webserver und keine eigenen Services. Zusätzlich gilt für jeden Test:
- Er läuft in einer Transaktion, die am Ende zurückgerollt wird (Rollback). Was ein Test in die Datenbank schreibt, sieht der nächste Test nicht mehr.
- Die Datenquelle wird durch eine eingebettete Datenbank ersetzt, hier H2 mit zufälligem Namen.
- Der
TestEntityManagersteht bereit, um Testdaten anzulegen. Er ist eine Hilfsklasse für Tests rund um denEntityManagervon JPA.
Den Ablauf eines Tests zeigt das Sequenzdiagramm. Vor jedem Test legt die Methode mit @BeforeEach die Stammdaten an; der Test selbst legt passende und unpassende Angebote an, ruft die Abfrage auf und vergleicht das Ergebnis:
Der Test liegt im Paket repository unter src/test/java (Importe gekürzt):
/** Integrationstest: Abfragemethoden des AngebotRepository gegen eine echte Datenbank. */
@DataJpaTest(properties = "spring.sql.init.mode=never")
class AngebotRepositoryTest {
private static final LocalDate MONTAG = LocalDate.of(2026, 11, 16);
private static final LocalDate DIENSTAG = MONTAG.plusDays(1);
@Autowired
private TestEntityManager entityManager;
@Autowired
private AngebotRepository angebotRepository;
private Nutzer fahrerin;
private Haltepunkt herford;
private Haltepunkt halle;
@BeforeEach
void legeStammdatenAn() {
fahrerin = entityManager.persist(new Nutzer("Testfahrerin", "Tara", "Test",
LocalDate.of(2004, 5, 1), null, true));
herford = entityManager.persist(new Haltepunkt("Bahnhof", "Herford", new BigDecimal("17.9")));
halle = entityManager.persist(new Haltepunkt("Marktplatz", "Halle (Westf.)", new BigDecimal("15.2")));
}
private Angebot angebot(Haltepunkt haltepunkt, Fahrtrichtung richtung, LocalDate datum, String abfahrt) {
return entityManager.persist(new Angebot(fahrerin, haltepunkt, richtung, datum,
LocalTime.parse(abfahrt), 3, new BigDecimal("1.50")));
}
@Test
void sucheFiltertNachDatumRichtungUndHaltepunkt() {
Angebot passend = angebot(herford, Fahrtrichtung.ZUR_SCHULE, MONTAG, "07:00");
angebot(herford, Fahrtrichtung.ZUR_SCHULE, DIENSTAG, "07:00");
angebot(herford, Fahrtrichtung.NACH_HAUSE, MONTAG, "14:20");
angebot(halle, Fahrtrichtung.ZUR_SCHULE, MONTAG, "07:00");
List<Angebot> gefunden = angebotRepository.sucheOffene(MONTAG, Fahrtrichtung.ZUR_SCHULE, herford.getId());
assertThat(gefunden).containsExactly(passend);
}
// weitere Tests: Datum mit Sortierung, ohne Filter, leeres Ergebnis, Flurmonitor mit Limit
}Der Aufbau folgt dem Muster Arrange, Act, Assert aus Artikel 04. Beim Arrange kommt es auf die Auswahl der Daten an: Neben dem einen passenden Angebot gibt es drei, die jeweils in genau einem Filter abweichen (anderer Tag, andere Richtung, anderer Haltepunkt). Fiele einer der drei Filter in der Abfrage weg, enthielte das Ergebnis ein Angebot zu viel und der Test schlüge fehl. containsExactly prüft dabei auch die Reihenfolge. Weil die Entities equals nicht überschreiben, vergleicht AssertJ die Objekte über ihre Identität; das klappt, weil Hibernate innerhalb einer Transaktion für jede Zeile genau ein Objekt verwaltet.
Insgesamt prüft die Klasse fünf Fälle: die Sortierung nach Abfahrt, die Suche ganz ohne Filter, die Suche mit allen drei Filtern, ein leeres Ergebnis und den Flurmonitor, der am Stichtag beginnt und nach zwei Treffern aufhört. Die Testklasse BuchungRepositoryTest kommt mit drei Tests dazu.
Eigene Testdaten statt data.sql
Auffällig ist die Angabe properties = "spring.sql.init.mode=never". Ohne sie führt Spring Boot auch beim Test die data.sql aus Artikel 06 aus, und die Szenariodaten mischen sich unter die Testdaten. Der Test ohne Filter schlägt dann fehl (gekürzt):
AngebotRepositoryTest > sucheOhneFilterLiefertAlleOffenenAngebote() FAILED
Expecting actual:
[de.dbh.mitfahrlabor.domain.Angebot@33c5fd44,
de.dbh.mitfahrlabor.domain.Angebot@1aa21c8a,
...
to contain exactly (and in same order):
[de.dbh.mitfahrlabor.domain.Angebot@6bbb60b1,
de.dbh.mitfahrlabor.domain.Angebot@774bbc87]
but some elements were not expected:
Die vier zusätzlichen Treffer sind die offenen Angebote aus data.sql. Man könnte die Erwartung an die Szenariodaten anpassen, aber dann hinge jeder Test davon ab, dass niemand die data.sql ändert. Jeder Test legt deshalb die Daten selbst an, die er braucht. So steht im Test, was er prüft.
Eine zweite Falle betrifft die IDs. Weil alle Tests eines Laufs dieselbe Testdatenbank nutzen und der Rollback die Zähler für neue IDs nicht zurücksetzt, bekommt der erste Nutzer eines Tests nicht unbedingt die ID 1. In einem Lauf hatte die Buchung im letzten Test den Fahrgast mit der ID 13 und das Angebot 17. Tests fragen deshalb immer mit getId() des gespeicherten Objekts und nie mit einer festen Zahl; die Übungen zeigen, was sonst passiert.
Eindeutigkeit prüfen: Ausnahmen aus der Datenbank
In Artikel 06 hat die Entity Buchung eine Eindeutigkeitsbedingung über angebot_id und mitfahrer_id bekommen. Ob sie wirklich greift, zeigt nur ein Test, der zweimal speichert:
@Test
void zweiteBuchungDerselbenPersonVerletztEindeutigkeit() {
buchungRepository.saveAndFlush(new Buchung(angebot, mitfahrer, ABEND));
assertThatThrownBy(() -> buchungRepository.saveAndFlush(new Buchung(angebot, mitfahrer, ABEND.plusMinutes(5))))
.isInstanceOf(DataIntegrityViolationException.class);
}saveAndFlush speichert und schickt alle anstehenden Änderungen sofort an die Datenbank. Normalerweise sammelt Hibernate Änderungen und schreibt sie erst beim Abschluss der Transaktion. Weil die Transaktion im Test aber nie abgeschlossen, sondern zurückgerollt wird, käme ein Fehler beim Schreiben sonst womöglich nie zum Vorschein. Bei den Entities des Mitfahrlabors schreibt Hibernate neue Zeilen wegen GenerationType.IDENTITY zwar ohnehin sofort, denn die ID kennt erst die Datenbank. Mit saveAndFlush hängt der Test aber nicht an diesem Detail.
Die Fehlermeldung der Datenbank übersetzt Spring in eine DataIntegrityViolationException, unabhängig davon, welche Datenbank dahintersteht. Im Log sieht man die Unterschiede:
WARN ... HHH000247: ErrorCode: 23505, SQLState: 23505
WARN ... Unique index or primary key violation: "PUBLIC.CONSTRAINT_38 INDEX PUBLIC.CONSTRAINT_INDEX_3 ON PUBLIC.BUCHUNG(ANGEBOT_ID NULLS FIRST, MITFAHRER_ID NULLS FIRST) VALUES ( /* key:4 */ CAST(17 AS BIGINT), CAST(13 AS BIGINT))"; SQL statement:
So meldet sich H2; MariaDB schreibt Duplicate entry '17-13' for key 'UKsf45gsfou4e79okxriq94guyi' mit dem Fehlercode 1062. Der Test bleibt derselbe. Fachregel 5 ist damit doppelt abgesichert: Der Service wird in Artikel 08 vorher mit existsByAngebotIdAndMitfahrerId fragen und eine verständliche Fehlermeldung erzeugen, die Datenbank verhindert den Fall auch dann, wenn zwei Anfragen gleichzeitig eintreffen.
Tests ausführen
Die Repository-Tests laufen mit allen anderen Tests:
.\gradlew.bat testWer nur die Repository-Tests starten möchte, filtert nach dem Klassennamen:
.\gradlew.bat test --tests "*RepositoryTest"Nach Artikel 07 sind es 49 grüne Tests: der Smoke-Test, 40 Komponententests aus Artikel 04 und acht neue Integrationstests. Der Lauf dauert ein paar Sekunden länger, weil Spring den Testkontext startet. Beide Repository-Testklassen haben dieselbe Konfiguration, deshalb startet Spring den Kontext nur einmal und verwendet ihn für die zweite Klasse wieder.
Im JaCoCo-Bericht steigt die Abdeckung von 44 auf 58 Prozent der Anweisungen (330 von 571). Die Repositories selbst tauchen dort gar nicht auf: Interfaces ohne Methodenrumpf enthalten keine Anweisungen, die JaCoCo zählen könnte, und die erzeugten Klassen gehören nicht zum Projekt. Die Abdeckung der Abfragen muss man deshalb an den Testfällen selbst ablesen, nicht an der Prozentzahl.
Dieselben Tests mit MariaDB
H2 ist für die Tests bequem, im Betrieb läuft aber MariaDB. Die beiden Systeme verhalten sich nicht in allem gleich: Artikel 06 hat gezeigt, dass H2 von Hand vergebene IDs anders behandelt, und in den Übungen findest Du einen Unterschied beim Vergleichen von Texten. Deshalb sollen die Repository-Tests auch gegen MariaDB laufen.
Ohne weitere Angabe laufen alle Tests mit H2. @DataJpaTest ersetzt die Datenquelle aus application.properties sogar durch eine eigene H2-Datenbank mit zufälligem Namen; im Log steht dann:
Replacing 'dataSource' DataSource bean with embedded version
Starting embedded database: url='jdbc:h2:mem:c24795ec-4dbc-4494-89d3-3ae4c5bb15f6;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false', username='sa'
Das ist schnell und braucht nichts außer dem Projekt. Für den Unterricht und für den täglichen Testlauf ist das der Standard.
Mit MariaDB laufen alle 49 Tests grün. Hibernate legt die Tabellen nun im Dialekt von MariaDB an (id bigint not null auto_increment, engine=InnoDB). Weil @DataJpaTest die Datenbank nicht mehr austauscht, sind am Ende eines Laufs die Tabellen der laufenden MariaDB neu angelegt und leer; zum Ausprobieren der Anwendung startet man sie danach wieder mit dem Profil mariadb und bekommt frische Daten aus data.sql.
Eine Alternative zum selbst gestarteten Container ist Testcontainers: Der Test startet dann selbst einen MariaDB-Container und meldet ihn über @ServiceConnection bei Spring an. Das ist elegant, setzt aber auf jedem Rechner und im Pipeline-Runner ein laufendes Docker voraus. Diese Reihe bleibt deshalb bei H2 als Standard und MariaDB über Profil und Pipeline.
Die Pipeline bekommt eine Datenbank
In der Pipeline aus Artikel 05 läuft der Job test mit H2. Ein zweiter Job test-mariadb führt dieselben Tests gegen MariaDB aus. Die Datenbank stellt GitLab als Dienst (services) bereit: einen Container, der neben dem Job läuft und unter einem Hostnamen erreichbar ist.
# dieselben Tests noch einmal gegen MariaDB (wie im Betrieb) statt H2
test-mariadb:
stage: test
services:
- name: mariadb:12.3
alias: mariadb
variables:
MARIADB_DATABASE: mitfahrlabor
MARIADB_USER: mitfahrlabor
MARIADB_PASSWORD: mitfahrlabor
MARIADB_ROOT_PASSWORD: geheim
SPRING_PROFILES_ACTIVE: mariadb
SPRING_DATASOURCE_URL: jdbc:mariadb://mariadb:3306/mitfahrlabor
script:
- ./gradlew test
artifacts:
when: always
reports:
junit: build/test-results/test/TEST-*.xmlDie Variablen des Jobs gelten auch für den Dienst; mit MARIADB_DATABASE, MARIADB_USER und MARIADB_PASSWORD legt das MariaDB-Image beim Start Datenbank und Benutzer an, genau wie die compose.yaml aus Artikel 06. SPRING_PROFILES_ACTIVE schaltet das Profil ein, und SPRING_DATASOURCE_URL überschreibt die Adresse aus application-mariadb.properties: Im Job heißt der Rechner der Datenbank nicht localhost, sondern so wie der alias des Dienstes. Spring Boot liest Umgebungsvariablen dieser Form automatisch als Einstellungen. Die Maven-Variante in ci/maven.gitlab-ci.yml unterscheidet sich nur im Skript (./mvnw $MAVEN_CLI_OPTS test) und im Pfad der Testberichte.
Beide Jobs gehören zur Stufe test und laufen deshalb nebeneinander. Die Stufe quality beginnt erst, wenn beide grün sind. Den Abdeckungsbericht erzeugt weiterhin nur der Job test, damit im Merge Request nicht zwei Berichte miteinander konkurrieren.
GET /angebote endlich beantworten
Mit sucheOffene kann der AngebotController die Suche aus dem Vertrag umsetzen. Die Methode überschreibt sucheAngebote aus der erzeugten Schnittstelle AngeboteApi, übersetzt die Richtung des Vertrags in die Fahrtrichtung der Entity und wandelt die Treffer mit dem bekannten zuApi um:
@Override
public ResponseEntity<List<Angebot>> sucheAngebote(LocalDate datum, Richtung richtung, Long haltepunkt) {
Fahrtrichtung fahrtrichtung = richtung == null ? null : Fahrtrichtung.valueOf(richtung.name());
List<Angebot> angebote = angebotRepository.sucheOffene(datum, fahrtrichtung, haltepunkt).stream()
.map(AngebotController::zuApi)
.toList();
return ResponseEntity.ok(angebote);
}Fehlt ein Filter in der Anfrage, kommt er als null an, und die Abfrage ignoriert ihn. Die Datei angebote.http enthält passende Anfragen. Gegen die Daten aus data.sql liefert GET /v1/angebote?datum=2026-10-19&richtung=zur-schule genau ein Angebot:
HTTP/1.1 200
Content-Type: application/json
[{"fahrerId":1,"haltepunktId":1,"richtung":"zur-schule","datum":"2026-10-19","abfahrt":"07:00","freiePlaetze":2,"kostenbeitragEur":1.8,"id":1,"status":"offen"}]
Ohne Filter erscheinen die vier offenen Angebote 1, 2, 4 und 5; das volle Angebot 3, das durchgeführte 6 und das abgesagte 7 fehlen. Ein Tag ohne Fahrten liefert 200 mit einer leeren Liste [], wie es der Vertrag vorsieht. Ein Datum im falschen Format (?datum=19.10.2026) oder eine unbekannte Richtung beantwortet Spring schon vor dem Controller mit 400 Bad Request. Den Controller selbst testet bisher niemand; das holt Artikel 10 mit Tests der Web-Schicht nach.
Nächster Artikel
Noch ruft der Controller die Repositories direkt auf, und die Fachregeln aus Artikel 04 prüft beim Speichern niemand. Im nächsten Artikel kommt deshalb eine Service-Schicht dazwischen: Sie bekommt Repositories und Regeln per Konstruktorinjektion, setzt Buchungen in Transaktionen um und wird mit Mockito getestet, ganz ohne Datenbank. Die Pipeline bekommt dazu eine Mindestabdeckung, unter der der Build scheitert.
Leitfragen
- Was prüft ein Integrationstest der Persistenzschicht, das ein Komponententest nicht prüfen kann?
Er prüft das Zusammenspiel von eigenem Code, Spring Data, Hibernate und Datenbank: ob Spring Data aus dem Methodennamen eine Abfrage erzeugen kann, ob die JPQL-Abfrage gültig ist, ob sie mit echten Daten das Richtige findet und ob Bedingungen der Tabelle greifen. Ein Komponententest ruft nur Java-Code ohne Spring und Datenbank auf; eine Abfragemethode hat aber keinen eigenen Code. siehe Abschnitt „Was ein Integrationstest hier prüft“
- Welche Abfrage erzeugt Spring Data aus
findByStatusAndDatumOrderByAbfahrt(status, datum)?
Eine Abfrage auf die Tabelle der Entity mit der Bedingung status = ? and datum = ?, sortiert aufsteigend nach abfahrt. Die Parameter werden in der Reihenfolge der Attribute im Namen eingesetzt. Ein falsch geschriebenes Attribut bemerkt erst Spring Data beim Start (No property … found for type …). siehe Abschnitt „Abfragen aus dem Methodennamen“
- Wann ist
@Querydie bessere Wahl als ein Methodenname, und worin unterscheidet sich JPQL von SQL?
Wenn der Methodenname zu lang würde, Filter freiwillig sind (Kombinationen mit :param is null or …) oder Joins und Funktionen gebraucht werden. JPQL verwendet Klassen- und Attributnamen der Entities (Angebot, a.haltepunkt.id) statt Tabellen- und Spaltennamen; Hibernate übersetzt sie in das SQL der jeweiligen Datenbank. siehe Abschnitt „Eigene Abfragen mit @Query“
- Was startet
@DataJpaTest, und was passiert mit den Daten eines Tests nach seinem Ende?
Einen schlanken Spring-Kontext mit Entities, Repositories, Hibernate, einer (standardmäßig eingebetteten) Datenbank und dem TestEntityManager, aber ohne Controller und Webserver. Jeder Test läuft in einer Transaktion, die am Ende zurückgerollt wird; seine Daten sind danach wieder verschwunden. Die Zähler für neue IDs laufen dagegen weiter. siehe Abschnitt „Der erste Test mit @DataJpaTest“
- Warum legen die Tests ihre Daten selbst an, statt die Szenariodaten aus
data.sqlzu nutzen, und warum fragen sie nicht nach festen IDs?
Mit data.sql hinge das erwartete Ergebnis von Daten ab, die außerhalb des Tests stehen und sich ändern können; im Test wäre nicht zu sehen, was er prüft. Feste IDs funktionieren nicht, weil alle Tests dieselbe Datenbank nutzen und der Rollback die ID-Zähler nicht zurücksetzt. Deshalb spring.sql.init.mode=never und getId() der gespeicherten Objekte. siehe Abschnitt „Eigene Testdaten statt data.sql“
- Wie prüfst Du, dass eine Eindeutigkeitsbedingung der Tabelle greift, und warum ist dabei
saveAndFlushsinnvoll?
Zweimal einen Datensatz mit denselben Werten speichern und mit assertThatThrownBy eine DataIntegrityViolationException erwarten. saveAndFlush schickt die Änderung sofort an die Datenbank; ohne Flush könnte Hibernate das Schreiben bis zum Ende der Transaktion aufschieben, die im Test nur zurückgerollt wird, sodass der Fehler nie auftritt. siehe Abschnitt „Eindeutigkeit prüfen: Ausnahmen aus der Datenbank“
- Wie laufen dieselben Tests gegen MariaDB, lokal und in der GitLab-Pipeline?
Lokal: MariaDB mit docker compose up -d starten, im Profil mariadb zusätzlich spring.test.database.replace=none setzen und die Tests mit der Umgebungsvariablen SPRING_PROFILES_ACTIVE=mariadb ausführen. In der Pipeline startet ein Job test-mariadb MariaDB als Dienst (services mit alias: mariadb) und setzt Profil und SPRING_DATASOURCE_URL über Variablen. siehe Abschnitt „Dieselben Tests mit MariaDB“ und Abschnitt „Die Pipeline bekommt eine Datenbank“
Links und weitere Informationen
zum Weiterlernen: Übungsaufgaben zu Abfragen und Repository-Integrationstests
als Grundlage: Teststrategie, Unit-Tests mit JUnit, SQL: Selektion und Projektion, SQL: Sortieren und Begrenzen
als Nachschlagewerk: Spring Data JPA – Abfragemethoden, Spring Boot – Testen von Spring-Boot-Anwendungen, Hibernate ORM – Dokumentation, GitLab – Dienste in CI/CD, MariaDB-Image auf Docker Hub, Testcontainers
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 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: “Abfragen schreiben und testen: Repository-Integrationstests mit @DataJpaTest” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/spring-boot-repository-integrationstest veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/java-springboot/Backend. Erstellt mit KI-Assistenz; von mir umfassend redaktionell überarbeitet, geprüft und freigegeben. Stand: 11.11.2026.
[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]
