Testing
Die CI/CD-Pipeline führt bei jedem Push und jedem Pull Request Tests aus. Diese Seite beschreibt, was dort eigentlich laufen soll: wie ihr Quarkus-Services und das React-Frontend absichert, und welche Werkzeuge das Projekt dafür vorsieht.
Der Nutzen ist im Monorepo größer als in einem Einzelprojekt. Mehrere Teams arbeiten auf derselben Codebasis, und ein Test ist die einzige Aussage über euren Code, die auch dann noch gilt, wenn jemand anderes ihn anfasst.
Die Testpyramide
Nicht jeder Test ist gleich viel wert. Die Faustregel: viele schnelle Tests unten, wenige langsame oben.
| Ebene | Was getestet wird | Werkzeug | Laufzeit |
|---|---|---|---|
| Unit | Eine Klasse, eine Methode, reine Rechenlogik | JUnit 5, plain Java | Millisekunden |
| Integration | Ein Service mit echtem HTTP, echter Datenbank, echtem Token | @QuarkusTest, RestAssured | Sekunden |
| E2E | Klickpfad durch die ganze Anwendung | Playwright oder manuell | Minuten |
Für euer Projekt gilt: Unit- und Integrationstests sind Pflicht, E2E ist Kür. Automatisierte E2E-Tests über mehrere Services, Keycloak und die Process Engine hinweg sind aufwendig zu bauen und brechen ständig. Der Aufwand lohnt sich im Semesterprojekt selten.
Backend: Quarkus
Was schon da ist
Ein über code.quarkus.io erzeugtes Projekt bringt die Testabhängigkeiten bereits mit:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
Ihr müsst also nichts installieren, sondern nur Tests schreiben. Die vollständige Referenz steht im Quarkus Testing Guide.
Unit-Test: reine Logik
Alles, was ohne Datenbank und ohne HTTP auskommt, wird als normaler JUnit-Test geschrieben. Kein @QuarkusTest, weil das die ganze Anwendung hochfährt und den Test unnötig verlangsamt.
class RabattRechnerTest {
@Test
void gewaehrtKeinenRabattUnterHundertEuro() {
assertEquals(new BigDecimal("99.00"),
RabattRechner.berechne(new BigDecimal("99.00")));
}
@Test
void gewaehrtZehnProzentAbHundertEuro() {
assertEquals(new BigDecimal("90.00"),
RabattRechner.berechne(new BigDecimal("100.00")));
}
}
Integrationstest mit @QuarkusTest
@QuarkusTest startet die Anwendung und schickt echte HTTP-Requests dagegen. Die Assertions schreibt ihr mit RestAssured, das in einem lesbaren given().when().then()-Stil funktioniert:
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;
@QuarkusTest
class RechnungResourceTest {
@Test
void liefertLeereListeWennKeineRechnungenExistieren() {
given()
.when().get("/rechnungen")
.then()
.statusCode(200)
.body("size()", is(0));
}
@Test
void legtRechnungAnUndGibtSieZurueck() {
given()
.contentType("application/json")
.body("""
{"kunde": "THI", "betrag": 120.00}
""")
.when().post("/rechnungen")
.then()
.statusCode(201)
.body("kunde", is("THI"));
}
}
Dev Services: Datenbank und Keycloak ohne Konfiguration
Ihr braucht weder eine Testdatenbank noch eine Keycloak-Instanz für Tests. Dev Services startet beides automatisch als Container, sobald die zugehörige URL nicht konfiguriert ist, und benutzt dafür intern Testcontainers. Voraussetzung ist nur, dass in der application.properties keine Datenbank- und keine Keycloak-URL steht. Genau deshalb bleibt sie im Projekt minimal, und die echten Werte kommen in Produktion über Umgebungsvariablen. Was Dev Services sonst noch für euch erledigt, steht auf der Seite Lokal entwickeln.
Für Tests kommt eine Zeile dazu:
# Im Test das Schema jedes Mal frisch aufbauen
%test.quarkus.hibernate-orm.database.generation=drop-and-create
Dev Services braucht eine laufende Container-Runtime. Ohne Docker oder Podman scheitern die Tests mit einer Fehlermeldung über einen nicht erreichbaren Docker-Daemon. Die Einrichtung beschreibt die Setup-Seite.
drop-and-create nur im TestJeder Testlauf startet damit mit leerem Schema. Tests, die auf Datenresten des vorherigen Laufs basieren, sind die häufigste Ursache für Tests, die mal grün und mal rot sind.
Geschützte Endpunkte testen
Eure Endpunkte sind über OIDC abgesichert und verlangen ein gültiges Bearer-Token. Für Tests braucht ihr deshalb kein echtes Keycloak-Login, sondern mockt die Identität mit @TestSecurity:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-test-security</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-test-security-oidc</artifactId>
<scope>test</scope>
</dependency>
import io.quarkus.test.security.TestSecurity;
import io.quarkus.test.security.oidc.Claim;
import io.quarkus.test.security.oidc.OidcSecurity;
@QuarkusTest
class RechnungResourceSecurityTest {
@Test
void weistAnfrageOhneTokenAb() {
given()
.when().get("/rechnungen")
.then().statusCode(401);
}
@Test
@TestSecurity(user = "studi", roles = "leser")
void erlaubtLesenFuerRolleLeser() {
given()
.when().get("/rechnungen")
.then().statusCode(200);
}
@Test
@TestSecurity(user = "studi", roles = "leser")
void verbietetSchreibenFuerRolleLeser() {
given()
.contentType("application/json").body("{}")
.when().post("/rechnungen")
.then().statusCode(403);
}
@Test
@TestSecurity(user = "chef", roles = "admin")
@OidcSecurity(claims = @Claim(key = "email", value = "chef@thi.de"))
void stelltClaimsAusDemTokenBereit() {
given()
.when().get("/rechnungen/mein-profil")
.then().statusCode(200).body("email", is("chef@thi.de"));
}
}
Testet dabei beide Richtungen. Ein Test, der nur prüft, dass ein Admin darf, übersieht genau den Fall, der im Betrieb wehtut: dass ein Nutzer ohne Rolle ebenfalls darf.
Wer gegen echte, von Keycloak signierte Tokens testen möchte, kann stattdessen Keycloak Dev Services mit OidcTestClient verwenden. Der Weg samt benötigter Abhängigkeit ist im Guide OIDC Bearer Token Authentication beschrieben. Für den Regelfall reicht @TestSecurity und ist deutlich schneller.
Manuell testen mit Swagger UI
Automatisierte Tests sagen euch, ob etwas kaputt ist. Beim Entwickeln eines neuen Endpunkts wollt ihr aber oft erst einmal ausprobieren, was er zurückgibt. Dafür braucht ihr weder Postman noch handgeschriebene curl-Aufrufe mit kopierten Tokens: Die Extension quarkus-smallrye-openapi erzeugt aus euren Endpunkten automatisch eine OpenAPI-Beschreibung und liefert eine Swagger UI mit, in der ihr jeden Endpunkt direkt im Browser aufrufen könnt.
./mvnw quarkus:add-extension -Dextensions='quarkus-smallrye-openapi'
Im Dev-Modus ist die Oberfläche ohne weitere Konfiguration aktiv:
| Adresse | Inhalt |
|---|---|
http://localhost:8080/q/swagger-ui | Swagger UI zum Ausprobieren der Endpunkte |
http://localhost:8080/q/openapi | Die generierte OpenAPI-Beschreibung als YAML |
Die OpenAPI-Beschreibung ist nebenbei die ehrlichste Schnittstellendokumentation, die ihr bekommen könnt, weil sie aus dem Code selbst erzeugt wird und nicht veralten kann. Wer im Frontend-Team eure API anbinden muss, braucht damit nicht mehr nachzufragen.
Login in den Keycloak Dev Service
Eure Endpunkte sind über OIDC abgesichert, ein Aufruf ohne Token liefert also nur 401. Genau dafür gibt es die Dev UI, die sich ein echtes Token vom Keycloak Dev Service holt und an die Swagger UI weiterreicht. Ihr müsst kein Token von Hand kopieren.
./mvnw quarkus:devstarten. Der Keycloak Dev Service startet automatisch mit, solange in derapplication.propertieswie oben beschrieben keineauth-server-urlgesetzt ist.- Im Browser
http://localhost:8080/q/dev-ui/extensionsöffnen und dort die Karte OpenID Connect auswählen. - Über den Login-Link zu Keycloak wechseln und sich mit einem der vorkonfigurierten Nutzer anmelden.
- Zurück in der Dev UI könnt ihr die Swagger UI aufrufen. Das erworbene Token wird an die Aufrufe angehängt, geschützte Endpunkte antworten jetzt mit
200.
Der Keycloak Dev Service legt dabei zwei Nutzer an, deren Passwort dem Benutzernamen entspricht:
| Nutzer | Passwort | Rollen |
|---|---|---|
alice | alice | admin, user |
bob | bob | user |
Damit lässt sich der Rollenunterschied direkt ausprobieren: Meldet euch als bob an und ruft einen Endpunkt auf, der admin verlangt. Kommt kein 403 zurück, stimmt eure Absicherung nicht.
Eigene Rollen konfiguriert ihr bei Bedarf in der application.properties:
quarkus.oidc.devservices.roles.alice=admin,buchhaltung
quarkus.oidc.devservices.roles.bob=leser
Die Details zu den Dev Services und der Dev UI stehen im Guide Dev Services and Dev UI for OpenID Connect.
Quarkus liefert die Swagger UI standardmäßig nur im Dev-Modus aus. Lasst das so. Eine öffentlich erreichbare Swagger UI legt eure gesamte API-Struktur offen und lädt zum Ausprobieren ein.
Swagger UI ist zum Erkunden und Debuggen da. Was ihr dort erfolgreich ausprobiert habt, gehört anschließend als @QuarkusTest in die Testsuite, sonst merkt niemand, wenn es in zwei Wochen nicht mehr funktioniert.
Testcontainers direkt verwenden
Dev Services deckt PostgreSQL, Keycloak und viele weitere Dienste ab. Braucht ihr einen Container, den Quarkus nicht kennt, steuert ihr Testcontainers direkt an. Die JUnit-5-Integration stellt dafür zwei Annotationen bereit:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
class ImportJobTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:17-alpine");
@Test
void importiertDatensaetze() {
String jdbcUrl = postgres.getJdbcUrl();
// ...
}
}
static ist hier entscheidend: Ein statisches Feld startet den Container einmal für die ganze Testklasse. Ohne static startet er vor jeder Testmethode neu, was die Laufzeit vervielfacht. Das PostgreSQL-Modul und alle weiteren sind in der offiziellen Doku dokumentiert.
Solange Quarkus einen Dienst über Dev Services abdeckt, ist das der einfachere Weg. Handgeschriebene Testcontainers-Setups sind mehr Code und erledigen dieselbe Aufgabe.
Namenskonvention
Maven unterscheidet anhand des Klassennamens, wann ein Test läuft:
| Suffix | Plugin | Läuft bei | Zweck |
|---|---|---|---|
...Test | Surefire | mvn test und mvn verify | Unit- und @QuarkusTest-Tests |
...IT | Failsafe | nur mvn verify | @QuarkusIntegrationTest gegen das gebaute Artefakt |
Mit @QuarkusIntegrationTest lassen sich dieselben Tests gegen das fertig gepackte Artefakt oder das Docker-Image laufen lassen, indem eine IT-Klasse von der Testklasse erbt:
@QuarkusIntegrationTest
class RechnungResourceIT extends RechnungResourceTest {
// erbt alle Tests, führt sie aber gegen das gebaute Artefakt aus
}
Das ist der Test, der Fehler findet, die nur im gebauten Image auftreten. Er ist langsam, deshalb gehört er in mvn verify und nicht in die schnelle Schleife beim Entwickeln.
Continuous Testing
Quarkus kann Tests im Hintergrund laufen lassen, während ihr programmiert. Im Dev-Modus mit mvn quarkus:dev startet ihr das mit r. Nach jedem Speichern laufen nur die Tests neu, die vom geänderten Code betroffen sind.
Frontend: Vitest und Testing Library
Das Frontend nutzt Vite, dazu passt Vitest als Testrunner. Für Komponenten kommt die React Testing Library dazu.
npm install -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom
/// <reference types="vitest/config" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/setupTests.ts',
},
});
import '@testing-library/jest-dom/vitest';
"scripts": {
"test": "vitest",
"test:run": "vitest run"
}
npm test läuft im Watch-Modus beim Entwickeln, npm run test:run einmalig für die CI.
Ein Komponententest
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import RechnungsListe from './RechnungsListe';
it('zeigt eine Meldung, wenn keine Rechnungen vorliegen', () => {
render(<RechnungsListe rechnungen={[]} />);
expect(screen.getByText('Keine Rechnungen vorhanden')).toBeInTheDocument();
});
it('ruft onLoeschen mit der ID der gewaehlten Rechnung auf', async () => {
const onLoeschen = vi.fn();
render(
<RechnungsListe rechnungen={[{ id: 42, kunde: 'THI' }]} onLoeschen={onLoeschen} />
);
await userEvent.click(screen.getByRole('button', { name: 'Rechnung 42 löschen' }));
expect(onLoeschen).toHaveBeenCalledWith(42);
});
Testet, was der Nutzer sieht
Das Leitprinzip der Testing Library: Sucht Elemente so, wie ein Nutzer sie finden würde. getByRole mit dem sichtbaren Namen ist fast immer die richtige Wahl, getByTestId die letzte Ausweichmöglichkeit. Die empfohlene Reihenfolge der Queries steht in der offiziellen Doku.
Der Nebeneffekt ist wertvoll: Ein Test, der über getByRole('button', { name: ... }) sucht, schlägt fehl, wenn der Button keinen zugänglichen Namen hat. Ihr testet damit gleichzeitig die Barrierefreiheit.
Komponenten, die fetch aufrufen, dürfen im Test nicht wirklich ins Netz gehen. Ersetzt den Aufruf mit vi.stubGlobal('fetch', vi.fn()) oder mockt die Modulgrenze mit vi.mock. Tests, die von einem laufenden Backend abhängen, sind in der CI wertlos.
In der CI
Die GitHub Action läuft auf main und auf Pull Requests. Damit ein roter Test den Merge verhindert, müssen die Testschritte vor dem Docker-Build stehen:
- name: Backend-Tests
run: mvn --batch-mode verify
- name: Frontend-Tests
run: npm ci && npm run test:run
Auf GitHub-Runnern ist Docker vorinstalliert, Dev Services und Testcontainers funktionieren dort ohne Zusatzkonfiguration.
Was einen guten Test ausmacht
- Ein Verhalten pro Test. Der Testname beschreibt das erwartete Verhalten, nicht die Methode.
weistAnfrageOhneTokenAbsagt mehr alstestGet. - Arrange, Act, Assert. Erst Ausgangslage herstellen, dann die eine Aktion ausführen, dann prüfen. Drei sichtbare Blöcke.
- Testet Verhalten, nicht Implementierung. Prüft die HTTP-Antwort und den Zustand danach, nicht welche private Methode aufgerufen wurde. Sonst bricht jeder Test beim ersten Refactoring.
- Deterministisch. Keine
Thread.sleep, keine Abhängigkeit von der Ausführungsreihenfolge, keine echten externen Dienste. Ein Test, der sporadisch rot ist, wird ignoriert und ist damit wertlos. - Auch der Fehlerfall gehört getestet. Ungültige Eingaben, fehlende Berechtigung, leere Liste. Dort stecken die Bugs, nicht im Happy Path.
- Jagt keine Prozentzahl. Eine hohe Testabdeckung ist kein Ziel an sich. Testet die Stellen, an denen ein Fehler wirklich wehtut: Berechnungen, Berechtigungen, Zustandsübergänge.
Wenn ihr einen Fehler findet, schreibt zuerst einen Test, der ihn reproduziert und rot ist. Dann repariert ihr den Code, bis er grün wird. So habt ihr den Beweis, dass ihr den richtigen Fehler behoben habt, und eine Absicherung, dass er nicht zurückkommt.