AB 01 – Fachlichkeit bekommt einen Ort, und Tests bekommen ein Ziel
Die Situation
Das Frontend-Team meldet zwei Wünsche an:
„In der Übersichtsliste brauchen wir eine kurze Vorschau des Kommentars — eine Zeile, nicht 2000 Zeichen."
„Und bitte lasst keine leeren Einträge mehr durch. Wir haben gerade drei Stück mit leerem Titel in der Liste."
Beides sind fachliche Regeln. Und beide gehören nirgendwohin, wo dein Code sie im Moment aufnehmen könnte.
Lernziele
Nach Bearbeitung dieses Arbeitsblatts kannst du:
- begründen, warum Fachlichkeit weder in den Controller noch ins Repository gehört
- eine Service-Schicht einziehen und den Controller darauf umstellen
- Unit-Tests nach Arrange-Act-Assert schreiben
- aus Äquivalenzklassen und Grenzwerten die nötigen Testfälle ableiten
- mit
@ParameterizedTestmehrere Fälle in einer Methode prüfen - ein Test-Doppel einsetzen und sagen, was der Test dadurch nicht mehr prüft
Wo soll die neue Regel hin?
Sieh dir an, was du hast:
| Schicht | Zuständig für | Kennt |
|---|---|---|
GuestbookEntryController | HTTP: Adressen, Statuscodes, Kopfzeilen | Web |
GuestbookEntryRepository | Speichern und Suchen | Datenbank |
Die Regel „ein Titel darf nicht leer sein" ist weder das eine noch das andere. Sie gilt unabhängig davon, ob die Anfrage über HTTP kommt, und unabhängig davon, ob H2 oder PostgreSQL darunter liegt.
Zwei Gründe
- Sie gilt dann nur für diesen einen Weg. Kommt später ein Import aus einer CSV-Datei oder ein zweiter Endpunkt dazu, ist die Regel dort nicht wirksam — es sei denn, jemand kopiert sie. Kopierte Regeln laufen auseinander.
- Sie ist dann nur zusammen mit HTTP prüfbar. Um die Regel „Titel nicht leer" zu testen, müsstest du eine HTTP-Anfrage bauen. Für eine Regel, die mit HTTP nichts zu tun hat.
Der zweite Grund ist der, um den es in diesem Tutorial geht. Merk ihn dir — er kommt in Arbeitsblatt 03 in schärferer Form wieder.
Aufgaben
Aufgabe 1 – Die Service-Schicht einziehen
Die neue Schicht bekommt ein eigenes Paket — neben controller, model und repository.
Dann zwei kleine Klassen — der Service braucht sie gleich. Warum er eigene Ausnahmen wirft statt Statuscodes, klärt Aufgabe 2; hier genügt, dass es sie gibt.
public class InvalidEntryException extends RuntimeException {
public InvalidEntryException(String message) {
super(message);
}
}
public class EntryNotFoundException extends RuntimeException {
public EntryNotFoundException(Long id) {
super("Kein Gästebucheintrag mit der Id " + id);
}
}
@Service
public class GuestbookEntryService {
private final GuestbookEntryRepository repository;
public GuestbookEntryService(GuestbookEntryRepository repository) {
this.repository = repository;
}
public GuestbookEntry create(GuestbookEntry entry) {
return repository.save(entry);
}
public Page<GuestbookEntry> findAll(Integer year, Pageable pageable) {
if (year == null) {
return repository.findAll(pageable);
}
LocalDateTime from = LocalDate.of(year, 1, 1).atStartOfDay();
LocalDateTime to = from.plusYears(1);
return repository.findByDateGreaterThanEqualAndDateLessThan(from, to, pageable);
}
public GuestbookEntry findById(Long id) {
return repository.findById(id)
.orElseThrow(() -> new EntryNotFoundException(id));
}
}
So sehen die beiden Methoden im Service aus
public GuestbookEntry update(Long id, GuestbookEntry entry) {
GuestbookEntry existing = findById(id);
existing.setTitle(entry.getTitle());
existing.setComment(entry.getComment());
existing.setAuthor(entry.getAuthor());
return repository.save(existing);
}
public void deleteById(Long id) {
repository.delete(findById(id));
}
„Verschieben" ist dabei nicht ganz das richtige Wort — an beiden Methoden ändert sich etwas:
| Im Controller stand | Im Service steht | Warum |
|---|---|---|
repository.findById(id).orElseThrow(() -> new ResponseStatusException(...)) | findById(id) | Deine eigene Methode wirft bereits EntryNotFoundException. Die Prüfung steht damit einmal im Service statt dreimal im Controller |
Rückgabetyp ResponseEntity<GuestbookEntry> | GuestbookEntry | Der Service kennt kein HTTP. Ob daraus 200 oder 204 wird, entscheidet der Controller |
ResponseEntity.noContent().build() | nichts — void | dasselbe: Ein Statuscode ist keine Antwort auf eine fachliche Frage |
deleteById schrumpft dadurch auf eine Zeile: findById(id) beschafft den Eintrag und wirft, wenn es ihn nicht gibt. Beides in einem Aufruf.
update nicht einfach repository.save(entry) aufruftDas Objekt im Parameter kommt aus dem JSON des Clients. Sein Datum ist leer, seine Id ist leer oder geraten. Wer es direkt speichert, überschreibt das Datum des vorhandenen Eintrags mit null — oder legt einen zweiten Eintrag an.
Deshalb wird der vorhandene Eintrag geladen, und es werden nur die drei Felder gesetzt, die der Client ändern darf: Titel, Kommentar, Verfasser. Das Datum bleibt, wie es war.
@Service — was macht die Annotation?Dasselbe wie @RestController und @Repository: Sie sagt Spring „von dieser Klasse brauche ich ein Exemplar". Spring legt es beim Start an und reicht es überall dort hinein, wo ein GuestbookEntryService im Konstruktor steht.
Technisch könnte auch @Component dort stehen. @Service sagt zusätzlich einem Menschen, der den Code liest, worum es sich handelt.
Aufgabe 2 – Eigene Ausnahmen statt Statuscodes
Der Service darf nichts von HTTP wissen — sonst wäre er wieder an das Web gebunden. Statt ResponseStatusException wirft er eigene Ausnahmen.
Genau dafür sind die beiden Klassen aus Aufgabe 1 da: InvalidEntryException und EntryNotFoundException sagen, was nicht stimmt — nicht, welchen Statuscode das ergibt.
Aus diesen Ausnahmen müssen aber wieder Statuscodes werden. Das erledigt eine Klasse an einer Stelle:
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(InvalidEntryException.class)
public ProblemDetail handleInvalidEntry(InvalidEntryException ex) {
ProblemDetail problem =
ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
problem.setTitle("Ungültiger Eintrag");
return problem;
}
@ExceptionHandler(EntryNotFoundException.class)
public ProblemDetail handleNotFound(EntryNotFoundException ex) {
ProblemDetail problem =
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Nicht gefunden");
return problem;
}
}
@RestControllerAdvice — die Stelle, an der aus einer Ausnahme eine Antwort wirdDie Klasse gehört zu keinem einzelnen Controller. Sie gilt für alle. Wirft irgendwo eine EntryNotFoundException, greift Spring hier zu und macht eine 404-Antwort daraus.
Sie ist damit die einzige Stelle, an der beide Welten aufeinandertreffen: Der Service kennt nur seine Ausnahmen, der Client nur Statuscodes.
ProblemDetail kennst du aus dem ersten Tutorial: das Standardformat für Fehlerantworten nach RFC 9457. Genau dieses Format hat Spring bisher selbst erzeugt — jetzt füllst du es mit deinem eigenen Text.
So sieht der Controller danach aus
@RestController
@RequestMapping("/api/v1/guestbook-entries")
public class GuestbookEntryController {
private final GuestbookEntryService service;
public GuestbookEntryController(GuestbookEntryService service) {
this.service = service;
}
@PostMapping
public ResponseEntity<GuestbookEntry> createEntry(@RequestBody GuestbookEntry entry) {
GuestbookEntry saved = service.create(entry);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(saved.getId())
.toUri();
return ResponseEntity.created(location).body(saved);
}
@GetMapping
public ResponseEntity<Page<GuestbookEntry>> findAllEntries(
@RequestParam(required = false) Integer year,
@PageableDefault(size = 10, sort = "date", direction = Sort.Direction.DESC)
@ParameterObject Pageable pageable) {
return ResponseEntity.ok(service.findAll(year, pageable));
}
@GetMapping("/{id}")
public ResponseEntity<GuestbookEntry> findEntryById(@PathVariable Long id) {
return ResponseEntity.ok(service.findById(id));
}
@PutMapping("/{id}")
public ResponseEntity<GuestbookEntry> updateEntry(@PathVariable Long id,
@RequestBody GuestbookEntry entry) {
return ResponseEntity.ok(service.update(id, entry));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteEntryById(@PathVariable Long id) {
service.deleteById(id);
return ResponseEntity.noContent().build();
}
}
Alle fünf Endpunkte sind noch da — es geht keiner verloren. Was verschwindet, ist die Arbeit in ihnen:
- Die
404-Behandlung ist weg — nicht verschwunden, sondern auf zwei Stellen aufgeteilt. Der Service stellt fest, dass etwas nicht stimmt, und wirftEntryNotFoundException. DerApiExceptionHandlerentscheidet, wie das über HTTP aussieht, und macht daraus die404. Der Controller trifft keine dieser beiden Entscheidungen mehr — er reicht nur noch durch. - In
findAllEntriesist die Umrechnung des Jahres in einen Zeitraum verschwunden. Sie steht jetzt im Service; der Controller reicht nur nochyearweiter. - In
updateEntrybleibt eine Zeile übrig. Das Laden, Kopieren und Speichern macht der Service.
Jede Methode ist damit auf das zusammengeschrumpft, was ein Controller tun soll: entgegennehmen, weiterreichen, verpacken.
@Operation und @ApiResponse aus dem vorigen Tutorial stehen oben aus Platzgründen nicht mit dabei — löschen musst du sie nicht. Sie beschreiben die Schnittstelle nach außen, und die ändert sich bei diesem Umbau ja gerade nicht.
Ein Refactoring ändert den Aufbau des Codes, ohne sein Verhalten zu ändern. Der Beweis dafür ist, dass sich von außen nichts unterscheidet.
Genau hier zeigt sich das Problem, das dieses Tutorial löst: Diesen Beweis hast du eben von Hand geführt, indem du geklickt hast. Beim nächsten Umbau musst du wieder klicken. Und beim übernächsten.
Aufgabe 3 – Die erste eigene Logik
Jetzt der erste Wunsch des Frontend-Teams: eine Vorschauzeile.
public final class CommentPreview {
public static final int MAX_LENGTH = 60;
private CommentPreview() {
}
public static String shorten(String comment) {
if (comment == null) {
return "";
}
if (comment.length() < MAX_LENGTH) {
return comment;
}
return comment.substring(0, MAX_LENGTH - 1) + "…";
}
}
Die Regel dazu lautet:
Ein Kommentar mit höchstens 60 Zeichen wird unverändert angezeigt. Ein längerer wird abgeschnitten und bekommt „…" ans Ende, sodass die Vorschau insgesamt genau 60 Zeichen lang ist.
nullwird zu einem leeren Text.
Aufgabe 4 – Testfälle ableiten, bevor du testest
Nicht jeden möglichen Wert prüfen — sondern die richtigen. Welche das sind, verrät das Infoblatt Testfälle formulieren. Wie sich Unit-, Slice- und Integrationstest unterscheiden, steht in Automatisiert testen.
| Eingabe | Erwartete Ausgabe |
|---|---|
| null | |
| Kommentar mit 20 Zeichen | |
| Kommentar mit 59 Zeichen | |
| Kommentar mit 60 Zeichen | |
| Kommentar mit 61 Zeichen | |
| Kommentar mit 500 Zeichen |
Bisher nirgends — die Klasse wird von keinem Endpunkt aufgerufen. Das ist Absicht und kein Versehen.
Für den Weg zur Vorschauzeile fehlt noch ein Stück, das erst im nächsten Tutorial drankommt: eine eigene Klasse für das, was nach außen geht, statt die Entität direkt auszuliefern. Bis dahin bleibt CommentPreview eine Regel, die für sich steht.
Fürs Testen ändert das nichts — und das ist die Beobachtung, um die es hier geht: Ob eine Funktion prüfbar ist, hängt nicht daran, wer sie aufruft, sondern nur daran, ob sie bei gleicher Eingabe dasselbe liefert.
Weil dort die Regel umschlägt. Ein Programmierfehler an einer Grenze — < statt <= — verändert das Verhalten für genau einen Wert. Ein Test mit 20 und 500 Zeichen findet ihn nie.
Das ist die Grenzwertanalyse: Prüfe an jeder Grenze den Wert davor, die Grenze selbst und den Wert danach.
Aufgabe 5 – Der erste Unit-Test
Ein Test besteht aus drei Abschnitten. Diese Gliederung heißt Arrange-Act-Assert:
class CommentPreviewTest {
@Test
@DisplayName("kurzer Kommentar bleibt unverändert")
void shortCommentStaysUnchanged() {
// Arrange — alles vorbereiten
String comment = "Hat mir gut gefallen!";
// Act — die eine Sache tun, um die es geht
String result = CommentPreview.shorten(comment);
// Assert — nachsehen, ob es stimmt
assertThat(result).isEqualTo(comment);
}
}
| Teil | Frage | Faustregel |
|---|---|---|
| Arrange | Was muss vorher da sein? | darf mehrere Zeilen haben |
| Act | Was wird geprüft? | genau eine Zeile |
| Assert | Woran erkennst du, dass es stimmt? | prüft eine Aussage |
assertThat statt assertEqualsBeides funktioniert. assertThat(...) kommt von AssertJ und liest sich wie ein Satz — assertThat(result).isEqualTo(comment). Vor allem aber sind die Fehlermeldungen aussagekräftiger.
AssertJ ist bereits dabei; es steckt im Test-Starter deines Projekts.
Der Import: import static org.assertj.core.api.Assertions.assertThat;
@DisplayName — der Test erklärt sich selbstOhne diese Annotation heißt der fehlgeschlagene Test kurzerKommentarBleibtUnveraendert. Mit ihr steht in der Ausgabe „kurzer Kommentar bleibt unverändert".
Der Unterschied zählt in dem Moment, in dem ein Test in einem halben Jahr rot wird und jemand anderes verstehen muss, was eigentlich kaputt ist.
Aufgabe 6 – Die Grenze prüfen
Jetzt die drei Fälle aus deiner Tabelle, an denen die Regel umschlägt.
/** Erzeugt einen Text aus genau so vielen Zeichen. */
private String textOfLength(int length) {
return "x".repeat(length);
}
@ParameterizedTest(name = "{0} Zeichen -> unverändert: {1}")
@DisplayName("An der Grenze von 60 Zeichen")
@CsvSource({
"59, true",
"60, true",
"61, false"
})
void atTheBoundary(int length, boolean staysUnchanged) {
String comment = textOfLength(length);
String result = CommentPreview.shorten(comment);
assertThat(result.equals(comment)).isEqualTo(staysUnchanged);
}
@ParameterizedTest — eine Methode, mehrere FälleDie Methode läuft dreimal, jedes Mal mit einer Zeile aus @CsvSource. Die Werte landen in den Parametern.
Der Gewinn: Ein weiterer Fall ist eine Zeile, keine weitere Methode. Und im Testbericht erscheinen drei einzelne Ergebnisse — du siehst, welcher Wert versagt hat.
Ein Test ist rot. Zwei Erklärungen sind möglich:
- Der Code ist falsch.
- Der Test ist falsch.
Die zweite Möglichkeit gerät leicht aus dem Blick — ein roter Test fühlt sich an wie ein Vorwurf an den Code. Beantworte die Frage trotzdem jedes Mal, und zwar an der Regel, nicht am Code:
Ein Kommentar mit höchstens 60 Zeichen wird unverändert angezeigt.
Was sagt die Regel für 60 Zeichen? Und was tut dein Code?
Auflösung
Der Code ist falsch. In shorten steht:
if (comment.length() < MAX_LENGTH) { // ❌
„höchstens 60" heißt <= 60. Mit < wird ein Kommentar von exakt 60 Zeichen gekürzt, obwohl er passt. Richtig:
if (comment.length() <= MAX_LENGTH) { // ✅
Ein einziges Zeichen im Quelltext. Es betrifft genau einen von Milliarden möglichen Eingabewerten — und genau diesen einen hat dein Test geprüft, weil du ihn aus der Grenzwertanalyse abgeleitet hast und nicht geraten.
Merke: Der Test hat den Fehler nicht gefunden, weil er ein Test ist. Er hat ihn gefunden, weil er an der richtigen Stelle geprüft hat.
Aufgabe 7 – Die Validierung
Der zweite Wunsch des Frontend-Teams: keine leeren Einträge mehr.
public static final int MAX_TITLE_LENGTH = 80;
public static final int MAX_COMMENT_LENGTH = 2000;
public static final int MAX_AUTHOR_LENGTH = 50;
public GuestbookEntry create(GuestbookEntry entry) {
validate(entry);
return repository.save(entry);
}
/** Paketsichtbar, damit der Test im selben Package sie direkt aufrufen kann. */
void validate(GuestbookEntry entry) {
checkText(entry.getTitle(), "Der Titel", MAX_TITLE_LENGTH);
checkText(entry.getComment(), "Der Kommentar", MAX_COMMENT_LENGTH);
checkText(entry.getAuthor(), "Der Verfasser", MAX_AUTHOR_LENGTH);
}
private void checkText(String value, String label, int maxLength) {
if (value == null || value.isBlank()) {
throw new InvalidEntryException(label + " darf nicht leer sein.");
}
if (value.length() > maxLength) {
throw new InvalidEntryException(
label + " darf höchstens " + maxLength + " Zeichen lang sein.");
}
}
Prüfst du nur beim Anlegen, kommt ein leerer Titel eben per PUT herein. Eine Regel, die nur einen von zwei Wegen absichert, ist keine Regel.
Genau deshalb steht sie im Service und nicht im Controller: Es gibt eine Stelle, an der sie steht, und beide Wege gehen dort vorbei.
Aufgabe 8 – Testen mit einem Doppel
Der Service lässt sich nicht so testen wie CommentPreview: Er braucht ein Repository, und das braucht eine Datenbank.
Muss es nicht. Es genügt etwas, das aussieht wie ein Repository.
class GuestbookEntryServiceTest {
private GuestbookEntryRepository repository;
private GuestbookEntryService service;
@BeforeEach
void setUp() {
repository = mock(GuestbookEntryRepository.class);
service = new GuestbookEntryService(repository);
}
/** Baut einen Eintrag, der alle Regeln erfüllt. */
private GuestbookEntry validEntry() {
GuestbookEntry entry = new GuestbookEntry();
entry.setTitle("Toller Kurs");
entry.setComment("Hat mir gut gefallen!");
entry.setAuthor("Anna");
return entry;
}
}
mock(...) — ein Doppel, kein Ersatzmock(GuestbookEntryRepository.class) erzeugt ein Objekt, das dieselben Methoden hat wie ein Repository. Es speichert nichts. Jede Methode gibt zunächst nichts zurück (null, false, leeres Optional) — bis der Test etwas anderes anordnet.
Das Werkzeug dahinter heißt Mockito und liegt bereits im Test-Starter deines Projekts.
@BeforeEach läuft vor jedem Test. So bekommt jeder Test ein frisches Doppel und kann nicht durch einen vorherigen Test beeinflusst werden.
@Test
@DisplayName("speichert einen gültigen Eintrag")
void savesValidEntry() {
// Arrange
GuestbookEntry input = validEntry();
when(repository.save(any(GuestbookEntry.class))).thenReturn(input);
// Act
GuestbookEntry result = service.create(input);
// Assert
assertThat(result).isSameAs(input);
verify(repository).save(input);
}
@Test
@DisplayName("speichert NICHT, wenn der Titel fehlt")
void doesNotSaveWithoutTitle() {
GuestbookEntry input = validEntry();
input.setTitle(" ");
assertThatThrownBy(() -> service.create(input))
.isInstanceOf(InvalidEntryException.class)
.hasMessageContaining("Titel");
verify(repository, never()).save(any());
}
| Baustein | Bedeutung |
|---|---|
when(...).thenReturn(...) | „Wenn jemand das aufruft, liefere das." |
verify(repository).save(input) | „Prüfe, dass save mit genau diesem Eintrag aufgerufen wurde." |
verify(repository, never()).save(any()) | „Prüfe, dass save nie aufgerufen wurde." |
assertThatThrownBy(() -> ...) | „Führe das aus und prüfe die geworfene Ausnahme." |
verify(..., never()) ist der eigentliche TestDass eine Ausnahme fliegt, ist die halbe Aussage. Die andere Hälfte: Es darf nichts in der Datenbank landen.
Ein Service, der erst speichert und dann prüft, würde den ersten Teil bestehen und trotzdem Datenmüll hinterlassen. never() schließt das aus.
Es gibt keine Datenbank in diesem Test. repository.save(...) speichert nichts, es gibt nur zurück, was der Test angeordnet hat.
Dieser Test kann also niemals einen Fehler in der Datenbankschicht finden — kein falsches Mapping, keine kaputte Abfrage, keine fehlende Spalte. Er prüft die Regeln des Service. Sonst nichts.
Das ist kein Mangel, sondern der Zweck: Wird dieser Test rot, weißt du sofort, wo der Fehler steckt. Für die Datenbank ist Arbeitsblatt 03 zuständig.
mvnw test
Aufgabe 9 – Testfälle
| ID | Beschreibung | Vorbedingung | Testschritte | Erwartetes Ergebnis | Ergebnis |
|---|---|---|---|---|---|
| TF-01 | Das Verhalten nach außen ist unverändert | Service-Umbau abgeschlossen, Anwendung läuft |
| Alle Aufrufe verhalten sich wie vor dem Umbau — dieselben Statuscodes, dieselben Antworten. | |
| TF-02 | Leerer Titel wird abgelehnt | Anwendung läuft |
| Status 400, im Rumpf ein Feld detail mit dem Wort Titel. | |
| TF-03 | Der abgelehnte Eintrag wurde nicht gespeichert | TF-02 wurde ausgeführt |
| Der abgelehnte Eintrag taucht nicht in der Liste auf. | |
| TF-04 | Grenzwert Titel: 80 Zeichen sind erlaubt | Anwendung läuft |
| Status 201 — nicht 400. | |
| TF-05 | Grenzwert Titel: 81 Zeichen sind zu viel | Anwendung läuft |
| Status 400. | |
| TF-06 | Der Grenzwerttest findet den Fehler | CommentPreview ist korrigiert, alle Tests sind grün |
| Genau der Fall 60 wird rot, die Fälle 59 und 61 bleiben grün. | |
| TF-07 | Alle Tests laufen zusammen durch | Alle Aufgaben bearbeitet |
| BUILD SUCCESS, keine Fehler, keine übersprungenen Tests. |
Einen Fehler absichtlich wieder einzubauen, um zu sehen, ob der Test ihn bemerkt, klingt umständlich. Es ist aber die einzige Möglichkeit zu prüfen, ob ein grüner Test überhaupt etwas leistet.
Ein Test, der auch bei kaputtem Code grün bleibt, ist schlimmer als kein Test: Er erzeugt Vertrauen, das nicht gedeckt ist.
Zusammenfassung
- Fachliche Regeln gehören in eine eigene Service-Schicht — nicht in den Controller, nicht ins Repository.
- Der Service kennt kein HTTP. Er wirft eigene Ausnahmen; der
@RestControllerAdviceübersetzt sie in Statuscodes. - Arrange-Act-Assert gliedert jeden Test: vorbereiten, eine Sache tun, eine Aussage prüfen.
- Testfälle werden aus Äquivalenzklassen und Grenzwerten abgeleitet, nicht geraten. Fehler sitzen an den Grenzen.
@ParameterizedTestmit@CsvSourceprüft mehrere Fälle in einer Methode und meldet jeden einzeln.- Ein Mock ersetzt eine Abhängigkeit. Er macht den Test schnell und eindeutig — und schließt aus, dass dieser Test etwas über die Datenbank aussagt.
- Bei einem roten Test ist die erste Frage nicht „was ist am Code kaputt", sondern „wer hat recht — Test oder Code?"
Selbstkontrolle
- Nenne zwei Gründe, warum die Prüfung „Titel nicht leer" nicht in den Controller gehört.
- Warum wirft der Service
InvalidEntryExceptionstattResponseStatusException? - Welche drei Werte prüfst du bei einer Grenze — und warum genau diese?
- Was bedeutet
verify(repository, never()).save(any()), und warum genügt die Prüfung der Ausnahme allein nicht? - Ein Test ist rot. Woran entscheidest du, ob der Test oder der Code falsch ist?
Antworten
- Erstens gälte sie nur für diesen einen Weg — ein zweiter Endpunkt oder ein CSV-Import umginge sie. Zweitens ließe sie sich nur zusammen mit HTTP prüfen, obwohl sie mit HTTP nichts zu tun hat.
- Weil
ResponseStatusExceptionein Statuscode ist, also HTTP. Der Service soll unabhängig davon sein, über welchen Weg er aufgerufen wird. Die Übersetzung in einen Statuscode passiert an genau einer Stelle: imApiExceptionHandler. - Den Wert unter der Grenze, die Grenze selbst und den Wert darüber — bei „höchstens 60" also 59, 60 und 61. Ein Verwechslungsfehler wie
<statt<=verändert das Verhalten für genau einen Wert; alle anderen Testwerte übersehen ihn. - „
savewurde kein einziges Mal aufgerufen." Die Ausnahme allein beweist nur, dass etwas abgebrochen wurde — nicht, wann. Ein Service, der erst speichert und dann prüft, würde die Ausnahme ebenfalls werfen und trotzdem einen ungültigen Eintrag hinterlassen. - An der Regel, nicht am Code. Die Regel ist die Vorgabe; Test und Code sind zwei Versuche, sie umzusetzen. Man liest die Regel und fragt, welcher der beiden von ihr abweicht.