#Tests, CI/CD und Qualitätssicherung
Characterization Tests, Testcontainers, Contract Tests, CI/CD, Static Analysis und OpenRewrite.
#Tests, Testcontainers, Characterization Tests und CI/CD für Legacy-Modernisierung
Dieser Abschnitt ist der praktische Test- und Lieferpfad für die Modernisierung alter Java-Enterprise-Systeme mit EJB, JTA, JMS, SOAP, JSP, JPA und Application Server. Ziel ist nicht, sofort perfekte Tests zu schreiben. Ziel ist, ein belastbares Sicherheitsnetz aufzubauen, damit du Legacy-Code kontrolliert ändern kannst.
Der wichtigste Grundsatz:
Bei Legacy-Modernisierung testest du zuerst Verhalten,
nicht ideale Architektur.
Du willst zuerst beweisen:
Was macht das System heute?
Wann wird committed?
Wann wird rollbacked?
Welche Messages werden gesendet?
Welche SOAP-Payloads entstehen?
Welche DB-Zustände sind nach Fehlern sichtbar?
Erst danach refactorst du.
#Teststrategie für Legacy Enterprise Java
Alte Java-Enterprise-Projekte haben oft diese Probleme:
- kaum Unit Tests
- Tests brauchen Application Server
- Datenbank ist schwer lokal aufzusetzen
- JMS Broker fehlt lokal
- SOAP-Partner sind nicht verfügbar
- Testdaten sind unbekannt
- Build ist langsam
- manuelle Regression dominiert
Deshalb brauchst du eine gestufte Teststrategie.
#Testpyramide für Legacy-Modernisierung
Ebene 1: Characterization Tests
↓
Ebene 2: Plain Java Use-Case Tests
↓
Ebene 3: Adapter Tests
↓
Ebene 4: Integration Tests mit DB/JMS/SOAP Stub
↓
Ebene 5: Contract Tests
↓
Ebene 6: Smoke / Deployment Tests
↓
Ebene 7: End-to-End Regression Tests
Bei Legacy beginnst du oft nicht unten, sondern dort, wo du das System am sichersten beobachten kannst.
#Ziel pro Testebene
| Ebene | Zweck | Beispiel |
|---|---|---|
| Characterization | aktuelles Verhalten festhalten | Audit bleibt trotz Rollback bestehen |
| Use Case Test | neue Plain-Java-Logik testen | CreateOrderUseCase erzeugt PaymentRequested |
| Adapter Test | technische Adapter prüfen | JpaOrderRepository speichert Status korrekt |
| Integration Test | echte Infrastruktur prüfen | PostgreSQL + JMS Broker + Outbox |
| Contract Test | Schnittstellen stabil halten | SOAP Request/Response bleibt kompatibel |
| Smoke Test | Deploybarkeit prüfen | App startet und Health Endpoint antwortet |
| E2E Test | kritischer Prozess vollständig | Order → Payment → Invoice |
#Characterization Tests
Ein Characterization Test beschreibt das aktuelle Verhalten. Er bewertet nicht, ob das Verhalten gut ist.
Er sagt nur:
Das System verhält sich heute so.
Wenn wir es ändern, müssen wir bewusst entscheiden.
#Beispiel: Audit mit REQUIRES_NEW
Legacy-Verhalten:
OrderServiceBean#createOrder startet Haupttransaktion.
AuditServiceBean#writeAudit läuft mit REQUIRES_NEW.
Danach schlägt Payment fehl.
Order rollbackt.
Audit bleibt committed.
Characterization Test:
@Test
void auditIsCommittedEvenWhenOrderCreationRollsBack() {
try {
orderService.createOrderThatFailsAfterAudit();
} catch (PaymentFailedException ignored) {
}
assertFalse(orderRepository.exists(orderId));
assertTrue(auditRepository.contains("ORDER_CREATED", orderId));
}
Wichtig:
Dieser Test sagt nicht, dass dieses Verhalten fachlich richtig ist.
Er schützt dich nur davor, es unbemerkt zu ändern.
#Characterization-Test-Kandidaten
- REQUIRES_NEW Verhalten
- Rollback bei checked Exceptions
- Rollback bei RuntimeException
- setRollbackOnly()
- JMS Send bei Rollback
- SOAP Fault Mapping
- JSP Formularvalidierung
- Session-Verhalten
- Security/Rollenprüfungen
- Batch Restart Verhalten
- Statusübergänge
#Dokumentationsformat
# Characterization Test: <Name>
## Beobachtetes Verhalten
## Warum kritisch?
## Testmethode
## Erwarteter Zustand nach Erfolg
## Erwarteter Zustand nach Fehler
## Darf später geändert werden?
- ja/nein
- Entscheidung durch:
#Plain Java Tests für den neuen Kern
Sobald du Use Cases extrahiert hast, testest du sie ohne Application Server.
Beispiel:
CreateOrderUseCase
PaymentRequestedHandler
OrderPaidMessageHandler
CreateInvoiceUseCase
Diese Klassen sollten nicht abhängen von:
- EntityManager
- JMS API
- SOAP API
- JNDI
- EJB SessionContext
- HttpServletRequest
Sie verwenden nur Ports:
OrderRepository
PaymentGateway
AuditPort
OutboxPort
InvoiceRepository
ProcessedMessageRepository
#Beispieltest: CreateOrderUseCase
class CreateOrderUseCaseTest {
@Test
void createOrder_createsPaymentPendingOrder_andStoresPaymentRequestedEvent() {
InMemoryOrderRepository orderRepository = new InMemoryOrderRepository();
FakeAuditPort auditPort = new FakeAuditPort();
InMemoryOutboxPort outboxPort = new InMemoryOutboxPort();
CreateOrderUseCase useCase = new CreateOrderUseCase(
orderRepository,
auditPort,
outboxPort
);
OrderId orderId = useCase.execute(
new CreateOrderCommand("customer-1", new BigDecimal("99.90"))
);
Order order = orderRepository.findById(orderId).orElseThrow();
assertEquals(OrderStatus.PAYMENT_PENDING, order.status());
assertTrue(auditPort.contains("ORDER_CREATED", orderId));
assertTrue(outboxPort.containsEvent("PaymentRequested", orderId.value()));
}
}
Dieser Test ist schnell, stabil und unabhängig von Infrastruktur.
#Regel
Jeder extrahierte Use Case bekommt mindestens:
- 1 Happy-Path-Test
- 1 fachlicher Fehler-Test
- 1 technischer Fehler-Test, falls relevant
- 1 Idempotenz-Test, falls Message/Retry beteiligt ist
#Test Doubles für Ports
Für Plain-Java-Tests brauchst du einfache Test-Doubles.
#InMemoryOrderRepository
public class InMemoryOrderRepository implements OrderRepository {
private final Map<String, Order> orders = new HashMap<>();
@Override
public void save(Order order) {
orders.put(order.id().value(), order);
}
@Override
public void update(Order order) {
if (!orders.containsKey(order.id().value())) {
throw new IllegalStateException("Order not found: " + order.id().value());
}
orders.put(order.id().value(), order);
}
@Override
public Optional<Order> findById(OrderId orderId) {
return Optional.ofNullable(orders.get(orderId.value()));
}
}
#FakeAuditPort
public class FakeAuditPort implements AuditPort {
private final List<String> entries = new ArrayList<>();
@Override
public void orderCreated(OrderId orderId) {
entries.add("ORDER_CREATED:" + orderId.value());
}
@Override
public void orderPaid(OrderId orderId) {
entries.add("ORDER_PAID:" + orderId.value());
}
@Override
public void paymentFailed(OrderId orderId) {
entries.add("PAYMENT_FAILED:" + orderId.value());
}
public boolean contains(String type, OrderId orderId) {
return entries.contains(type + ":" + orderId.value());
}
}
#InMemoryOutboxPort
public class InMemoryOutboxPort implements OutboxPort {
private final List<OutboxEvent> events = new ArrayList<>();
@Override
public void store(OutboxEvent event) {
events.add(event);
}
public boolean containsEvent(String eventType, String aggregateId) {
return events.stream().anyMatch(event ->
event.eventType().equals(eventType)
&& event.aggregateId().equals(aggregateId)
);
}
public List<OutboxEvent> events() {
return List.copyOf(events);
}
}
Diese Test-Doubles sind absichtlich simpel. Sie testen deine Fachlogik, nicht JPA oder JMS.
#Adapter Tests
Adapter Tests prüfen, ob deine Ports korrekt mit echter oder realistischer Infrastruktur verbunden sind.
Beispiele:
JpaOrderRepositoryTest
JpaOutboxRepositoryTest
SoapPaymentGatewayTest
JmsOrderEventPublisherTest
OrderPaidMessageMapperTest
#Was Adapter Tests prüfen
- Mapping Domain ↔ Entity
- DB Constraints
- Enum/String Status Mapping
- Transaktionsverhalten im Adapter
- SOAP Fault Mapping
- JMS Header/Payload Mapping
- JSON/XML Serialisierung
#Beispiel: Status Mapping
@Test
void mapsPaymentPendingStatusToDatabaseValue() {
Order order = Order.create("customer-1", new BigDecimal("99.90"));
OrderEntity entity = OrderEntityMapper.toEntity(order);
assertEquals("PAYMENT_PENDING", entity.getStatus());
}
#Beispiel: unbekannter DB-Status
@Test
void unknownDatabaseStatusThrowsException() {
OrderEntity entity = new OrderEntity();
entity.setId("order-1");
entity.setCustomerId("customer-1");
entity.setAmount(new BigDecimal("99.90"));
entity.setStatus("OLD_UNKNOWN_STATUS");
assertThrows(
IllegalArgumentException.class,
() -> OrderEntityMapper.toDomain(entity)
);
}
Warum dieser Test wichtig ist:
Legacy-Datenbanken enthalten oft alte Statuswerte,
die im Code nicht mehr bekannt sind.
#Integration Tests mit Testcontainers
Für Datenbank, JMS Broker oder externe Services brauchst du realistische Integrationstests. Testcontainers ist dafür sehr nützlich, weil du kurzlebige Container für Datenbanken, Message Broker oder andere Dienste im Test starten kannst.
#Wann Testcontainers sinnvoll ist
- echte Datenbank statt H2-Inkompatibilitäten
- echtes SQL Dialektverhalten
- echte Constraints
- echte Transaktionen
- JMS Broker Verhalten
- Outbox Queries
- Flyway/Liquibase Migrationen
#Maven Dependencies Beispiel
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
#PostgreSQL Testcontainer
@Testcontainers
class JpaOrderRepositoryIT {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("legacy_modernization_test")
.withUsername("test")
.withPassword("test");
@Test
void savesAndLoadsOrder() {
String jdbcUrl = postgres.getJdbcUrl();
String username = postgres.getUsername();
String password = postgres.getPassword();
// EntityManagerFactory mit diesen Properties starten
// Repository testen
}
}
#Wichtige Regel
Nutze für Adapter/DB-Tests lieber die echte Ziel-Datenbank
als H2, wenn SQL-Dialekt, Locking oder Constraints wichtig sind.
H2 ist nützlich für einfache Tests, aber gefährlich, wenn dein Produktivsystem Oracle, DB2, PostgreSQL oder SQL Server nutzt.
#Datenbank-Migrationen mit Tests absichern
Wenn du Outbox oder processed_message einführst, brauchst du DB-Migrationen.
Typische Tools:
- Flyway
- Liquibase
- vendor-spezifische SQL-Skripte
#Migration für Outbox
CREATE TABLE outbox_event (
id VARCHAR(36) PRIMARY KEY,
aggregate_id VARCHAR(100) NOT NULL,
aggregate_type VARCHAR(100) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload CLOB NOT NULL,
status VARCHAR(30) NOT NULL,
created_at TIMESTAMP NOT NULL,
published_at TIMESTAMP NULL,
retry_count INTEGER NOT NULL,
last_error CLOB NULL
);
CREATE INDEX idx_outbox_pending
ON outbox_event(status, event_type, created_at);
#Migration für Idempotenz
CREATE TABLE processed_message (
message_id VARCHAR(100) PRIMARY KEY,
processed_at TIMESTAMP NOT NULL
);
#Migration-Test
@Test
void migrationCreatesOutboxTable() throws Exception {
try (Connection connection = dataSource.getConnection()) {
ResultSet tables = connection.getMetaData().getTables(
null,
null,
"outbox_event",
null
);
assertTrue(tables.next());
}
}
#Was du testen solltest
- Tabelle existiert
- Index existiert
- NOT NULL Constraints greifen
- Primary Key verhindert Duplikate
- alte Daten bleiben lesbar
- Rollback-Skript oder Wiederholbarkeit ist klar
#SOAP Contract Tests
SOAP-Systeme leben von stabilen Verträgen. Deshalb testest du nicht nur Java-Code, sondern XML-Verträge.
#Was stabil bleiben muss
- WSDL Operationen
- XML Namespaces
- Elementnamen
- Pflichtfelder
- SOAP Fault Struktur
- Datums-/Zahlenformate
- Encoding
- WS-Security Header, falls vorhanden
#Golden-Master-Dateien
src/test/resources/golden/soap/create-order-request.xml
src/test/resources/golden/soap/create-order-response.xml
src/test/resources/golden/soap/payment-fault.xml
#XML semantisch vergleichen
Nicht so:
assertEquals(expectedXml, actualXml);
Besser:
assertThat(actualXml)
.and(expectedXml)
.ignoreWhitespace()
.areSimilar();
Beispiel mit XMLUnit:
@Test
void createOrderResponseMatchesGoldenMaster() {
String expected = load("golden/soap/create-order-response.xml");
String actual = soapEndpoint.createOrder(load("golden/soap/create-order-request.xml"));
Diff diff = DiffBuilder.compare(expected)
.withTest(actual)
.ignoreWhitespace()
.checkForSimilar()
.build();
assertFalse(diff.hasDifferences(), diff.toString());
}
#Dynamische Felder ignorieren
Dynamisch sind oft:
- timestamp
- requestId
- orderId
- sessionId
- generated reference
Diese Felder solltest du entweder normalisieren oder beim Vergleich ignorieren.
#JMS Tests
Bei JMS testest du zwei Dinge getrennt:
1. Message Handler Logik
2. Technische JMS-Integration
#Handler Test
@Test
void duplicateOrderPaidMessageCreatesOnlyOneInvoice() {
InMemoryProcessedMessageRepository processedMessages =
new InMemoryProcessedMessageRepository();
InMemoryInvoiceRepository invoiceRepository =
new InMemoryInvoiceRepository();
CreateInvoiceUseCase createInvoiceUseCase =
new CreateInvoiceUseCase(invoiceRepository);
OrderPaidMessageHandler handler = new OrderPaidMessageHandler(
processedMessages,
invoiceRepository,
createInvoiceUseCase
);
OrderPaidMessage message = new OrderPaidMessage(
"msg-1",
OrderId.of("order-1")
);
handler.handle(message);
handler.handle(message);
assertEquals(1, invoiceRepository.countByOrderId(OrderId.of("order-1")));
}
#JMS Integration Test
Der Integrationstest prüft:
- Queue existiert
- Payload wird korrekt serialisiert
- Header werden gesetzt
- Consumer kann Message lesen
- Redelivery/DLQ Verhalten ist bekannt
Beispiel-Testidee:
@Test
void publishesOrderPaidMessageToQueue() {
OrderPaidEvent event = new OrderPaidEvent(OrderId.of("order-1"));
publisher.publish(event);
Message message = testJmsClient.receive("OrderPaidQueue");
assertNotNull(message);
assertEquals("OrderPaid", message.getStringProperty("eventType"));
}
#Transaktionstests
Transaktionen sind das Herz alter Enterprise-Java-Systeme. Du brauchst Tests, die Rollback und Commit sichtbar machen.
#Test: RuntimeException führt zu Rollback
@Test
void runtimeExceptionRollsBackOrderTransaction() {
assertThrows(RuntimeException.class, () ->
orderApplicationService.createOrderAndFailAfterPersist()
);
assertFalse(orderRepository.exists(orderId));
}
#Test: REQUIRES_NEW bleibt committed
@Test
void requiresNewAuditCommitsEvenWhenOuterTransactionRollsBack() {
assertThrows(RuntimeException.class, () ->
orderApplicationService.createOrderAndFailAfterAudit()
);
assertFalse(orderRepository.exists(orderId));
assertTrue(auditRepository.contains("ORDER_CREATED", orderId));
}
#Test: checked Exception Rollback
EJB und Spring unterscheiden sich oft bei checked Exceptions.
EJB-Beispiel:
@ApplicationException(rollback = true)
public class PaymentFailedException extends Exception {
}
Spring-Äquivalent:
@Transactional(rollbackFor = PaymentFailedException.class)
public void pay() throws PaymentFailedException {
}
Test:
@Test
void checkedPaymentExceptionRollsBackWhenConfigured() {
assertThrows(PaymentFailedException.class, () ->
service.createOrderWithCheckedPaymentFailure()
);
assertFalse(orderRepository.exists(orderId));
}
#Outbox Tests
Outbox ist kritisch, weil sie zuverlässige Folgeprozesse ermöglicht.
#Test: Event wird in derselben Transaktion gespeichert
@Test
void orderAndOutboxEventAreCommittedTogether() {
OrderId orderId = createOrderUseCase.execute(
new CreateOrderCommand("customer-1", new BigDecimal("99.90"))
);
assertTrue(orderRepository.exists(orderId));
assertTrue(outboxRepository.exists("PaymentRequested", orderId.value()));
}
#Test: Rollback entfernt auch Outbox Event
@Test
void rollbackRemovesOrderAndOutboxEvent() {
assertThrows(RuntimeException.class, () ->
createOrderUseCase.executeAndFailAfterOutbox(
new CreateOrderCommand("customer-1", new BigDecimal("99.90"))
)
);
assertFalse(orderRepository.existsByCustomerId("customer-1"));
assertFalse(outboxRepository.existsForCustomerId("customer-1"));
}
#Test: Publisher markiert Event als published
@Test
void outboxPublisherMarksEventAsPublishedAfterSuccessfulSend() {
OutboxEvent event = outboxRepository.storePendingOrderPaidEvent("order-1");
publisher.publishPendingOrderPaidEvents();
OutboxEventEntity updated = outboxRepository.findById(event.id()).orElseThrow();
assertEquals("PUBLISHED", updated.getStatus());
assertNotNull(updated.getPublishedAt());
}
#Test: Publisher erhöht Retry Count bei Fehler
@Test
void outboxPublisherIncrementsRetryCountWhenSendFails() {
fakeJmsPublisher.failNextSend();
OutboxEvent event = outboxRepository.storePendingOrderPaidEvent("order-1");
publisher.publishPendingOrderPaidEvents();
OutboxEventEntity updated = outboxRepository.findById(event.id()).orElseThrow();
assertEquals("PENDING", updated.getStatus());
assertEquals(1, updated.getRetryCount());
assertNotNull(updated.getLastError());
}
#Idempotenztests
Jede Message-Verarbeitung, die fachliche Daten schreibt, braucht Idempotenztests.
#Fälle
- gleiche messageId zweimal
- unterschiedliche messageId, aber gleiche orderId
- Consumer stürzt nach fachlichem Write ab
- Consumer stürzt vor markProcessed ab
- Outbox Publisher sendet doppelt
#Test: gleiche messageId
@Test
void sameMessageIdIsProcessedOnlyOnce() {
OrderPaidMessage message = new OrderPaidMessage("msg-1", OrderId.of("order-1"));
handler.handle(message);
handler.handle(message);
assertEquals(1, invoiceRepository.countByOrderId(OrderId.of("order-1")));
assertTrue(processedMessageRepository.alreadyProcessed("msg-1"));
}
#Test: andere messageId, gleiche Order
@Test
void differentMessageIdForSameOrderDoesNotCreateDuplicateInvoice() {
handler.handle(new OrderPaidMessage("msg-1", OrderId.of("order-1")));
handler.handle(new OrderPaidMessage("msg-2", OrderId.of("order-1")));
assertEquals(1, invoiceRepository.countByOrderId(OrderId.of("order-1")));
}
#Regel
Idempotenz darf nicht nur auf Message-ID basieren,
wenn fachliche Duplikate auch mit neuer Message-ID entstehen können.
Deshalb brauchst du oft zwei Prüfungen:
1. Wurde diese Message schon verarbeitet?
2. Existiert das fachliche Ergebnis bereits?
#Testdatenstrategie
Legacy-Systeme scheitern oft an unklaren Testdaten.
Du brauchst drei Arten von Testdaten:
1. minimale technische Testdaten
2. fachliche Szenario-Testdaten
3. Golden-Master-Testdaten
#Minimale technische Testdaten
- ein Kunde
- eine Bestellung
- eine Queue
- eine Datasource
- eine Rolle/User
#Fachliche Szenarien
- Bestellung erfolgreich bezahlt
- Payment fehlgeschlagen
- Payment Timeout / unknown
- doppelte OrderPaid Message
- Invoice existiert bereits
- Audit REQUIRES_NEW bleibt bestehen
#Testdaten als Builder
public class OrderTestData {
public static CreateOrderCommand validCreateOrderCommand() {
return new CreateOrderCommand(
"customer-1",
new BigDecimal("99.90")
);
}
public static Order paymentPendingOrder() {
return Order.create("customer-1", new BigDecimal("99.90"));
}
}
#Regel
Testdaten gehören in Code oder versionierte Ressourcen,
nicht in private Datenbank-Dumps auf Entwickler-Laptops.
#Legacy Smoke Tests
Smoke Tests prüfen, ob das System grundsätzlich lebt.
#Smoke-Test-Kandidaten
- Application Server startet
- Deployment erfolgreich
- Datasource erreichbar
- JMS Broker erreichbar
- SOAP Endpoint antwortet
- Login funktioniert
- Hauptseite lädt
- Health/Status-Seite zeigt OK
#Minimaler HTTP Smoke Test
@Test
void applicationHealthEndpointReturnsOk() throws Exception {
HttpResponse<String> response = httpClient.send(
HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/health"))
.GET()
.build(),
HttpResponse.BodyHandlers.ofString()
);
assertEquals(200, response.statusCode());
}
#SOAP Smoke Test
@Test
void customerSoapEndpointResponds() {
String response = soapClient.call(
"/CustomerService",
load("smoke/customer-ping-request.xml")
);
assertThat(response).contains("CustomerServiceResponse");
}
#Smoke-Test-Regel
Smoke Tests müssen wenige Minuten laufen.
Sie entscheiden, ob ein Build überhaupt weiter geprüft wird.
#CI/CD Pipeline für Legacy-Modernisierung
Eine gute Pipeline für Legacy-Modernisierung ist gestuft.
#Pipeline-Stufen
1. Compile
2. Unit Tests
3. Static Checks
4. Plain Java Use Case Tests
5. Adapter Tests
6. Integration Tests
7. Packaging
8. Smoke Tests
9. Optional: E2E Tests
10. Optional: OpenRewrite Dry Run
#Beispiel GitHub Actions für Maven
name: Java CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
cache: maven
- name: Compile
run: mvn -B -DskipTests compile
- name: Unit tests
run: mvn -B test
- name: Package
run: mvn -B -DskipTests package
#Integration Tests getrennt ausführen
integration-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
cache: maven
- name: Run integration tests
run: mvn -B verify -Pintegration-tests
#Warum getrennt?
Unit Tests laufen bei jedem Commit schnell.
Integration Tests dürfen länger laufen,
sollen aber Pull Requests trotzdem absichern.
#Maven Profile für Teststufen
Du kannst Testarten über Maven Profile trennen.
#Konvention
*Test.java → Unit Tests
*IT.java → Integration Tests
*E2E.java → End-to-End Tests
#Surefire für Unit Tests
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<includes>
<include>**/*Test.java</include>
</includes>
</configuration>
</plugin>
#Failsafe für Integration Tests
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<configuration>
<includes>
<include>**/*IT.java</include>
</includes>
</configuration>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
#Profile
<profiles>
<profile>
<id>integration-tests</id>
<properties>
<skipITs>false</skipITs>
</properties>
</profile>
</profiles>
#Regel
Schnelle Tests dürfen nicht durch langsame Container-Tests blockiert werden.
Aber Container-Tests müssen regelmäßig in CI laufen.
#Static Analysis und Architekturregeln
Neben Tests brauchst du Regeln, die neue Legacy-Schulden verhindern.
#Beispiele für verbotene Abhängigkeiten
application darf nicht abhängen von jakarta.persistence
application darf nicht abhängen von javax.jms
application darf nicht abhängen von SOAP/JAXB generated classes
application darf nicht abhängen von HttpServletRequest
Domain darf nicht abhängen von Infrastruktur
#ArchUnit Beispiel
@AnalyzeClasses(packages = "com.company.order")
class ArchitectureTest {
@ArchTest
static final ArchRule domainDoesNotDependOnInfrastructure =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage(
"..adapters..",
"jakarta.persistence..",
"javax.jms..",
"jakarta.jms..",
"jakarta.servlet.."
);
@ArchTest
static final ArchRule applicationDoesNotDependOnAdapters =
noClasses()
.that().resideInAPackage("..application..")
.should().dependOnClassesThat()
.resideInAPackage("..adapters..");
}
#Checkstyle/SpotBugs/PMD
Nutze Static Analysis nicht als Schönheitswaffe, sondern gezielt:
- verbotene APIs
- leere catch-Blöcke
- printStackTrace
- System.out.println
- unsichere XML Parser
- direkte InitialContext Lookups außerhalb legacy/adapters
#OpenRewrite in der Pipeline
OpenRewrite kann automatische Refactorings und Migrationen vorbereiten.
Typische Einsätze:
- Java-Version-Migration vorbereiten
- javax.* zu jakarta.* prüfen
- veraltete APIs ersetzen
- Maven Plugin/Dependency Updates
- Spring Boot Migrationen unterstützen
#Wichtig
OpenRewrite nicht blind auf main laufen lassen.
Erst dry run, dann Review, dann kleine PRs.
#Maven Beispiel
mvn -U org.openrewrite.maven:rewrite-maven-plugin:dryRun \
-Drewrite.activeRecipes=org.openrewrite.java.migrate.UpgradeToJava17
#Workflow
1. Recipe im Branch ausführen
2. Diff prüfen
3. Tests laufen lassen
4. kleine PR erstellen
5. Migration dokumentieren
#Nicht automatisieren ohne Review
- Transaktionsannotation-Mapping
- Exception-Rollback-Verhalten
- SOAP/JAXB generierte Klassen
- Security-Konfiguration
- Application-Server-spezifische Descriptoren
#Pull-Request-Checkliste für Legacy-Modernisierung
Jeder Modernisierungs-PR sollte diese Fragen beantworten.
# PR Checkliste Legacy-Modernisierung
## Verhalten
- [ ] Welches bestehende Verhalten wurde dokumentiert?
- [ ] Gibt es Characterization Tests?
- [ ] Wurde fachliches Verhalten geändert?
- [ ] Wenn ja: wer hat es bestätigt?
## Transaktionen
- [ ] Wurden TransactionAttribute / @Transactional geändert?
- [ ] Gibt es REQUIRES_NEW?
- [ ] Gibt es checked Exceptions mit Rollback?
- [ ] Gibt es setRollbackOnly?
## Messaging
- [ ] Werden neue Messages erzeugt?
- [ ] Ist der Consumer idempotent?
- [ ] Gibt es Retry/DLQ-Verhalten?
- [ ] Sind Message Headers/Payloads dokumentiert?
## SOAP / externe Calls
- [ ] Wurde ein SOAP Vertrag geändert?
- [ ] Gibt es Timeouts?
- [ ] Gibt es Fault Mapping?
- [ ] Läuft der externe Call innerhalb einer DB-Transaktion?
## Datenbank
- [ ] Wurde Schema geändert?
- [ ] Gibt es Migrationsskripte?
- [ ] Gibt es Rollback-/Recovery-Plan?
- [ ] Wurden Indizes geprüft?
## Tests
- [ ] Unit Tests
- [ ] Integration Tests
- [ ] Contract Tests
- [ ] Smoke Tests
- [ ] Idempotenztests
## Betrieb
- [ ] Logs aussagekräftig?
- [ ] Metriken vorhanden?
- [ ] Alerts nötig?
- [ ] Feature Flag nötig?
#Test- und CI/CD-Plan für Complex Slice 001
Für unseren Order/Payment/Invoice-Slice sieht der konkrete Plan so aus.
#Test Matrix
| Bereich | Test | Priorität |
|---|---|---|
| CreateOrderUseCase | PaymentRequested Outbox Event | P1 |
| PaymentRequestedHandler | Payment success → OrderPaid | P1 |
| PaymentRequestedHandler | Payment failed → PAYMENT_FAILED | P1 |
| PaymentRequestedHandler | Timeout → PAYMENT_UNKNOWN | P1 |
| Outbox Publisher | successful publish → PUBLISHED | P1 |
| Outbox Publisher | failed publish → retry_count++ | P1 |
| Invoice Consumer | duplicate message → one invoice | P1 |
| JPA Adapter | status mapping | P1 |
| SOAP Adapter | fault mapping | P2 |
| JMS Adapter | headers and payload | P2 |
| Full Flow | Order → Payment → Invoice | P2 |
#Minimaler erster Sprint
Tag 1:
- CreateOrderUseCase Tests
- InMemoryOrderRepository
- InMemoryOutboxPort
Tag 2:
- PaymentRequestedHandler Tests
- FakePaymentGateway
- Payment success/failure/timeout
Tag 3:
- OrderPaidMessageHandler Idempotenztests
- ProcessedMessageRepository einführen
Tag 4:
- JPA Adapter Test mit Testcontainers
- Outbox Tabelle prüfen
Tag 5:
- CI Pipeline: mvn test + mvn verify
- PR Checkliste einführen
#Definition of Done für Test-Schutznetz
- Use Cases sind ohne Container testbar
- Outbox-Verhalten ist getestet
- Idempotenz ist getestet
- mindestens ein DB-Adapter-Test läuft mit echter DB
- CI führt Unit Tests bei jedem PR aus
- Integration Tests laufen mindestens im PR oder nightly
- PR-Checkliste ist im Repository
#Wichtigster Merksatz
Legacy-Modernisierung ohne Tests ist Raten.
Legacy-Modernisierung mit Characterization Tests ist kontrolliertes Lernen.
#Referenzen
- Testcontainers for Java: https://java.testcontainers.org/
- Testcontainers JUnit 5 Integration: https://java.testcontainers.org/test_framework_integration/junit_5/
- Testcontainers Container Lifecycle Guide: https://testcontainers.com/guides/testcontainers-container-lifecycle/
- JUnit 5 User Guide: https://docs.junit.org/5.10.2/user-guide/index.html
- GitHub Actions: Building and testing Java with Maven: https://docs.github.com/actions/guides/building-and-testing-java-with-maven
- OpenRewrite Maven Plugin: https://docs.openrewrite.org/reference/rewrite-maven-plugin
- OpenRewrite Java 17 Migration Guide: https://docs.openrewrite.org/running-recipes/popular-recipe-guides/migrate-to-java-17
- OpenRewrite Java 21 Migration Recipe: https://docs.openrewrite.org/recipes/java/migrate/upgradetojava21