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:
- Unit-Tests: Testen isolierte Logik in Entitäten und Utilities.
- Controller-Tests: Sichern die API-Schicht (
@WebMvcTest). - 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@MockitoBeanvollständig gemockt.
Was wird hier getestet?
Diese Tests sind extrem schnell und validieren vier Dinge:
- JSON-Serialisierung: Wird der ankommende JSON-Request (
ProjectRequest) korrekt in ein Java-Objekt umgewandelt? - HTTP-Statuscodes: Sendet der Endpunkt den korrekten Status (z.B. 201 Created oder 200 OK)?
- JSON-Deserialisierung: Wird das Java-Response-Objekt (
ProjectOverviewDto) korrekt in den JSON-Response umgewandelt? - 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
- Realismus: Mocks und In-Memory-Datenbanken (wie H2) lügen. Sie verhalten sich anders als PostgreSQL (z.B. bei JSON-Typen oder SQL-Dialekten).
- 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
BaseTestContainerConfigdefiniert 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(...)
);
}
}