6. Testing‐Strategie - Dxme98/Dokumentation GitHub Wiki

Das Projekt wird durch > 300 Tests abgesichert, die eine Testabdeckung (Code Coverage) von ~80% gewährleisten.

Die Strategie gliedert sich in drei klare Ebenen, um schnelles Feedback (Unit-Tests) mit Zuverlässigkeit (Integrationstests) zu balancieren:

  1. Unit-Tests: Testen isolierte Logik in Entitäten und Utilities.
  2. Controller-Tests: Sichern die API-Schicht (@WebMvcTest).
  3. Service-Integrationstests: Validieren den gesamten Business-Flow gegen eine echte Datenbank (mit Testcontainers und Custom Slices).

1. Unit- & Mock-Tests (Entitäten & Domain-Logik)

Das Fundament bilden schnelle Unit-Tests mit JUnit5 und Mockito.

  • Ziel: Testen von isolierter Business-Logik innerhalb der Domain-Entitäten (folgend einem leichten Domain-Driven Design-Ansatz).
  • Setup: Alle Abhängigkeiten (wie andere Entitäten oder Repositories) werden vollständig weggemockt (@Mock). Die Tests laufen in Millisekunden, da kein Spring-Context geladen wird.

Der ProjectInviteTests-Test ist ein perfektes Beispiel. Er validiert die internen Zustandsänderungen (State) und "Guardrail"-Methoden der ProjectInvite-Entität. Die Tests sind logisch mit @Nested gruppiert.

Code-Beispiel (ProjectInviteTests.java)

@Nested
@DisplayName("Accept Invite Tests")
class AcceptInviteTests {

    // ... (Setup mit @Mock UserEntity, @Mock Project) ...

    @Test
    @DisplayName("Should accept invite successfully (Happy Path)")
    void shouldAcceptInviteSuccessfully() {
        // Aktion
        projectInvite.accept("receiver456");

        // Verifizierung
        assertEquals(ProjectInviteStatus.ACCEPTED, projectInvite.getInviteStatus());
    }

    @Test
    @DisplayName("Should throw exception when wrong user tries to accept (Sad Path)")
    void shouldThrowExceptionWhenWrongUserTriesToAccept() {
        // Verifizierung, dass die interne Logik eine Exception wirft
        assertThrows(UnauthorizedInviteHandleAcception.class,
                () -> projectInvite.accept("wronguser789"));
    }

    @Test
    @DisplayName("Should throw exception when invite is not pending (Edge Case)")
    void shouldThrowExceptionWhenInviteIsNotPending() {
        // State für den Test manipulieren (nur im Unit-Test möglich)
        ReflectionTestUtils.setField(projectInvite, "inviteStatus", ProjectInviteStatus.DECLINED);

        assertThrows(InviteIsNotPendingException.class,
                () -> projectInvite.accept("receiver456"));
    }
}

2. Controller-Tests (@WebMvcTest)

Diese Ebene sichert die API-Schnittstelle (den Controller-Layer) ab.

  • Ziel: Sicherstellen, dass die HTTP-Schicht korrekt funktioniert.
  • Technologie: @WebMvcTest. Dies ist ein "Slice-Test", der nur die Web-Schicht lädt (Controller, JSON-Konverter, Security-Filter).
  • Wichtig: Der gesamte Business-Layer (alle @Service- und @Repository-Klassen) wird nicht geladen. Die Service-Schicht wird stattdessen per @MockitoBean vollständig gemockt.

Was wird hier getestet?

Diese Tests sind extrem schnell und validieren vier Dinge:

  1. JSON-Serialisierung: Wird der ankommende JSON-Request (ProjectRequest) korrekt in ein Java-Objekt umgewandelt?
  2. HTTP-Statuscodes: Sendet der Endpunkt den korrekten Status (z.B. 201 Created oder 200 OK)?
  3. JSON-Deserialisierung: Wird das Java-Response-Objekt (ProjectOverviewDto) korrekt in den JSON-Response umgewandelt?
  4. Security: Wird der Endpunkt korrekt durch Spring Security (JWT-Validierung) geschützt?

Code-Beispiel: ProjectControllerWebMvcTest

Der ProjectControllerWebMvcTest demonstriert diese Strategie.

// 1. Der "Slice": Lade *nur* den ProjectController
@WebMvcTest(controllers = ProjectController.class)
class ProjectControllerWebMvcTest {

    @Autowired
    private MockMvc mockMvc; // 2. Das Tool, um "falsche" HTTP-Anfragen zu senden

