#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:

text
Bei Legacy-Modernisierung testest du zuerst Verhalten,
nicht ideale Architektur.

Du willst zuerst beweisen:

text
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:

text
- 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

text
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:

text
Das System verhält sich heute so.
Wenn wir es ändern, müssen wir bewusst entscheiden.

#Beispiel: Audit mit REQUIRES_NEW

Legacy-Verhalten:

text
OrderServiceBean#createOrder startet Haupttransaktion.
AuditServiceBean#writeAudit läuft mit REQUIRES_NEW.
Danach schlägt Payment fehl.
Order rollbackt.
Audit bleibt committed.

Characterization Test:

java
@Test
void auditIsCommittedEvenWhenOrderCreationRollsBack() {
    try {
        orderService.createOrderThatFailsAfterAudit();
    } catch (PaymentFailedException ignored) {
    }

    assertFalse(orderRepository.exists(orderId));
    assertTrue(auditRepository.contains("ORDER_CREATED", orderId));
}

Wichtig:

text
Dieser Test sagt nicht, dass dieses Verhalten fachlich richtig ist.
Er schützt dich nur davor, es unbemerkt zu ändern.

#Characterization-Test-Kandidaten

text
- 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

markdown
# 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:

text
CreateOrderUseCase
PaymentRequestedHandler
OrderPaidMessageHandler
CreateInvoiceUseCase

Diese Klassen sollten nicht abhängen von:

text
- EntityManager
- JMS API
- SOAP API
- JNDI
- EJB SessionContext
- HttpServletRequest

Sie verwenden nur Ports:

text
OrderRepository
PaymentGateway
AuditPort
OutboxPort
InvoiceRepository
ProcessedMessageRepository

#Beispieltest: CreateOrderUseCase

java
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

text
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

java
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

java
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

java
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:

text
JpaOrderRepositoryTest
JpaOutboxRepositoryTest
SoapPaymentGatewayTest
JmsOrderEventPublisherTest
OrderPaidMessageMapperTest

#Was Adapter Tests prüfen

text
- 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

java
@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

java
@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:

text
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

text
- echte Datenbank statt H2-Inkompatibilitäten
- echtes SQL Dialektverhalten
- echte Constraints
- echte Transaktionen
- JMS Broker Verhalten
- Outbox Queries
- Flyway/Liquibase Migrationen

#Maven Dependencies Beispiel

xml
<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

java
@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

text
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:

text
- Flyway
- Liquibase
- vendor-spezifische SQL-Skripte

#Migration für Outbox

sql
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

sql
CREATE TABLE processed_message (
    message_id VARCHAR(100) PRIMARY KEY,
    processed_at TIMESTAMP NOT NULL
);

#Migration-Test

java
@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

text
- 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

text
- WSDL Operationen
- XML Namespaces
- Elementnamen
- Pflichtfelder
- SOAP Fault Struktur
- Datums-/Zahlenformate
- Encoding
- WS-Security Header, falls vorhanden

#Golden-Master-Dateien

text
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:

java
assertEquals(expectedXml, actualXml);

Besser:

java
assertThat(actualXml)
        .and(expectedXml)
        .ignoreWhitespace()
        .areSimilar();

Beispiel mit XMLUnit:

java
@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:

text
- 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:

text
1. Message Handler Logik
2. Technische JMS-Integration

#Handler Test

java
@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:

text
- Queue existiert
- Payload wird korrekt serialisiert
- Header werden gesetzt
- Consumer kann Message lesen
- Redelivery/DLQ Verhalten ist bekannt

Beispiel-Testidee:

java
@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

java
@Test
void runtimeExceptionRollsBackOrderTransaction() {
    assertThrows(RuntimeException.class, () ->
            orderApplicationService.createOrderAndFailAfterPersist()
    );

    assertFalse(orderRepository.exists(orderId));
}

#Test: REQUIRES_NEW bleibt committed

java
@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:

java
@ApplicationException(rollback = true)
public class PaymentFailedException extends Exception {
}

Spring-Äquivalent:

java
@Transactional(rollbackFor = PaymentFailedException.class)
public void pay() throws PaymentFailedException {
}

Test:

java
@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

java
@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

java
@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

java
@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

java
@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

text
- 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

java
@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

java
@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

text
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:

text
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:

text
1. minimale technische Testdaten
2. fachliche Szenario-Testdaten
3. Golden-Master-Testdaten

#Minimale technische Testdaten

text
- ein Kunde
- eine Bestellung
- eine Queue
- eine Datasource
- eine Rolle/User

#Fachliche Szenarien

text
- Bestellung erfolgreich bezahlt
- Payment fehlgeschlagen
- Payment Timeout / unknown
- doppelte OrderPaid Message
- Invoice existiert bereits
- Audit REQUIRES_NEW bleibt bestehen

#Testdaten als Builder

java
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

text
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

text
- 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

java
@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

java
@Test
void customerSoapEndpointResponds() {
    String response = soapClient.call(
            "/CustomerService",
            load("smoke/customer-ping-request.xml")
    );

    assertThat(response).contains("CustomerServiceResponse");
}

#Smoke-Test-Regel

text
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

text
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

yaml
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

yaml
  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?

text
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

text
*Test.java      → Unit Tests
*IT.java        → Integration Tests
*E2E.java       → End-to-End Tests

#Surefire für Unit Tests

xml
<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

xml
<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

xml
<profiles>
    <profile>
        <id>integration-tests</id>
        <properties>
            <skipITs>false</skipITs>
        </properties>
    </profile>
</profiles>

#Regel

text
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

text
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

java
@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:

text
- 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:

text
- Java-Version-Migration vorbereiten
- javax.* zu jakarta.* prüfen
- veraltete APIs ersetzen
- Maven Plugin/Dependency Updates
- Spring Boot Migrationen unterstützen

#Wichtig

text
OpenRewrite nicht blind auf main laufen lassen.
Erst dry run, dann Review, dann kleine PRs.

#Maven Beispiel

bash
mvn -U org.openrewrite.maven:rewrite-maven-plugin:dryRun \
  -Drewrite.activeRecipes=org.openrewrite.java.migrate.UpgradeToJava17

#Workflow

text
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

text
- 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.

markdown
# 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

text
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

text
- 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

text
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

⌂ Cockpit