Zum Hauptinhalt springen

AB 05 – Den Webshop testen

Dieses Arbeitsblatt ist ein Zusatz

Der Webshop ist nach Arbeitsblatt 04 vollständig. Hier geht es um die Frage danach: Woher weißt du, dass er das tut, was du glaubst?

Wenn du das Test-Tutorial zum Gästebuch gemacht hast, findest du alles wieder — an einem Gegenstand mit Beziehungen, und das macht es schwieriger. Wenn nicht, ist das kein Hindernis: Jedes Werkzeug wird hier noch einmal eingeführt.

Die Situation

Du hast im Laufe der vier Arbeitsblätter viermal von Hand geprüft, ob ein Löschversuch verweigert wird. Beim fünften Mal wirst du es vergessen.

Und du hast eine Regel eingebaut, die nur in einem einzigen Fall greift: Lieferant löschen, aber nur wenn keine Artikel da sind. Genau solche Regeln fallen bei der nächsten Änderung heraus, ohne dass es jemandem auffällt.

Lernziele

Nach Bearbeitung dieses Arbeitsblatts kannst du:

  • einen Unit-Test für eine Klasse ohne Spring und ohne Datenbank schreiben
  • ein Test-Doppel mit Mockito einsetzen und benennen, was dadurch nicht geprüft wird
  • mit @WebMvcTest die Web-Schicht allein prüfen — Statuscodes, Kopfzeilen, JSON-Felder
  • mit @DataJpaTest gegen die echte Datenbank im Container testen
  • begründen, warum man hier anders testet als im Gästebuch

Die drei Testarten im Überblick — mit den Importpfaden für Spring Boot 4 — stehen im Infoblatt Automatisiert testen.

Aufgabe 1 – Der Mapper, ganz ohne alles

Der ArticleMapper ist reines Java: kein Spring, keine Datenbank, kein HTTP. Ein Test dafür läuft in Millisekunden.

src/test/java/de/szut/webshop/article/ArticleMapperTest.java
package de.szut.webshop.article;

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

import java.math.BigDecimal;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import de.szut.webshop.supplier.Supplier;

@DisplayName("ArticleMapper")
class ArticleMapperTest {

private final ArticleMapper mapper = new ArticleMapper();

private Supplier sampleSupplier() {
Supplier supplier = new Supplier();
supplier.setId(7L);
supplier.setName("Testlauf GmbH");
return supplier;
}

@Test
@DisplayName("nimmt vom Lieferanten nur Kennung und Name mit")
void toDtoFlattensSupplier() {
Article article = new Article();
article.setId(1L);
article.setDesignation("Schraube M6");
article.setPrice(new BigDecimal("0.45"));
article.setSupplier(sampleSupplier());

ArticleDto dto = mapper.toDto(article);

assertThat(dto.id()).isEqualTo(1L);
assertThat(dto.designation()).isEqualTo("Schraube M6");
assertThat(dto.price()).isEqualByComparingTo("0.45");
assertThat(dto.supplierId()).isEqualTo(7L);
assertThat(dto.supplierName()).isEqualTo("Testlauf GmbH");
}
}
Die statischen Importe

assertThat steht ohne Klassennamen im Code. Möglich macht das ein statischer Import — die erste Zeile im Block oben:

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

Alle Prüfmethoden in diesem Arbeitsblatt kommen so herein: assertThat, assertThatThrownBy, mock, when, verify, never, post, delete, status, jsonPath. Sie stehen deshalb in den Importblöcken mit dabei.

Die drei Abschnitte sind immer dieselben und heißen Arrange–Act–Assert: Zuerst wird die Ausgangslage gebaut, dann passiert genau eine Sache, dann wird geprüft.

isEqualByComparingTo statt isEqualTo beim Preis

BigDecimal unterscheidet 0.45 und 0.450 — beide sind wertgleich, aber nicht equals. isEqualByComparingTo vergleicht den Wert, nicht die Schreibweise. Ein Test, der an dieser Stelle rot wird, prüft die falsche Sache.

