AB 05 – Den Webshop testen
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
@WebMvcTestdie Web-Schicht allein prüfen — Statuscodes, Kopfzeilen, JSON-Felder - mit
@DataJpaTestgegen 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.
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");
}
}
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 PreisBigDecimal 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.
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());
}
}
| Baustein | Bedeutung |
|---|---|
mock(X.class) | Ein Doppel: sieht aus wie ein SupplierRepository, tut aber nichts |
when(…).thenReturn(…) | Was das Doppel antworten soll, wenn es gefragt wird |
assertThatThrownBy | Erwartet, 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.
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.
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.
| Annotation | Wirkung |
|---|---|
@WebMvcTest(SupplierController.class) | Lädt die Web-Schicht — und von deinen Controllern nur diesen |
@MockitoBean | Ersetzt eine Bean im Spring-Kontext durch ein Doppel |
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.
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?
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();
}
}
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.
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 Fehler | Test (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 |
Die Buchstaben:
| Test | |
|---|---|
| a | ArticleMapperTest — Unit-Test ohne Spring |
| b | SupplierServiceTest — Unit-Test mit Doppeln |
| c | SupplierControllerTest — Slice-Test der Web-Schicht |
| d | SupplierRepositoryTest — 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
| ID | Beschreibung | Vorbedingung | Testschritte | Erwartetes Ergebnis | Ergebnis |
|---|---|---|---|---|---|
| TF-01 | Alle Tests laufen durch | Container läuft |
| Alle Tests sind grün, keiner wird übersprungen. | |
| TF-02 | Die Unit-Tests brauchen keine Datenbank | Container gestoppt (docker compose stop) |
| Beide sind grün — sie kennen weder Spring noch Datenbank. | |
| TF-03 | Der Datenbanktest braucht den Container | Container gestoppt |
| Er scheitert, und zwar an der Verbindung — nicht an einer Zusicherung. Danach Container starten und wiederholen: grün. | |
| TF-04 | Der Slice-Test braucht keinen Server | Die Anwendung läuft NICHT |
| Grün. Es ist kein Port offen, und es wurde kein Byte über eine Leitung geschickt. | |
| TF-05 | Die Tests hinterlassen keine Daten | Alle Tests wurden ausgeführt |
| 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-06 | Ein eingebauter Fehler wird gefunden | Alle Tests grün |
| Genau der Datenbanktest wird rot. Die anderen bleiben grün — sie können es nicht sehen. |
Was die vier Tests zusammen aussagen
| Test | Ist er rot, liegt es an … | Was er nicht sieht |
|---|---|---|
ArticleMapperTest | deiner Übersetzung zwischen Entität und DTO | alles andere |
SupplierServiceTest | deiner fachlichen Regel | ob die Datenbank mitspielt |
SupplierControllerTest | der Übersetzung zwischen HTTP und Java | ob die Regel stimmt — der Service ist ein Doppel |
SupplierRepositoryTest | einer Abfrage, einer Kaskade oder einem Fremdschlüssel | alles oberhalb der Datenzugriffsschicht |
Keiner deckt alles ab. Zusammen decken sie jede Stelle ab, an der du in diesem Tutorial etwas entschieden hast.
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.