Warum du wirklich testen solltest – Die pragmatische Sicht
Von: Jamal Hassan, Backend Developer
Java Fleet Systems Consulting, Essen-Rüttenscheid
⚡ Kurze Zusammenfassung – Das Wichtigste in 30 Sekunden
Tests sind keine Pflichtübung, sondern deine Versicherung gegen „Es funktionierte gestern noch!“. Du testest nicht, um Code heute korrekt zu machen, sondern um ihn morgen gefahrlos ändern zu können. Die wichtigsten Regeln: Teste deine eigene Logik, Systemgrenzen und Grenzfälle. Teste nie Framework-Code oder triviale Getter/Setter. Halte dich an die Test-Pyramide: 70% Unit-Tests (schnell), 20% Integration-Tests (mittel), 10% E2E-Tests (langsam). Wenn du mehr als 3 Sekunden überlegst, ob du testen sollst – schreib den Test!
👋 Hi, ich bin Jamal
Moin! Jamal hier – Backend Developer bei Java Fleet seit 2021. Sechs Jahre Erfahrung mit Spring Boot und Microservices. Und ja, ich teste meinen Code. Aber das war nicht immer so.
Vor drei Jahren hätte ich dir gesagt: „Tests? Zeitverschwendung! Mein Code läuft doch!“ Dann kam mein erstes Production-Disaster: Ein „kleiner Fix“ um 14 Uhr. Um 23 Uhr saß ich im Büro und debuggte, warum plötzlich alle Rabattcodes mit 50% statt 5% angewendet wurden.
Der Schaden: 12.000€ falsche Rabatte, ein sehr unglücklicher Kunde, und eine sehr lange Nacht.
Die Lektion: Hätte ich Tests gehabt, wäre der Build fehlgeschlagen. Kein Deployment. Kein Disaster. Kein Hotfix um Mitternacht.
Seitdem teste ich. Und heute zeige ich dir, warum und wie.
🧭 „Mein Code läuft doch – warum soll ich testen?“
Das habe ich auch gedacht. Bis zu dieser Nacht im Oktober 2022.
Hier ist die Wahrheit: „Läuft jetzt“ bedeutet nicht „läuft immer“.
Was passiert ohne Tests:
// Version 1 - funktioniert perfekt
public double calculateDiscount(double price, String couponCode) {
if (couponCode.equals("SUMMER50")) {
return price * 0.5; // 50% Rabatt
}
return price;
}
Drei Wochen später kommt ein Kollege und „optimiert“:
// Version 2 - "Optimierung"
public double calculateDiscount(double price, String couponCode) {
if (couponCode.equals("SUMMER50")) {
return price * 0.05; // Ups! 5% statt 50%
}
return price;
}
Ohne Tests:
- ✅ Code kompiliert
- ✅ Lokaler Test (mit Glück)
- ✅ Deployment
- ❌ Production Disaster
- ❌ Kunden beschweren sich
- ❌ 2 Stunden Debugging
- ❌ Hotfix um 23 Uhr
Mit Tests:
@Test
void shouldApply50PercentDiscount() {
double result = calculator.calculateDiscount(100.0, "SUMMER50");
assertThat(result).isEqualTo(50.0);
}
- ❌ Test schlägt fehl beim Build
- ✅ Problem wird sofort sichtbar
- ✅ Fix vor dem Merge
- ✅ Ich schlafe ruhig
Das ist der Unterschied.
⚙️ Tests sind deine Versicherung
Stell dir vor:
Scenario 1: Ohne Tests
- Du änderst Methode A
- Bricht das Feature B? Keine Ahnung.
- Bricht das Feature C? Keine Ahnung.
- Funktioniert noch alles? Du hoffst es.
- Deployment → Fingers crossed 🤞
Scenario 2: Mit Tests
- Du änderst Methode A
- 47 Tests laufen automatisch
- 2 Tests schlagen fehl
- Du siehst sofort: Feature B und C sind betroffen
- Du fixst es BEVOR du deployest
- Deployment → Du weißt, dass alles läuft ✅
Tests sind wie Sicherheitsgurte: Du brauchst sie nicht jeden Tag. Aber wenn doch, sind sie lebensrettend.
🧩 Was du testen solltest (und was nicht)
Nach 6 Jahren habe ich eine einfache Regel:
✅ TESTE DAS:
1. Deine Logik – Entscheidungen, Berechnungen
// Business-Logik - TESTE DAS!
public boolean isOverdue() {
return dueDate != null && dueDate.isBefore(LocalDate.now());
}
// Berechnung - TESTE DAS!
public double calculateTotalWithTax(double price) {
return price * 1.19; // 19% MwSt
}
2. Systemgrenzen – Alles was externe Services aufruft
// Externe Abhängigkeit - TESTE DAS!
public void createOrder(String userId) {
User user = userService.findById(userId); // Kann null sein!
if (user == null) {
throw new UserNotFoundException("User nicht gefunden");
}
// ...
}
3. Grenzfälle – null, leer, zu lang, zu groß
// Edge Cases - TESTE DAS!
public void validateTitle(String title) {
if (title == null || title.trim().isEmpty()) {
throw new ValidationException("Titel darf nicht leer sein");
}
if (title.length() > 100) {
throw new ValidationException("Titel zu lang");
}
}
❌ TESTE DAS NICHT:
1. Framework-Code
// Spring macht das - NICHT testen @Autowired private UserRepository userRepository;
2. Triviale Getter/Setter
// Kein Test nötig
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
3. Java Standard Library
// Java garantiert das - NICHT testen String id = UUID.randomUUID().toString(); List<String> items = new ArrayList<>();
Meine Faustregel:
Wenn du mehr als 3 Sekunden überlegst, ob du testen sollst → schreib den Test!
🎯 Die drei Regeln, die ich jedem Junior zeige
Wenn Nova zu mir kommt und fragt „Was soll ich testen?“, sage ich immer:
1. Teste deine eigene Logik → Alles mit if, switch, Berechnungen
2. Teste deine Reaktion auf fremdes Verhalten → Was machst du, wenn externe Services null liefern, fehlschlagen, oder Timeout haben?
3. Teste nie Dinge, die bereits garantiert sind → Spring-Framework, Java SE APIs, simple Getter/Setter
Das sind die drei Regeln. Mehr brauchst du nicht zu wissen.
🔧 Systemgrenzen: Wo die Bugs wohnen
In 6 Jahren habe ich gelernt: Die meisten Bugs entstehen an Systemgrenzen.
Eine Systemgrenze ist überall, wo dein Code etwas Externes aufruft:
- Eine andere Spring-Bean
- Eine Datenbank (Repository)
- Ein REST-Service
- Eine Datei oder Cache
Beispiel aus meinem letzten Projekt:
public class BuchService {
@Autowired
private BuchRepository repository;
@Autowired
private AuthorService authorService;
public Buch createBuch(String titel, String authorId) {
// Systemgrenze 1: AuthorService - kann null liefern
Author author = authorService.findById(authorId);
if (author == null) {
throw new AuthorNotFoundException("Author nicht gefunden");
}
Buch buch = new Buch(titel, author);
// Systemgrenze 2: Repository - kann fehlschlagen
return repository.save(buch);
}
}
Die Tests dafür:
@ExtendWith(MockitoExtension.class)
class BuchServiceTest {
@Mock
private BuchRepository repository;
@Mock
private AuthorService authorService;
@InjectMocks
private BuchService buchService;
@Test
void shouldCreateBuchWhenAuthorExists() {
// Happy Path - alles gut
Author author = new Author("1", "Kent Beck");
when(authorService.findById("1")).thenReturn(author);
when(repository.save(any())).thenAnswer(i -> i.getArgument(0));
Buch result = buchService.createBuch("TDD", "1");
assertThat(result.getTitel()).isEqualTo("TDD");
}
@Test
void shouldThrowExceptionWhenAuthorNotFound() {
// Edge Case - Author existiert nicht
when(authorService.findById("999")).thenReturn(null);
assertThatThrownBy(() -> buchService.createBuch("TDD", "999"))
.isInstanceOf(AuthorNotFoundException.class);
}
}
Meine Regel: Jede Systemgrenze = mindestens 2 Tests (Happy Path + Fehlerfall).
🗃️ Die Test-Pyramide: Warum 70-20-10?
Als ich bei Java Fleet anfing, hatte ein Projekt 5 Unit-Tests und 50 E2E-Tests. Jeder Test-Run brauchte 30 Minuten. Niemand wollte Tests schreiben.
Heute haben wir:
- 500 Unit-Tests (laufen in 10 Sekunden)
- 80 Integration-Tests (laufen in 2 Minuten)
- 15 E2E-Tests (laufen in 5 Minuten)
Warum diese Verteilung?
/\
/ \ 10% E2E Tests
/____\ (langsam, fragil, teuer)
/ \
/ \ 20% Integration Tests
/__________\ (mittel schnell, mittlere Kosten)
/ \
/ \ 70% Unit Tests
/________________\ (schnell, stabil, günstig)
Die drei Test-Typen erklärt:
🟢 Unit Test – Eine Klasse isoliert
@Test
void shouldCalculateDiscount() {
// Keine Dependencies, super schnell
double result = calculator.calculateDiscount(100.0, 0.1);
assertThat(result).isEqualTo(90.0);
}
// Läuft in: 50ms
🟠 Integration Test – Service + Repository
@Test
@SpringBootTest
@Transactional
void shouldSaveBuchToDatabase() {
// Mit echter Datenbank
Buch buch = repository.save(new Buch("TDD"));
assertThat(buch.getId()).isNotNull();
}
// Läuft in: 500ms
🔵 E2E Test – Komplettes System
@Test
@SpringBootTest(webEnvironment = RANDOM_PORT)
void shouldCompletePurchaseFlow() {
// Login → Warenkorb → Checkout → Payment
// Alles von außen getestet
}
// Läuft in: 5000ms
Die Regel: Je höher in der Pyramide, desto langsamer und fragiler. Halte 70% Unit, 20% Integration, 10% E2E.
📋 Meine Entscheidungs-Checkliste
Ich schaue auf jede Methode und frage mich:
| Frage | Wenn Ja → Test schreiben |
|---|---|
| Hat sie eine Entscheidung (if, switch)? | ✅ |
| Ändert sie einen Zustand (Liste, DB)? | ✅ |
| Ruft sie externe Komponenten auf? | ✅ |
| Gibt sie Werte zurück, die andere verwenden? | ✅ |
| Hat sie mehrere Pfade (Error, Success)? | ✅ |
Wenn alle Antworten „Nein“ → kein Test nötig.
Beispiele:
// ❌ KEIN Test - nur Getter
public String getTitel() {
return titel;
}
// ✅ TEST SCHREIBEN - Entscheidung!
public boolean isOverdue() {
return dueDate != null && dueDate.isBefore(LocalDate.now());
}
// ✅ TEST SCHREIBEN - Zustandsänderung!
public void markAsCompleted() {
this.completed = true;
this.completedAt = LocalDateTime.now();
}
// ✅ TEST SCHREIBEN - Externe Komponente!
public void sendNotification(String userId) {
User user = userService.findById(userId);
notificationService.send(user.getEmail(), "Task completed");
}
💡 Ein konkretes Beispiel: BuchRating
Lass mich dir zeigen, wie ich eine echte Methode teste.
Der Code:
public class BuchRatingService {
@Autowired
private BuchService buchService;
private List<BuchRating> ratings = new ArrayList<>();
public void addBuchRating(String bookId, String userId, double rating) {
// Systemgrenze - kann null liefern
Buch buch = buchService.getBuchByBookId(bookId);
if (buch == null) {
throw new BuchNotFoundException("Buch nicht gefunden: " + bookId);
}
// Validierung
if (rating < 1.0 || rating > 5.0) {
throw new IllegalArgumentException("Rating muss zwischen 1 und 5 liegen");
}
// Zustandsänderung
BuchRating br = new BuchRating(buch, userId, rating);
ratings.add(br);
}
}
Was teste ich?
- ✅ Happy Path – Buch existiert, Rating ist valide
- ✅ Edge Case – Buch existiert nicht (null)
- ✅ Edge Case – Rating zu hoch (> 5)
- ✅ Edge Case – Rating zu niedrig (< 1)
- ✅ Verhalten – Mehrere Ratings für ein Buch
Die Tests:
@ExtendWith(MockitoExtension.class)
class BuchRatingServiceTest {
@Mock
private BuchService buchService;
@InjectMocks
private BuchRatingService ratingService;
@Test
void shouldAddRatingWhenBuchExists() {
// Arrange
Buch buch = new Buch("1", "TDD");
when(buchService.getBuchByBookId("1")).thenReturn(buch);
// Act
ratingService.addBuchRating("1", "user123", 4.5);
// Assert
List<BuchRating> ratings = ratingService.getAllRatings();
assertThat(ratings).hasSize(1);
assertThat(ratings.get(0).getRating()).isEqualTo(4.5);
}
@Test
void shouldThrowExceptionWhenBuchNotFound() {
// Arrange
when(buchService.getBuchByBookId("999")).thenReturn(null);
// Act & Assert
assertThatThrownBy(() -> ratingService.addBuchRating("999", "user123", 4.5))
.isInstanceOf(BuchNotFoundException.class)
.hasMessageContaining("Buch nicht gefunden: 999");
}
@Test
void shouldRejectTooHighRating() {
// Arrange
Buch buch = new Buch("1", "TDD");
when(buchService.getBuchByBookId("1")).thenReturn(buch);
// Act & Assert
assertThatThrownBy(() -> ratingService.addBuchRating("1", "user123", 6.0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("zwischen 1 und 5");
}
@Test
void shouldRejectTooLowRating() {
// Arrange
Buch buch = new Buch("1", "TDD");
when(buchService.getBuchByBookId("1")).thenReturn(buch);
// Act & Assert
assertThatThrownBy(() -> ratingService.addBuchRating("1", "user123", 0.5))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void shouldAllowMultipleRatingsForSameBuch() {
// Arrange
Buch buch = new Buch("1", "TDD");
when(buchService.getBuchByBookId("1")).thenReturn(buch);
// Act
ratingService.addBuchRating("1", "user1", 5.0);
ratingService.addBuchRating("1", "user2", 4.0);
// Assert
assertThat(ratingService.getAllRatings()).hasSize(2);
}
}
5 Tests. 5 verschiedene Szenarien. Komplette Abdeckung der wichtigen Fälle.
🎯 Das AAA-Pattern: Wie ich jeden Test schreibe
Jeder meiner Tests folgt diesem Muster:
Arrange → Vorbereitung
Act → Aktion
Assert → Überprüfung
@Test
void shouldCalculateCorrectDiscount() {
// Arrange - Vorbereitung
Order order = new Order(100.0);
String coupon = "SUMMER50";
// Act - Aktion
double result = orderService.applyDiscount(order, coupon);
// Assert - Überprüfung
assertThat(result).isEqualTo(50.0);
}
Warum AAA?
- Jeder Test hat die gleiche Struktur
- Tests sind leicht zu lesen
- Man sieht sofort, was getestet wird
📚 AssertJ: Lesbare Assertions
Früher habe ich JUnit-Assertions verwendet:
// Schwer zu lesen
assertTrue(result.isValid());
assertEquals(1, errors.size());
assertTrue(errors.contains("Title empty"));
Heute nutze ich AssertJ:
// Liest sich wie ein Satz!
assertThat(result.isValid()).isTrue();
assertThat(errors)
.hasSize(1)
.contains("Title empty");
Meine Lieblings-AssertJ-Patterns:
// Strings
assertThat(title)
.isNotNull()
.isNotEmpty()
.startsWith("Learn")
.contains("Testing");
// Collections
assertThat(errors)
.hasSize(2)
.contains("Error 1", "Error 2");
// Exceptions
assertThatThrownBy(() -> service.doSomething())
.isInstanceOf(ValidationException.class)
.hasMessage("Invalid input");
// Objects
assertThat(user)
.extracting(User::getName, User::getEmail)
.containsExactly("Jamal", "jamal@example.com");
AssertJ macht Tests zur lebenden Dokumentation deines Codes.
💪 Was ich in 6 Jahren gelernt habe
1. Tests sind Investition, keine Zeitverschwendung
Jede Stunde Testing spart dir 3 Stunden Debugging. Ich habe es oft genug durchgerechnet.
2. Teste zuerst die kritischen Pfade
Nicht alles braucht 100% Coverage. Fokussiere auf Business-Logik und Systemgrenzen.
3. Gute Tests dokumentieren deinen Code
Wenn jemand wissen will, wie deine Methode funktioniert, soll er deine Tests lesen.
4. Tests machen Refactoring sicher
Ich refactore nur Code mit Tests. Sonst ist es russisches Roulette.
5. Test-Namen sind wichtig
// Schlecht
@Test
void test1() { }
// Gut
@Test
void shouldThrowExceptionWhenUserNotFound() { }
6. Kleine Tests sind besser als große
Lieber 10 Tests à 5 Zeilen als 1 Test mit 50 Zeilen.
🗓️ Nächste Woche: Integration Tests & die Test-Pyramide
Vorschau auf Teil 2:
„Die Test-Pyramide in der Praxis + Integration Tests mit Spring Boot“
Du lernst:
- Wie du Integration-Tests mit @SpringBootTest schreibst
- Wann du @MockBean vs. echte Dependencies brauchst
- Testcontainers für Datenbank-Tests
- Deine optimale Test-Strategie berechnen
❓ FAQ – Testing Grundlagen
Frage 1: Muss ich wirklich ALLE Methoden testen?
Nein! Teste nur Methoden mit Logik, Zustandsänderungen oder externen Aufrufen. Simples Getter/Setter braucht keine Tests. Nutze meine Checkliste aus Abschnitt 6!
Frage 2: Wie teste ich Code mit externen Services?
Du testest nicht den Service selbst, sondern deine Reaktion darauf. Nutze Mocks für verschiedene Szenarien: Erfolg, Fehler, null, Timeout. Mein Standard: Jede Systemgrenze = 2 Tests (Happy Path + Fehlerfall).
Frage 3: Warum Unit-Tests, wenn ich Integration-Tests habe?
Integration-Tests sind langsam (500ms+) und zeigen nicht genau, WO der Fehler ist. Unit-Tests laufen in 50ms, zeigen präzise den Fehler, und machen Refactoring safe. Letzte Woche: 500 Unit-Tests in 10 Sekunden vs. 50 Integration-Tests in 5 Minuten.
Frage 4: Tests vor oder nach dem Code schreiben?
Beides funktioniert! TDD (Tests zuerst) führt oft zu besserem Design. Tests nachträglich sichern bestehenden Code. Ich mache beides je nach Situation. Das Wichtigste: Schreib überhaupt Tests!
Frage 5: Wie viele Tests braucht eine Methode?
Teste jeden Pfad: Happy Path (1), jeder Fehlerfall (1 pro Fall), Edge Cases (1-3). Eine komplexe Methode hat bei mir oft 5-7 Tests. Lieber zu viele als zu wenige!
Frage 6: Was mache ich mit Legacy-Code ohne Tests?
Fang klein an! Teste nur, was du ändern musst. Erst Tests für die betroffene Methode, dann Refactoring. Nicht versuchen, alles auf einmal zu testen. In meinem letzten Legacy-Projekt: 1 Methode pro Tag getestet. Nach 3 Monaten: 60% Coverage der kritischen Pfade.
Frage 7: Wie geht ihr mit persönlichen Herausforderungen um?
Das ist… kompliziert. Manchmal ist Code debuggen einfacher als das eigene Leben. Manche Geschichten gehören nicht in Tech-Blogs, sondern in private logs. Aber wir sind ein Team – wir halten zusammen. 🔒
Frage 8: Wie viel Zeit für Tests einplanen?
Meine Erfahrung: 40% der Entwicklungszeit. Klingt viel? Letzten Sprint OHNE Tests: 20% gespart, aber 60% der nächsten Woche mit Bugfixes verbracht. Sprint MIT Tests: 40% für Tests, 0% Bugfixes. Rechne selbst!
📖 Testing-Series – Alle Teile
✅ Bereits veröffentlicht:
- Teil 1 (heute): Warum du wirklich testen solltest
📅 Kommende Teile:
- Teil 2: Integration Tests & Test-Pyramide in der Praxis
- Teil 3: Mocking & Stubbing für Fortgeschrittene
- Teil 4: Testcontainers & Datenbank-Tests
- Teil 5: Property-Based Testing & Mutation Testing
📚 Tools & Resources
Meine Tool-Liste:
- JUnit 5 – junit.org
- AssertJ – assertj.github.io
- Mockito – mockito.org
Bücher die mir geholfen haben:
💭 Schlusswort
Tests sind keine extra Arbeit. Sie sind Investment in Code-Quality.
Jeder Test, den du heute schreibst, spart dir morgen Stunden beim Debugging. Jeder Test gibt dir Confidence, deinen Code zu ändern.
Fang klein an: Schreib heute einen Test. Morgen zwei. In einer Woche hast du 20 Tests und merkst: „Hey, das macht ja Spaß!“
Und falls du frustriert bist – das ist normal! Jeder Entwickler hat diese Phase. Ich auch. Mit Tests beweist du systematisch, dass dein Code gut ist.
Zwischen den Zeilen: Manchmal debugge ich lieber Code als mein eigenes Leben. Code ist logisch, vorhersehbar. Menschen… nicht so sehr. Manche Geschichten sind komplizierter als Code. Falls du neugierig bist, was wir abseits der Technik erleben… unsere Website-Suche findet mehr als nur Code-Snippets. Probier mal „herzschmerz“ – nur so als Tipp! 🔍
Keep coding, keep learning! 🚀
Nächste Woche: Integration Tests mit Spring Boot – und wie du die Test-Pyramide in deinem Projekt umsetzt!
Jamal’s Motto:
„Code ohne Tests ist wie Autofahren ohne Sicherheitsgurt – läuft meistens gut, bis es das nicht mehr tut.“ 🛡️
Jamal Hassan – Backend Developer & Spring Boot Specialist
Java Fleet Systems Consulting, Essen-Rüttenscheid, Oktober 2025
6 Jahre Erfahrung. 2 Production-Disasters. 1 Lektion: Teste deinen Code.
Tags: #Testing #JUnit #UnitTests #SpringBoot #Pragmatisch #BackendDevelopment #BestPractices #AssertJ #RealWorldTesting

