Testing-Time-Travel-Serie
Von Dr. Cassian Holt & Jamal Hassan
Java Fleet Systems Consulting, Essen-Rüttenscheid


🔗 Bisher in der Serie

Heute: Teil 6 – Integration Testing mit Spring Boot 🔧


⚡ 30-Sekunden-Zusammenfassung

Integration Testing:

  • @SpringBootTest richtig nutzen (ohne 5-Minuten-Tests)
  • Testcontainers für echte Datenbanken
  • @DataJpaTest vs. @WebMvcTest – wann was?
  • Test-Slicing für schnellere Tests

Bottom Line: Integration-Tests sind langsamer als Unit-Tests, aber du brauchst sie trotzdem. Wir zeigen dir, wie du sie schnell UND aussagekräftig machst.


🔧 Hi, Jamal hier!

Moin! Jamal hier. Diese Woche geht’s um Integration-Tests und AI-Testing das Thema, bei dem die meisten Entwickler die Augen verdrehen.

Warum? Weil Integration-Tests oft langsam, flaky und schwer zu debuggen sind. Ich kenne das. Ich hatte mal ein Projekt, wo die Integration-Tests 45 Minuten liefen. Ja, 45 Minuten. Niemand wollte sie laufen lassen. Nach einem Monat waren sie komplett ignoriert.

Heute zeige ich dir, wie du das vermeidest.

Meine Story: Der Tag, an dem ich Integration-Tests schätzen lernte

2021, kurz nachdem ich zu Java Fleet kam. Wir hatten eine Spring Boot App mit einer PostgreSQL-Datenbank. Ich hatte super Unit-Tests geschrieben. Alle grün. Alles gut.

Dann deployen wir nach Staging. Und die App startet nicht. BeanCreationException. InvalidDataAccessApiUsageException. Chaos.

Was war das Problem?

Meine Unit-Tests hatten alles gemockt. Ich hatte nie getestet, ob meine JPA-Repositories wirklich mit PostgreSQL funktionieren. Oder ob meine Spring-Configuration korrekt war. Oder ob die Transaktionen richtig laufen.

Der Lernmoment:

Unit-Tests testen deine Logik. Integration-Tests testen, ob deine Komponenten zusammenspielen. Beides ist wichtig.

Seitdem: Jedes Feature bekommt Unit-Tests UND Integration-Tests. Und die Integration-Tests laufen in unter 2 Minuten.


📊 Integration Testing 101: Was sind Integration-Tests?

Definition (Jamal-Style)

Unit-Test: „Funktioniert meine Klasse isoliert?“
Integration-Test: „Funktionieren meine Klassen zusammen?“

Praktisches Beispiel:

// Unit-Test: TaskService isoliert testen
@ExtendWith(MockitoExtension.class)
class TaskServiceUnitTest {
    
    @Mock
    private TaskRepository taskRepository;
    
    @Mock
    private EmailService emailService;
    
    @InjectMocks
    private TaskService taskService;
    
    @Test
    void shouldCreateTask() {
        // Alles gemockt - sehr schnell!
        when(taskRepository.save(any())).thenReturn(testTask);
        
        Task result = taskService.createTask(request);
        
        assertThat(result).isNotNull();
    }
}

// Integration-Test: TaskService + Repository + Database
@SpringBootTest
@Transactional
class TaskServiceIntegrationTest {
    
    @Autowired
    private TaskService taskService;
    
    @Autowired
    private TaskRepository taskRepository;
    
    @Test
    void shouldSaveTaskToRealDatabase() {
        // Nichts gemockt - echte DB!
        CreateTaskRequest request = new CreateTaskRequest("Test", "Description");
        
        Task created = taskService.createTask(request);
        
        // Verify: Task ist wirklich in der DB
        Task fromDb = taskRepository.findById(created.getId()).orElseThrow();
        assertThat(fromDb.getTitle()).isEqualTo("Test");
    }
}