Aufgabe 2 – Die Regel prüfen, ohne Datenbank

Die Regel „ein Lieferant mit Artikeln wird nicht gelöscht" steht im SupplierService. Der braucht zwei Repositories — aber keine echten.

src/test/java/de/szut/webshop/supplier/SupplierServiceTest.java
package de.szut.webshop.supplier;

import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.util.Optional;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import de.szut.webshop.article.ArticleRepository;

@DisplayName("SupplierService")
class SupplierServiceTest {

private SupplierRepository supplierRepository;
private ArticleRepository articleRepository;
private SupplierService service;

@BeforeEach
void setUp() {
supplierRepository = mock(SupplierRepository.class);
articleRepository = mock(ArticleRepository.class);
service = new SupplierService(supplierRepository, articleRepository, new SupplierMapper());
}

private Supplier sampleSupplier() {
Supplier supplier = new Supplier();
supplier.setId(1L);
supplier.setName("Testlauf GmbH");
return supplier;
}

@Test
@DisplayName("verweigert das Löschen, solange Artikel da sind")
void deleteRefusesWhenArticlesExist() {
when(supplierRepository.findById(1L)).thenReturn(Optional.of(sampleSupplier()));
when(articleRepository.countBySupplierId(1L)).thenReturn(3L);

assertThatThrownBy(() -> service.deleteById(1L))
.isInstanceOf(SupplierHasArticlesException.class)
.hasMessageContaining("3 Artikel");

verify(supplierRepository, never()).delete(any());
}
}
BausteinBedeutung
mock(X.class)Ein Doppel: sieht aus wie ein SupplierRepository, tut aber nichts
when(…).thenReturn(…)Was das Doppel antworten soll, wenn es gefragt wird
assertThatThrownByErwartet, dass der Code eine Ausnahme wirft
verify(…, never()).delete(…)Prüft, dass eine Methode nicht aufgerufen wurde

Die letzte Zeile ist die eigentliche Prüfung. Dass eine Ausnahme fliegt, ist die halbe Miete — entscheidend ist, dass vorher nichts gelöscht wurde.

Was dieser Test nicht prüft

Er weiß nichts davon, ob countBySupplierId in der echten Datenbank die richtige Zahl liefert. Das Doppel sagt 3, weil du es so eingestellt hast. Diese Lücke schließt erst Aufgabe 4.

Aufgabe 3 – Die Web-Schicht allein

Ob aus einer SupplierHasArticlesException wirklich eine 409 wird, hat bisher nur dein Auge geprüft.

src/test/java/de/szut/webshop/supplier/SupplierControllerTest.java
package de.szut.webshop.supplier;