    @Autowired
    private ObjectMapper objectMapper; // Für die JSON-Umwandlung

    // 3. Der Mock: Ersetze den echten Service durch einen Mock
    @MockitoBean
    private ProjectService projectService;

    private static final String API_BASE_URL = "/api/v1/projects";
    private String testUserId = "user-id-123";

    @Test
    @DisplayName("POST /projects - Sollte 201 Created zurückgeben...")
    void createProject_whenValidRequest_shouldReturn201AndProject() throws Exception {
        
        // Given: Definiere das Verhalten des Mocks
        ProjectRequest request = new ProjectRequest("Neues Projekt", ProjectType.SCRUM);
        ProjectOverviewDto mockResponse = ProjectOverviewDto.builder().id(1L).build();
        when(projectService.createProject(any(), any())).thenReturn(mockResponse);

        // When: Simuliere den HTTP-Aufruf
        mockMvc.perform(post(API_BASE_URL)
                        // 4. SECURITY-TEST: Simuliere ein gültiges JWT
                        .with(jwt().jwt(jwt -> jwt.claim("sub", testUserId))) 
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                
                // Then: Prüfe das HTTP-Ergebnis
                .andExpect(status().isCreated()) // 5. Status-Code korrekt?
                .andExpect(jsonPath("$.id").value(1L)); // 6. JSON-Antwort korrekt?

        // 7. Verify: Hat der Controller den Service korrekt aufgerufen?
        verify(projectService).createProject(any(), any());
    }
}

3. Service-Integrationstests

Integrationstests sind ein zentraler Teil der Qualitätssicherung. Sie validieren die gesamte Business-Logik (von der Service-Schicht bis zur Datenbank) als Einheit.

  • Ziel: Den echten "Happy Path" und "Sad Path" (z.B. Berechtigungsfehler, Duplikate) der Service-Schicht gegen eine echte Datenbank zu testen.

Die Herausforderung

  1. Realismus: Mocks und In-Memory-Datenbanken (wie H2) lügen. Sie verhalten sich anders als PostgreSQL (z.B. bei JSON-Typen oder SQL-Dialekten).
  2. Geschwindigkeit: Ein voller @SpringBootTest (der die gesamte Anwendung lädt) ist extrem langsam und macht die Test-Suite unbrauchbar.

Die Strategie löst beide Probleme durch eine Kombination aus Testcontainers und Custom "Test-Slices".

Die hier verwendete Strategie (Kombination aus Testcontainers und Custom "Test Slices") ist inspiriert von einem Vortrag auf dem Spring Developer Channel zur Optimierung von Test-Kontexten. https://www.youtube.com/watch?v=2bTAb-2vhBk&t


1. Realismus durch Testcontainers

Alle Integrationstests laufen gegen einen echten PostgreSQL-Docker-Container, der von Testcontainers verwaltet wird.

  • Vorteil: Die Tests werden gegen exakt dieselbe Datenbanktechnologie wie in der Produktion ausgeführt.
  • Implementierung: Eine BaseTestContainerConfig definiert und startet den Container für alle Tests, die davon erben.
// Die Basis-Konfiguration für *alle* Integrationstests
@Testcontainers
@Transactional // Rollt die DB-Änderungen nach jedem Test zurück
@ActiveProfiles("test")
@Import(TestContainersConfiguration.class) // Definiert den Container
public abstract class BaseTestContainerConfig {