Der Unterschied:

  • Unit-Test: 10ms, findet Logik-Bugs
  • Integration-Test: 500ms, findet Integration-Bugs

Wann brauchst du Integration-Tests?

Immer wenn:

  • ✅ Du mit einer Datenbank arbeitest
  • ✅ Du externe Services callst
  • ✅ Du Spring-Configuration testest
  • ✅ Du Transaktions-Verhalten prüfst
  • ✅ Du Security-Setup validierst

Nicht nötig wenn:

  • ❌ Pure Business-Logik (Unit-Tests reichen)
  • ❌ Einfache Utilities (zu schnell für Integration)
  • ❌ Algorithmen ohne Dependencies

🚀 @SpringBootTest: Der Complete Spring Context

Das Basics-Setup

@SpringBootTest
class TaskControllerIntegrationTest {
    
    @Autowired
    private TaskController controller;
    
    @Test
    void contextLoads() {
        // Test 1: Spring Context startet überhaupt
        assertThat(controller).isNotNull();
    }
}

Was passiert hier?

  1. Spring Boot startet KOMPLETT (alle Beans, ganze Configuration)
  2. Datenbank wird hochgefahren (H2 in-memory per default)
  3. Controller, Services, Repositories – alles echt
  4. Dauert 3-5 Sekunden

Das Problem: Zu langsam!

Cassian würde jetzt sagen: „Das ist thermodynamisch ineffizient!“

Ich sage: „Wenn jeder Test 5 Sekunden braucht, läuft keiner sie mehr.“

Die Lösung: Test Slicing


✂️ Test Slicing: Nur laden, was du brauchst

Spring Boot bietet spezialisierte Test-Annotationen, die nur Teile der App laden.

@WebMvcTest – Nur Controller testen

@WebMvcTest(TaskController.class)
class TaskControllerWebTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean  // Dieser Service wird gemockt!
    private TaskService taskService;
    
    @Test
    void shouldReturnTasksViaHttp() throws Exception {
        // Arrange
        Task task = new Task(1L, "Test Task");
        when(taskService.getAllTasks()).thenReturn(List.of(task));
        
        // Act & Assert
        mockMvc.perform(get("/api/tasks"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$[0].title").value("Test Task"));
    }
}

Was wird geladen:

  • ✅ Web Layer (Controller, Request/Response Mapping)
  • ✅ MockMvc für HTTP-Testing
  • ❌ Services (müssen gemockt werden)
  • ❌ Datenbank
  • ❌ Security (außer du aktivierst es)

Speed: ~1 Sekunde statt 5

@DataJpaTest – Nur Repository testen

@DataJpaTest
class TaskRepositoryTest {
    
    @Autowired
    private TestEntityManager entityManager;
    
    @Autowired
    private TaskRepository taskRepository;
    
    @Test
    void shouldFindTasksByStatus() {
        // Arrange: Task in DB schreiben
        Task task = new Task("Test", TaskStatus.PENDING);
        entityManager.persistAndFlush(task);
        
        // Act: Repository-Methode aufrufen
        List<Task> pending = taskRepository.findByStatus(TaskStatus.PENDING);
        
        // Assert
        assertThat(pending).hasSize(1);
        assertThat(pending.get(0).getTitle()).isEqualTo("Test");
    }
}

Was wird geladen:

  • ✅ JPA Repositories
  • ✅ In-Memory H2 Database
  • ✅ Transaction Management
  • ❌ Services
  • ❌ Controller
  • ❌ Security

Speed: ~800ms

Jamal’s Faustregel: Wann welche Annotation?

Du testest…Verwende…Warum?
REST-Endpoints@WebMvcTestNur Web Layer, schnell
Database Queries@DataJpaTestNur Repository, schnell
Service + DB@SpringBootTestVoller Context nötig
Security Config@SpringBootTestVoller Context nötig
Async Processing@SpringBootTestVoller Context nötig

🐳 Testcontainers: Echte Datenbanken für Tests

Das Problem mit H2

// Test läuft mit H2
@DataJpaTest
class TaskRepositoryTest {
    
    @Test
    void shouldFindByComplexQuery() {
        // Test ist grün mit H2
        List<Task> tasks = taskRepository.findByComplexPostgreSQLQuery();
        assertThat(tasks).hasSize(5);
    }
}

Dann in Production mit PostgreSQL:

ERROR: syntax error at or near "LIMIT"

Warum? Weil H2 nicht 100% PostgreSQL-kompatibel ist. Especially bei:

  • Native Queries
  • Database-spezifischen Features
  • JSON-Columns
  • Full-Text-Search

Die Lösung: Testcontainers

Testcontainers startet einen echten PostgreSQL Docker-Container für deine Tests.

Setup (Maven):

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.19.3</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <version>1.19.3</version>
    <scope>test</scope>
</dependency>

Verwendung:

@SpringBootTest
@Testcontainers
class TaskRepositoryPostgresTest {
    
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");
    
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }
    
    @Autowired
    private TaskRepository taskRepository;
    
    @Test
    void shouldWorkWithRealPostgreSQL() {
        // Jetzt mit ECHTEM PostgreSQL!
        Task task = new Task("Test", "Description");
        Task saved = taskRepository.save(task);
        
        assertThat(saved.getId()).isNotNull();
        
        // Native PostgreSQL Query funktioniert
        List<Task> result = taskRepository.findByPostgreSQLSpecificQuery();
        assertThat(result).isNotEmpty();
    }
}