import static org.mockito.Mockito.doThrow;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(SupplierController.class)
@DisplayName("SupplierController (Web-Schicht)")
class SupplierControllerTest {

@Autowired
private MockMvc mockMvc;

@MockitoBean
private SupplierService service;

@Test
@DisplayName("DELETE eines Lieferanten mit Artikeln liefert 409")
void deleteWithArticlesReturns409() throws Exception {
doThrow(new SupplierHasArticlesException(1L, 3))
.when(service).deleteById(1L);

mockMvc.perform(delete("/api/v1/suppliers/1"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.detail").value(
org.hamcrest.Matchers.containsString("3 Artikel")));
}

@Test
@DisplayName("POST ohne Namen liefert 400 und benennt das Feld")
void postWithoutNameReturns400() throws Exception {
mockMvc.perform(post("/api/v1/suppliers")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"name": " ",
"contact": {
"street": "Hafenstr. 12",
"postcode": "28217",
"city": "Bremen"
}
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors.name").exists());
}
}

Ein Slice-Test startet von Spring nur einen Ausschnitt — hier die Web-Schicht. Es läuft kein Server, es ist kein Port offen. MockMvc reicht die Anfrage direkt an die Spring-Web-Schicht weiter.

AnnotationWirkung
@WebMvcTest(SupplierController.class)Lädt die Web-Schicht — und von deinen Controllern nur diesen
@MockitoBeanErsetzt eine Bean im Spring-Kontext durch ein Doppel
Warum der Ausnahmebehandler von selbst dabei ist

Auffällig: Der Test prüft eine 409 und eine 400 — beide entstehen erst durch deinen ApiExceptionHandler. Trotzdem steht nirgends, dass er geladen werden soll.

@WebMvcTest lädt nämlich nicht nur den genannten Controller, sondern alles, was zur Web-Schicht gehört: Controller, @RestControllerAdvice, Konverter, Filter. Nicht geladen werden Services und Repositories — die musst du selbst ersetzen, dafür ist @MockitoBean da.

Probier es aus: Kommentiere im ApiExceptionHandler die Zeile @RestControllerAdvice kurz aus und lass den Test erneut laufen. Aus der 409 wird eine 500. Genau daran siehst du, dass die Klasse vorher mitgeladen war.

Die Import-Pfade haben sich mit Spring Boot 4 geändert

Findest du im Netz eine Anleitung, steht dort fast immer org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest. Das ist der Pfad aus Spring Boot 3 und existiert in Boot 4 nicht mehr. Richtig sind:

import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

Ebenfalls verschwunden ist @MockBean; es heißt jetzt @MockitoBean.

Aufgabe 4 – Gegen die echte Datenbank

Bleibt die Lücke aus Aufgabe 2: Stimmt in der Datenbank, was der Code annimmt? Verschwindet die Anschrift wirklich mit dem Lieferanten?

src/test/java/de/szut/webshop/supplier/SupplierRepositoryTest.java
package de.szut.webshop.supplier;

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

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;

import de.szut.webshop.contact.Contact;

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@DisplayName("Lieferanten in der Datenbank")
class SupplierRepositoryTest {

@Autowired
private TestEntityManager entityManager;

@Autowired
private SupplierRepository supplierRepository;

private Supplier newSupplier(String name) {
Contact contact = new Contact();
contact.setStreet("Hafenstr. 12");
contact.setPostcode("28217");
contact.setCity("Bremen");

Supplier supplier = new Supplier();
supplier.setName(name);
supplier.setContact(contact);
return supplier;
}

@Test
@DisplayName("löscht die Anschrift mit dem Lieferanten - die gefüllte Raute")
void deletingSupplierRemovesContact() {
Supplier saved = supplierRepository.save(newSupplier("Testlauf GmbH"));
entityManager.flush();
Long contactId = saved.getContact().getId();

supplierRepository.delete(saved);
entityManager.flush();
entityManager.clear();

assertThat(entityManager.find(Contact.class, contactId)).isNull();
}
}
Auch hier die Boot-4-Importe

Die drei Annotationen dieses Tests liegen an Pfaden, die IntelliJ nicht von selbst vorschlägt — und die in jeder älteren Anleitung anders lauten:

import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;

Drei verschiedene Pfade für drei Dinge, die zusammengehören: Das ist die Folge davon, dass Spring Boot 4 die Test-Bibliotheken auseinandergezogen hat. Tippfehler an dieser Stelle äußern sich als „cannot find symbol".

Dieser Test ist die Probe auf die gefüllte Raute aus Arbeitsblatt 01. Nirgends im Test wird ein Contact gespeichert oder gelöscht — beides macht die Kaskade.

Warum hier nicht durch eine Datenbank im Arbeitsspeicher ersetzt wird

Im Gästebuch ersetzte @DataJpaTest die Datenbank automatisch durch eine im Arbeitsspeicher. Das war dort richtig: Es ging um Abfragen, und die sehen überall gleich aus.

Hier geht es um Fremdschlüssel, Eindeutigkeit und Kaskaden — also genau um das Verhalten dieser Datenbank. Eine andere Datenbank zu testen als die, die später läuft, würde die Frage nicht beantworten. replace = NONE schaltet den Austausch ab.

Der Preis: Der Container muss laufen. Ohne ihn scheitern diese Tests, und zwar mit einer Meldung über die Verbindung, nicht über die Fachlichkeit.

Jeder Test wird am Ende zurückgerollt — deine Daten bleiben unangetastet.

Aufgabe 5 – Welcher Test hätte es gefunden?

Eingebauter FehlerTest (a–d)
Im ArticleMapper wird supplier.getId() durch article.getId() ersetzt
Die Prüfung if (articleCount > 0) im Service wird gelöscht
Im ApiExceptionHandler wird CONFLICT durch BAD_REQUEST ersetzt
An Supplier.contact wird cascade = ALL entfernt
An CreateSupplierDto.name wird @NotBlank entfernt
0 von 5 ausgefüllt

Die Buchstaben:

Test
aArticleMapperTest — Unit-Test ohne Spring
bSupplierServiceTest — Unit-Test mit Doppeln
cSupplierControllerTest — Slice-Test der Web-Schicht
dSupplierRepositoryTest — gegen die echte Datenbank

Das ist keine Spielerei: Ein Test, der nie rot war, hat noch nie bewiesen, dass er etwas prüft.

Aufgabe 6 – Testfälle

IDBeschreibungVorbedingungTestschritteErwartetes ErgebnisErgebnis
TF-01Alle Tests laufen durchContainer läuft
  1. In IntelliJ alle Tests starten — oder im Terminal ./mvnw test (Windows: mvnw.cmd test)
Alle Tests sind grün, keiner wird übersprungen.
TF-02Die Unit-Tests brauchen keine DatenbankContainer gestoppt (docker compose stop)
  1. Nur ArticleMapperTest und SupplierServiceTest starten
Beide sind grün — sie kennen weder Spring noch Datenbank.
TF-03Der Datenbanktest braucht den ContainerContainer gestoppt
  1. SupplierRepositoryTest starten
Er scheitert, und zwar an der Verbindung — nicht an einer Zusicherung. Danach Container starten und wiederholen: grün.
TF-04Der Slice-Test braucht keinen ServerDie Anwendung läuft NICHT
  1. SupplierControllerTest starten
Grün. Es ist kein Port offen, und es wurde kein Byte über eine Leitung geschickt.
TF-05Die Tests hinterlassen keine DatenAlle Tests wurden ausgeführt
  1. Im Datenbankfenster die Tabelle supplier ansehen
Nur deine eigenen Lieferanten. Kein Lieferant namens Testlauf GmbH — jeder Test wurde zurückgerollt. Genau dafür heißen die Testdaten anders als deine.
TF-06Ein eingebauter Fehler wird gefundenAlle Tests grün
  1. cascade = ALL an Supplier.contact entfernen
  2. Alle Tests starten
Genau der Datenbanktest wird rot. Die anderen bleiben grün — sie können es nicht sehen.

Was die vier Tests zusammen aussagen

TestIst er rot, liegt es an …Was er nicht sieht
ArticleMapperTestdeiner Übersetzung zwischen Entität und DTOalles andere
SupplierServiceTestdeiner fachlichen Regelob die Datenbank mitspielt
SupplierControllerTestder Übersetzung zwischen HTTP und Javaob die Regel stimmt — der Service ist ein Doppel
SupplierRepositoryTesteiner Abfrage, einer Kaskade oder einem Fremdschlüsselalles oberhalb der Datenzugriffsschicht

Keiner deckt alles ab. Zusammen decken sie jede Stelle ab, an der du in diesem Tutorial etwas entschieden hast.

Es gibt eine fünfte Art — den Integrationstest

Die vier Tests oben prüfen jeweils ein Stück. Was sie nicht prüfen: ob die Stücke zusammenpassen. Genau dafür gibt es den Integrationstest — er lässt eine echte Anfrage durch alle Schichten bis in die Datenbank laufen, ohne ein einziges Doppel.

In der Musterlösung liegt eine solche Klasse als Beispiel bei: SupplierIntegrationTest. Wie sie aufgebaut ist und was sie an Vorarbeit braucht, steht im Infoblatt Automatisiert testen.


Musterlösung — die Tests

Musterlösung

Das Passwort bekommst du von deiner Lehrkraft.