Teststrategie und Testpyramide
Diese Seite beschreibt, was auf welcher Ebene getestet wird und mit welchem Werkzeug. Für die Methode (wie Tests entstehen), siehe TDD-Dokumentation.
Die Ebenen
| Ebene | Werkzeug | Läuft unter | Geschwindigkeit | Docker nötig? |
|---|---|---|---|---|
| Domain-Unit-Tests | JUnit 5, AssertJ | mvn test |
Millisekunden | Nein |
| Application-/Use-Case-Tests | JUnit 5, AssertJ, Mockito | mvn test |
Millisekunden | Nein |
| Web-Slice-Tests | @WebMvcTest, MockMvc |
mvn test |
~1 Sekunde | Nein |
| Architektur-Tests | ArchUnit | mvn test (Modul architecture-tests) |
Sekunden | Nein |
Integrationstests (*IT.java) |
@SpringBootTest, Testcontainers |
mvn verify (Failsafe) |
Sekunden bis Minuten | Ja |
Namenskonvention: *Test.java = schnelle Ebenen (Surefire, mvn test), *IT.java =
Testcontainers-Integrationstests (Failsafe, erst mvn verify). So bleibt die tägliche
TDD-Feedback-Schleife (mvn test) durchgehend im Sekundenbereich, auch wenn das Projekt wächst.
Domain-Unit-Tests
Testen Value Objects und Aggregate direkt, ohne jeden Spring-Kontext. Beispiel: MoneyTest,
AggregateRootTest in common-domain. Das ist die Basis der Pyramide — die meisten Tests in
diesem Projekt liegen hier, weil Geschäftsregeln (Geldbeträge, Kontostatus-Übergänge,
Scoring-Regeln) bewusst in der Domain-Schicht konzentriert sind, nicht in Controllern oder
Repository-Adaptern (siehe ADR-0002).
Application-/Use-Case-Tests
Testen eine Anwendungsfall-Klasse (z. B. OpenAccountUseCase) mit gemockten Ports (Mockito). Kein
Spring-Kontext. Prüfen die Orchestrierung: welche Port-Methoden werden in welcher Reihenfolge
mit welchen Argumenten aufgerufen, welche Domain-Fehler werden auf welche Anwendungsfall-Ergebnisse
abgebildet.
Web-Slice-Tests
@WebMvcTest lädt nur die Web-Schicht (Controller, @ControllerAdvice, JSON-Serialisierung),
mit gemocktem Anwendungsfall dahinter. Prüft HTTP-Status-Codes, Response-Bodies,
Validierungsfehler-Mapping (GlobalExceptionHandler aus common-web) — ohne Datenbank oder
vollen Anwendungskontext.
Architektur-Tests
Siehe architecture-tests und
TDD für Architekten, Abschnitt "Architekturtests als
ausführbare ADRs". Laufen unter mvn test (schnell genug, keine Docker-Abhängigkeit), da sie nur
bereits kompilierte .class-Dateien analysieren.
Integrationstests
@SpringBootTest mit echtem PostgreSQL/Kafka via Testcontainers (siehe ADR-0006), Namensmuster
*IT.java. Zwei Hauptkategorien:
- Persistenz-Integrationstests:
AccountRepositoryAdapterITprüft, dass der JPA-Adapter das Domain-Port-Interface korrekt gegen eine echte PostgreSQL erfüllt (inkl. Flyway-Migrationen). - Messaging-Integrationstests: prüfen, dass ein Event tatsächlich auf dem erwarteten
Kafka-Topic ankommt bzw. korrekt konsumiert und verarbeitet wird (inkl. Idempotenz bei
doppelter Zustellung, siehe
payments-service, Phase 4).
Bekannter Stolperstein: Docker-Desktop-Kontention auf geteilten Entwicklungsmaschinen
Auf einer Maschine, auf der gleichzeitig viele andere Container laufen (CI-Agenten, weitere
Lernprojekte, Observability-Stack), kann die Docker-Desktop-API kurzzeitig mit einer leeren
Antwort auf /info reagieren, obwohl docker ps/docker info in der Kommandozeile normal
funktionieren. Testcontainers meldet das als Could not find a valid Docker environment, obwohl
Docker tatsächlich laeuft. In diesem Fall hilft meist ein einfacher Wiederholungslauf
(mvn verify erneut ausführen) - kein Hinweis auf einen Fehler im eigenen Code. Ein echter Bug
wurde auf dem Weg dorthin trotzdem gefunden: AuroraBankTestContainers.kafka() musste
apache/kafka-native explizit per asCompatibleSubstituteFor(...) als Ersatz fuer
confluentinc/cp-kafka deklarieren, sonst wirft der KafkaContainer-Konstruktor immer eine
IllegalStateException (siehe zugehöriger fix:-Commit) - genau der Unterschied zwischen
Infrastruktur-Flakiness (Retry hilft) und einem echten Defekt (Retry hilft NICHT), den man beim
Lesen von Testcontainers-Fehlermeldungen auseinanderhalten muss.
Weiterer echter Bug, gefunden ueber einen Domain-Test: BigDecimal-Skalierung
Account.accrueInterest() in accounts-service berechnete 1000.00 * 0.02 = 20.0000 (Skalierung
4) und addierte das zu 1000.00 (Skalierung 2) - das Ergebnis 1020.0000 war laut
BigDecimal-/Record-equals() ungleich 1020.00, obwohl fachlich derselbe Betrag gemeint war.
Der Domain-Test accrueInterest_erhoehtSaldoBeiSparkontoUmDenZinssatz deckte das auf; behoben
durch Normalisierung auf currency.getDefaultFractionDigits() im kanonischen Konstruktor von
Money (siehe TDD-Katalog, common-domain-Abschnitt).
Klassisches Beispiel dafuer, dass ein Domain-Test in einem Service (accounts-service) einen
latenten Fehler im gemeinsam genutzten Kern (common-domain) aufdecken kann.
Bekannter Stolperstein: Mockito-Feldinitialisierer vs. @BeforeEach
In vier Anwendungsfall-Tests von lending-service (ScoreCreditApplicationUseCaseTest,
GenerateRepaymentScheduleUseCaseTest, FinalizeLoanDecisionUseCaseTest,
GetLoanApplicationUseCaseTest) wurde die zu testende Anwendungsfall-Klasse zunaechst als
Feld-Initialisierer angelegt: private final XxxUseCase useCase = new XxxUseCase(repository, ...).
Das Ergebnis: jeder Test schlug mit einer NullPointerException fehl, nicht mit seiner
eigentlichen Assertion. Ursache: MockitoExtension injiziert @Mock-Felder erst in einem
JUnit-5-Extension-Callback (postProcessTestInstance), der nach der Feldinitialisierung der
Testinstanz laeuft - der Feld-Initialisierer erfasste also noch ein nicht-injiziertes (null)
Repository. Behoben durch Konstruktion in @BeforeEach statt als Feld (siehe zugehöriger
fix:-Commit im TDD-Katalog). Lehre: bei
@ExtendWith(MockitoExtension.class) gehört jede Konstruktion, die gemockte Felder verwendet,
in eine Lifecycle-Methode, nie in einen Feld-Initialisierer.
Bekannter Stolperstein: nackte new ObjectMapper() ohne registrierte Module
RegisterCustomerUseCaseTest und OutboxRelayTest (customer-service, Phase 7) instanziierten
zunächst direkt new ObjectMapper(), um CustomerRegisteredEvent-Payloads zu (de-)serialisieren -
beide Tests scheiterten mit InvalidDefinitionException: Java 8 date/time type java.time.Instant not supported by default. Der produktiv verwendete ObjectMapper-Bean wird von Spring Boot
autokonfiguriert und registriert dabei automatisch alle auf dem Klassenpfad gefundenen
Jackson-Module (u. a. jackson-datatype-jsr310 für Instant); eine "nackte" new ObjectMapper()
im Test tut das nicht. Behoben durch new ObjectMapper().findAndRegisterModules() in beiden
Testklassen - ein Beispiel dafür, dass ein Test-Double (hier: ein selbst instanziiertes Utility statt
des echten Spring-Beans) sich unbemerkt vom Produktivverhalten unterscheiden kann.
Coverage
Jedes Modul erzeugt einen JaCoCo-Bericht (target/site/jacoco/index.html) über
mvn test (siehe Parent-POM, jacoco-maven-plugin). Es gibt bewusst keine reactor-weite
Coverage-Aggregation und keine harte CI-Schwelle (anders als im Bibliothek-Enterprise-Lab) — für
den Umfang dieses Projekts genügt der Bericht pro Modul als Leseindikator, nicht als
Torwächter-Metrik; hohe Coverage ist in einem konsequent testgetriebenen Projekt ohnehin eine
Konsequenz der Methode, kein separates Ziel (siehe ADR-0001).