Was passiert:

  1. Test startet → Docker Container wird hochgefahren
  2. PostgreSQL läuft (~5 Sekunden beim ersten Mal)
  3. Spring verbindet sich mit Container
  4. Test läuft gegen ECHTE PostgreSQL
  5. Test endet → Container wird gestoppt

Performance-Trick:

@SpringBootTest
@Testcontainers
class FastPostgresTest {
    
    // static = Container wird für ALLE Tests wiederverwendet!
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
        .withReuse(true);  // Container bleibt nach Tests am Leben
    
    // Erste Test-Klasse: 5 Sekunden
    // Weitere Test-Klassen: <1 Sekunde (Container-Reuse!)
}

Jamal’s Testcontainers-Strategie

Meine Regel:

  • Unit-Tests: Keine DB
  • Repository-Tests: @DataJpaTest mit H2 (schnell)
  • Integration-Tests (kritische Queries): Testcontainers mit PostgreSQL
  • E2E-Tests: Testcontainers

Warum nicht alles mit Testcontainers?

Weil 100 Tests mit Testcontainers = 10 Minuten. 100 Tests mit H2 = 30 Sekunden.

Use Testcontainers nur für Tests, wo H2 nicht ausreicht.


🌐 HTTP-Testing mit MockMvc

REST-Endpoints testen ohne Server