    @Container
    static PostgreSQLContainer<?> postgres = TestContainersConfiguration.postgresContainer();
}

2. Geschwindigkeit durch Custom "Test Slices"

Um das langsame Laden des gesamten Spring-Contexts zu verhindern, werden modul-spezifische Test-Slices verwendet. Anstatt @SpringBootTest ohne Argumente zu verwenden, wird dem Test genau gesagt, welche Klassen er für den jeweiligen Anwendungsfall laden soll.

Beispiel: Um das Scrum-Modul zu testen, muss der Context nichts über das "Basic"-Modul wissen.

Implementierung: Abstrakte Basisklassen (z.B. ScrumBaseTest) werden erstellt. Sie erben von der Testcontainers-Config und laden über @SpringBootTest(classes = {...}) nur die relevanten Beans für dieses Modul.

Beispiel (ScrumBaseTest.java)

// Dieser Test lädt NICHT die ganze App, sondern nur das "Scrum-Slice"
@EnableDatabaseTest // (Custom Annotation, siehe unten)
@SpringBootTest(classes = {
        // Lade nur diese spezifischen Services und Mapper:
        ProjectAccessService.class,
        ScrumBoardServiceImpl.class,
        SprintServiceImpl.class,
        UserStoryServiceImpl.class,
        UserStoryMapper.class,
        SprintMapper.class,
        // ... (etc.)
})
@Import(TestDataFactory.class) // Helfer zum Erstellen von Test-Daten
public abstract class ScrumBaseTest extends BaseTestContainerConfig {
    // ...
}

Dies ergibt für die gesamte Service-Test-Suite folgende Vererbungsstruktur:

Die @EnableDatabaseTest Meta-Annotation (Code-Hygiene)

Um die "Test-Slice"-Basisklassen (wie ScrumBaseTest oder ProjectManagementBaseTest) sauber und frei von Konfigurations-Boilerplate zu halten, wurde eine eigene Meta-Annotation @EnableDatabaseTest erstellt.

Das Problem: Jede Test-Basisklasse benötigt exakt dieselbe JPA-Konfiguration (Wo sind die Repositories? Wo sind die Entities? Nutze Testcontainers, nicht H2).

Die Lösung: Diese 6+ Zeilen Konfiguration werden in einer einzigen, wiederverwendbaren Annotation gebündelt.


Dies ist die gesamte Konfiguration, die für jeden Datenbank-Integrationstest benötigt wird:

/**
 * Eigene Meta-Annotation, die den Spring-Context für einen 
 * Integrationstest mit FOKUS auf die Datenbank-Schicht konfiguriert.
 */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)

// 1. Sagt Spring, wo die Repository-Interfaces zu finden sind
@EnableJpaRepositories(basePackages = {"com.dev.tasktrackr...repository"})

// 2. Sagt Spring, wo die @Entity-Klassen zu finden sind
@EntityScan(basePackages = {"com.dev.tasktrackr...domain"})

// 3. Importiert Konfigurationen (z.B. für @CreatedDate)
@Import(JpaAuditingConfig.class) 

// 4. Konfiguriert die JPA-Test-Infrastruktur
@AutoConfigureDataJpa 
@AutoConfigureTestEntityManager

// 5. Verhindert, dass Spring die Testcontainers-DB durch H2 ersetzt
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) 
public @interface EnableDatabaseTest {
}

3. Das Ergebnis: Der finale Integrationstest

Der eigentliche Test ist jetzt sauber, schnell und extrem aussagekräftig. Er erbt von ScrumBaseTest (erhält den schnellen Slice-Context und die echte DB-Verbindung) und kann sich zu 100% auf die Validierung der Business-Logik konzentrieren.

Code-Beispiel (UserStoryServiceIntegrationTest.java):

@DisplayName("UserStoryService Integration Tests")
public class UserStoryServiceIntegrationTest extends ScrumBaseTest {

    @Autowired
    private UserStoryService userStoryService; // Der ECHTE Service
    @Autowired
    private UserStoryRepository userStoryRepository; // Das ECHTE Repository

    private UserEntity testUser;
    private Project scrumProject;
    
    @BeforeEach
    void setUp() {
        // ARRANGE: Die TestDataFactory bereitet die ECHTE DB vor
        testUser = testDataFactory.createTestUser("storyUser123", "storyUser");
        scrumProject = testDataFactory.createTestProject("Scrum Project", ProjectType.SCRUM, testUser);
    }

    @Test
    @DisplayName("Sollte User Story erfolgreich erstellen (Happy Path)")
    void shouldCreateUserStorySuccessfully() {
        // ARRANGE
        CreateUserStoryRequest validRequest = new CreateUserStoryRequest(...);

        // ACT: Rufe den ECHTEN Service auf
        UserStoryResponseDto result = userStoryService.createUserStory(...);

        // ASSERT (Echte DB-Prüfung):
        UserStory savedStory = userStoryRepository.findById(result.getId()).orElseThrow();
        assertEquals("Test UserStoryTitle", savedStory.getTitle());
    }

    @Test
    @DisplayName("Sollte Exception werfen, wenn Benutzer keine Berechtigung hat (Sad Path)")
    void shouldThrowIfUserLacksPermission() {
        // ARRANGE: Erstelle User mit "BASE"-Rolle (ohne Rechte)
        UserEntity memberUser = testDataFactory.createTestUser("memberUser", "member");
        testDataFactory.createTestMember(scrumProject, memberUser); 
        
        // ACT & ASSERT: Erwarte die Exception vom ECHTEN Service-Guardrail
        assertThrows(PermissionDeniedException.class,
                () -> userStoryService.createUserStory(...)
        );
    }
}