@SpringBootTest
@AutoConfigureMockMvc
class TaskApiIntegrationTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @Autowired
    private TaskRepository taskRepository;
    
    @Test
    @WithMockUser(username = "test", roles = "USER")
    void shouldCreateTaskViaApi() throws Exception {
        // Arrange
        String taskJson = """
            {
                "title": "New Task",
                "description": "Test Description",
                "status": "PENDING"
            }
            """;
        
        // Act
        MvcResult result = mockMvc.perform(post("/api/tasks")
                .contentType(MediaType.APPLICATION_JSON)
                .content(taskJson))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").exists())
            .andExpect(jsonPath("$.title").value("New Task"))
            .andReturn();
        
        // Assert: Task ist in DB
        String responseBody = result.getResponse().getContentAsString();
        Long taskId = JsonPath.read(responseBody, "$.id");
        
        Task saved = taskRepository.findById(taskId).orElseThrow();
        assertThat(saved.getTitle()).isEqualTo("New Task");
    }
    
    @Test
    @WithMockUser(username = "test", roles = "USER")
    void shouldReturn404ForNonExistentTask() throws Exception {
        mockMvc.perform(get("/api/tasks/999"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.error").value("Task not found"));
    }
}

Was ich hier mag:

  • Testet HTTP Layer (Request Mapping, Status Codes, JSON)
  • Testet Security (mit @WithMockUser)
  • Validiert DB-Interaktion
  • Kein echter Server nötig (MockMvc simuliert)

<details> <summary>📚 <strong>Cassian’s Deep-Dive: Die Thermodynamik von Integration-Tests</strong></summary>

Cassian hier.

Jamal hat vorhin „thermodynamisch ineffizient“ gesagt. Das war nur halb-ironisch. Lass mich das erklären.

Test-Execution als thermodynamisches System:

Unit-Tests sind wie isolierte Partikel: O(1) Komplexität. Jeder Test ist unabhängig, vorhersagbar, deterministisch.

Integration-Tests sind wie interagierende Systeme: O(n) bis O(n²) Komplexität. Je mehr Komponenten, desto mehr potenzielle Interaktionen.

Die Mathematik:

Wenn du N Komponenten hast:

  • Unit-Tests: N Tests (linear)
  • Integration-Tests: N×(N-1)/2 potenzielle Interaktionen (quadratisch!)

Deshalb Jamal’s 70/20/10-Verteilung: Unit-Tests skalieren linear, Integration-Tests nicht.

Das Spring-Context-Problem:

Spring Boot Context-Loading ist wie ein Phasenübergang in der Physik. Es braucht Energie (Zeit), den ganzen Context zu initialisieren. Test Slicing (@WebMvcTest, @DataJpaTest) ist wie Teil-System-Analyse: Du testest Subsysteme statt das Gesamtsystem.

Thermodynamisch optimal: Minimize State, Maximize Information. Das ist Test Slicing. </details>


🎯 Praxis: Complete Integration-Test-Suite

Hier ist, wie ich eine komplette Feature-Test-Suite aufbaue:

Feature: Task Management

// 1. Unit-Tests für Business-Logik (70%)
@ExtendWith(MockitoExtension.class)
class TaskServiceUnitTest {
    
    @Mock TaskRepository repository;
    @Mock EmailService emailService;
    @InjectMocks TaskService taskService;
    
    @Test
    void shouldValidateTaskTitle() {
        // Business-Logik isoliert testen
    }
    
    @Test
    void shouldSendEmailOnTaskCompletion() {
        // Email-Integration gemockt
    }
}

// 2. Repository-Tests mit H2 (15%)
@DataJpaTest
class TaskRepositoryTest {
    
    @Autowired TaskRepository repository;
    @Autowired TestEntityManager entityManager;
    
    @Test
    void shouldFindTasksByStatus() {
        // Schnelle Repository-Tests mit H2
    }
}

// 3. Integration-Tests mit Testcontainers (10%)
@SpringBootTest
@Testcontainers
class TaskServicePostgresIntegrationTest {
    
    @Container
    static PostgreSQLContainer<?> postgres = /*...*/;
    
    @Autowired TaskService taskService;
    
    @Test
    void shouldHandleComplexTransactionScenarios() {
        // Kritische Szenarien mit echter DB
    }
}

// 4. API-Tests mit MockMvc (5%)
@SpringBootTest
@AutoConfigureMockMvc
class TaskApiIntegrationTest {
    
    @Autowired MockMvc mockMvc;
    
    @Test
    @WithMockUser
    void shouldCreateTaskViaApi() {
        // E2E für kritische User-Flows
    }
}

Resultat:

  • 50 Unit-Tests: 5 Sekunden
  • 10 Repository-Tests: 3 Sekunden
  • 5 Integration-Tests mit Testcontainers: 15 Sekunden (Container-Reuse!)
  • 3 API-Tests: 5 Sekunden

Total: 28 Sekunden für 68 Tests


💡 Action Items – Deine Integration-Test-Strategie

Level 1: Basics (Diese Woche)

  • [ ] Einen @SpringBootTest für deine wichtigste Feature schreiben
  • [ ] @DataJpaTest für dein komplexestes Repository
  • [ ] MockMvc für deinen wichtigsten REST-Endpoint

Level 2: Optimization (Nächste Woche)

  • [ ] Test Slicing einführen (@WebMvcTest, @DataJpaTest)
  • [ ] Testcontainers Setup für PostgreSQL/MySQL
  • [ ] Test-Execution-Zeit messen und optimieren

Level 3: Mastery (Nächster Monat)

  • [ ] Komplette Integration-Test-Suite für ein Feature
  • [ ] Container-Reuse für schnellere Tests
  • [ ] CI/CD Integration mit parallelen Tests

💬 Cassian & Jamal’s Diskussion

Cassian: Jamal, wie entscheidest du, ob ein Test ein Unit-Test oder Integration-Test sein soll?

Jamal: Gute Frage. Meine Regel: Wenn ich mehr als eine echte Komponente brauche, ist es Integration. Aber ich fange immer mit Unit-Tests an.

Cassian: Interessant. Aus theoretischer Sicht ist die Grenze fließend. Michael Feathers definiert Unit-Tests als „Tests, die schnell laufen, isoliert sind und keine externe Dependencies haben“. Alles andere ist Integration.

Jamal: Das ist mir zu abstrakt. Ich denke pragmatisch: Brauche ich Spring? Brauche ich eine DB? Dann Integration. Sonst Unit.

Cassian: Fair enough. Aber was machst du mit Services, die andere Services callen?

Jamal: Mocken. Wenn Service A Service B callt, dann mocke ich B im Unit-Test von A. Im Integration-Test lasse ich beide echt laufen.

Cassian: Und wenn Service B eine kritische Dependency ist?

Jamal: Dann schreibe ich einen zusätzlichen Integration-Test. Aber Unit-Tests bleiben gemockt. Das ist der Unterschied: Unit = isoliert + schnell. Integration = realistisch + langsamer.

Cassian: Das ist methodisch sauber. Test-Pyramide in Aktion.

Jamal: Genau.


❓ FAQ

1. @SpringBootTest vs. @DataJpaTest – wann was?

Jamal: Einfach: @DataJpaTest wenn du nur Repositories testest. @SpringBootTest wenn du Services + Repositories + mehr brauchst. DataJpaTest ist 5x schneller.

2. Muss ich Testcontainers verwenden?

Cassian: Nicht zwingend, aber empfohlen für Production-nahe Tests. H2 ist okay für einfache Queries, aber native SQL oder DB-spezifische Features brauchest du echte Datenbanken.

Jamal: Meine Regel: 80% mit H2, 20% mit Testcontainers für kritische Queries.

3. Wie halte ich Integration-Tests schnell?

Jamal:

  1. Test Slicing (@WebMvcTest, @DataJpaTest)
  2. Container-Reuse für Testcontainers
  3. Parallele Execution mit Maven/Gradle
  4. Nur kritische Szenarien als Integration-Tests

4. @Transactional in Tests – Ja oder Nein?

Cassian: Per default macht @DataJpaTest automatisch Rollback nach jedem Test. Bei @SpringBootTest musst du @Transactional manuell setzen, wenn du Rollback willst.

Jamal: Ich nutze @Transactional fast immer. Tests sollen die DB nicht verschmutzen. Ausnahme: Wenn ich explizit Commit-Verhalten testen will.

5. MockMvc vs. TestRestTemplate – was ist besser?

Jamal: MockMvc für @WebMvcTest (schneller, kein Server). TestRestTemplate für @SpringBootTest mit richtigem Server. Ich bevorzuge MockMvc, weil schneller.

6. Wie teste ich Async-Processing?

Cassian: Mit @Async brauchst du @SpringBootTest und Awaitility:

@Test
void shouldProcessAsync() {
    taskService.processAsync(task);
    
    await().atMost(5, SECONDS)
        .untilAsserted(() -> {
            Task result = taskRepository.findById(task.getId()).orElseThrow();
            assertThat(result.getStatus()).isEqualTo(TaskStatus.COMPLETED);
        });
}

7. Was ist mit Flaky Integration-Tests?

Jamal: Die häufigsten Gründe:

  • Timing-Probleme (Async ohne await)
  • Shared State zwischen Tests (fehlende @Transactional)
  • Port-Konflikte (Testcontainers-Port-Collision)
  • Netzwerk-Timeouts

Lösung: Transactional Rollback, await() für Async, Container-Reuse.

8. Manchmal denke ich, Integration-Tests sind mehr Arbeit als sie wert sind. Aber dann erinnere ich mich an Production-Bugs, die nur Integration-Tests gefunden hätten…

Falls du dich fragst, wie wir mit Testing-Frust umgehen – manche Geschichten gehören in private logs. Aber hey, wenn du neugierig bist: Unsere Website-Suche findet mehr als nur Code-Patterns. 🔍


📝 Zusammenfassung

Integration-Testing:

  • Teste Komponenten-Interaktionen, nicht nur isolierte Logik
  • Spring bietet Test Slicing für schnellere Tests
  • Testcontainers für echte Datenbanken bei kritischen Tests
  • MockMvc für HTTP-Layer ohne Server

Jamal’s Bottom Line:

Integration-Tests fühlen sich langsam an. Aber mit Test Slicing, Container-Reuse und smarter Strategie (70/20/10) sind sie handhabbar. Und sie fangen Bugs, die Unit-Tests niemals finden.

Cassian’s Bottom Line:

Integration-Testing ist empirische System-Analyse. Du testest emergente Eigenschaften komplexer Systeme. Das ist methodisch notwendig, auch wenn es thermodynamisch teurer ist als Unit-Tests.


🔮 Nächste Woche: Teil 7 – Das Testing-Mastery-Toolkit

Beide übernehmen 👥

Das große Finale:

  • 🎁 Complete Testing Cheat Sheet (PDF Download)
  • 📊 Test-Strategy Decision Tree
  • 🛠️ CI/CD Integration Guide
  • 🏆 Java Fleet Testing Certificate

Plus: Die ultimative Testing-Ressourcen-Sammlung!

Cassian: „Wir packen alles zusammen, was du für Testing-Mastery brauchst.“

Jamal: „Und ich zeige dir, wie du das im echten Projekt umsetzt. Pragmatisch, nicht theoretisch.“

Cassian: „Fair enough.“

Bis nächste Woche! 👋


📚 Serie-Übersicht


Java Fleet Systems Consulting, Essen-Rüttenscheid
Pragmatische Software-Entwicklung mit wissenschaftlichem Fundament


P.S. von Jamal: Integration-Tests haben mir mehr Production-Bugs erspart als ich zählen kann. Der Invest lohnt sich. Versprochen.

P.P.S. von Cassian: Die Test-Pyramide ist nicht nur Konvention – sie ist mathematisch optimal für Test-Suites. Die Thermodynamik lügt nicht.

Autor

  • Cassian Holt

    43 Jahre alt, promovierter Informatiker mit Spezialisierung auf Programming Language Theory. Cassian arbeitet als Senior Architect bei Java Fleet Systems Consulting und bringt eine einzigartige wissenschaftliche Perspektive in praktische Entwicklungsprojekte. Seine Leidenschaft: Die Evolution von Programmiersprachen und warum "neue" Features oft alte Konzepte sind.