From Production Incidents to JUnit 5 Annotations: A Three-Tier Chaos Testing Framework
Engineering category.testing category.chaos-engineering

From Production Incidents to JUnit 5 Annotations: A Three-Tier Chaos Testing Framework

448 L1 syscall primitives. 92 L2 fault composites. 64 L3 production incident scenarios encoded as single annotations. A JUnit 5 extension framework that turns post-mortems into CI gates for Spring Boot, Quarkus, and Micronaut.

E
Engineering Team
Senior Solutions Architects
June 26, 2026
20 min read

One Annotation. Your Last Production Incident. Reproduced.

Somewhere in your team's Slack history there's a post-mortem. It has a section called "Root Cause" that describes how a Kubernetes rolling update created a 30-second window where iptables hadn't propagated the new pod's removal, and ECONNRESET was being returned on active connections to the old pod. It has a "Mitigation" section that says you configured retry-with-backoff. It has an "Action Items" section with a checkbox next to "Add chaos test for this scenario." That checkbox has never been checked. Not because the team doesn't care. Because writing a chaos test for that specific scenario requires understanding how iptables lag maps to ECONNRESET rates, which libc call gets intercepted, what the right probability is, and how to inject the fault into a Docker container running in a CI environment. That's a lot of prerequisite knowledge for a checkbox. `@IncidentChaosK8sRollingUpdateRst(toxicity = 0.3)` is one import and one annotation. That's the checkbox being checked.

                      // The post-mortem's action item, as a test:
@Test
@IncidentChaosK8sRollingUpdateRst(toxicity = 0.3)
void service_survives_rolling_update_rst_storm() {
    // 30% of RECV calls return ECONNRESET
    // This is exactly what a Kubernetes rolling update looks like
    assertThat(callService("/api/health")).isEqualTo(200);
}
                    

The Three-Tier Annotation Hierarchy

The framework organizes chaos scenarios into three tiers, each addressing a different level of expertise and use case: **L1 — Raw Primitives (448 annotations):** Direct syscall-level control. One annotation maps to one fault rule: one errno, one operation type, one probability. This is the layer for engineers who know exactly which kernel call they want to fault and at what rate. Full expressive power, no abstraction. **L2 — Named Composites (92 annotations):** Documented fault families, each composing 2–6 L1 rules into a named pattern. This is the layer for common failure scenarios that have a well-understood shape but don't need to trace back to a specific incident. The defaults are calibrated from real-world failure analysis. **L3 — Incident Scenarios (64 annotations):** Real production incidents encoded as multi-domain compositions. Each L3 annotation carries a Severity rating, a reference to the original incident or industry source, and a Composer class that knows how to decompose the scenario into the right mix of L1 rules across multiple domains. This is the layer for "reproduce what happened in the post-mortem." The tiers are not exclusive — you can mix L1, L2, and L3 annotations on the same test class or method, and they stack correctly. Method-scoped annotations override class-scoped annotations for the duration of the test method, then restore the class-scoped rules when the method ends.

L1: Raw Syscall Primitives — 448 Annotations

The L1 tier gives you direct control over individual syscall fault rules. Every annotation follows the same pattern: target operation + errno or effect + probability + optional host or path pattern.

                      // Network — connect/recv/send
@ChaosConnectEconnrefused(probability = 0.10)
@ChaosConnectEtimedout(probability = 0.03)
@ChaosRecvEconnreset(probability = 0.05)
@ChaosSendEpipe(probability = 0.02)

// DNS — getaddrinfo
@ChaosDnsGetaddrinfoEaiAgain(hostPattern = "*.internal", probability = 0.20)
@ChaosDnsGetaddrinfoEaiFail(hostPattern = "legacy.svc", probability = 1.0)

// File I/O — writes and reads
@ChaosWriteTorn(pathPrefix = "/data/wal", probability = 0.05)
@ChaosReadEio(pathPrefix = "/etc/app", probability = 0.01)
@ChaosWriteEnospc(pathPrefix = "/data", probability = 0.001)

// Memory
@ChaosMmapEnomem(probability = 0.005)

// Timing — breaks JWT expiry checks, caching TTLs
@ChaosClock(effect = ClockEffect.OFFSET, offsetMs = 30_000)  // +30 second skew

// All annotations support:
//   probability  — 0.0 (never) to 1.0 (always)
//   id           — target a specific container by ID
//   onMissingEnv — ERROR (fail test) or ABORT (skip test) if chaos env unavailable
                    

L2: Named Fault Composites — 92 Documented Patterns

The L2 tier encodes documented failure patterns with calibrated defaults. Each composite represents a failure shape that has a name in incident post-mortems and a known, predictable impact on application behavior.
  • @CompositeChaosConnectionRefused — ECONNREFUSED on every connect(), simulating a dead downstream
  • @CompositeChaosTransientDnsFailure — EAI_AGAIN on 15% of getaddrinfo() calls, simulating CoreDNS overload
  • @CompositeChaosLowMemoryPressure — ENOMEM on 0.5% of mmap() calls, simulating memory contention
  • @CompositeChaosNetworkFlap — alternating ECONNRESET and success, simulating a flapping network link
  • @CompositeChaosSlowDisk — 50–200ms latency on write(), simulating I/O-saturated storage
  • @CompositeChaosClockDrift — ±5 second signed clock skew, simulating NTP drift
  • @CompositeChaosConnectionTimeout — ETIMEDOUT on 5% of connect() with 3s latency, simulating firewall timeout
  • @CompositeChaosShortWrite — TORN on 10% of writes, simulating filesystem returning short counts

L3: Real Production Incidents — 64 Annotated Scenarios

The L3 tier is where chaos engineering becomes institutional memory. Each annotation encodes a real failure mode — documented in production incident reports, Kubernetes issue trackers, or well-known distributed systems failure analyses. The Composer pattern handles the decomposition: each L3 annotation references a Composer class that knows how to translate a high-level incident description into the right mix of L1 rules across multiple fault domains.

                      // L3 Incident: Kubernetes Rolling Update RST Storm
// Source: Kubernetes GitHub Issue #56903, multiple production incidents
// Severity: CRITICAL
// What happens: During rolling updates, iptables rules update asynchronously.
// Old pods receive connection attempts for ~15-30s after replacement.
// Active connections to old pods get ECONNRESET when the pod terminates.
@IncidentChaosK8sRollingUpdateRst(toxicity = 0.3)

// L3 Incident: Feign Retry Amplification Storm
// Source: Documented in multiple high-traffic Java service incidents
// Severity: CRITICAL
// What happens: Feign default retry (5x) × replicas (3) × backends (N)
// creates multiplicative amplification. A 50% failure rate becomes 9x load.
@IncidentChaosFeignRetryAmplification(toxicity = 0.5)

// L3 Incident: Spring @Transactional(REQUIRES_NEW) Deadlock
// Source: Spring Framework known issue pattern
// Severity: SEVERE
// What happens: REQUIRES_NEW suspends outer transaction and borrows new
// connection. Under load, pool exhausts — outer tx holds one connection,
// REQUIRES_NEW waits for another, deadlock.
@IncidentChaosSpringTransactionalPoolDeadlock

// L3 Incident: Kubernetes DNS ndots=5 Storm
// Source: CoreDNS performance analysis, ndots search path behavior
// Severity: SEVERE
// What happens: With ndots:5 (K8s default), each hostname generates 5+
// DNS queries (search path expansion). Under CoreDNS load, EAI_AGAIN
// spikes. Services that don't handle DNS transient failures cascade.
@IncidentChaosK8sDnsNdots5Storm

// L3 Incident: JVM Code Cache Full
// Source: JVM JIT compilation documentation, production profiling
// Severity: SEVERE
// What happens: When code cache fills, JIT stops compiling new methods.
// Throughput drops 10–50x as hot paths deoptimize to interpreted mode.
@IncidentChaosJvmCodeCacheFull

// L3 Incident: Redis Sentinel Failover Storm
// Source: Redis Sentinel documentation, observed failover cascades
// Severity: MEDIUM
// What happens: Sentinel failover triggers connection flap on all clients.
// Applications that don't handle LOADING errors loop reconnecting,
// overwhelming the new master.
@IncidentChaosRedisNetworkFlap
                    

Docker + LD_PRELOAD: Chaos on Any Production Image

For tests that run application code inside Docker containers (Testcontainers pattern), the framework automatically injects the C99 LD_PRELOAD libraries into the container before it starts. The injection mechanism uses the Docker API tar stream — not shell exec, not volumes, not init containers: 1. The JUnit extension detects the container image's base OS (Alpine → musl, Debian/RHEL/Ubuntu → glibc). 2. It selects the correct pre-compiled binary (glibc/musl × amd64/arm64 — four variants, bundled in the JAR). 3. It copies the `.so` file into the container via `DockerClient.copyArchiveToContainerCmd()` before `container.start()`. 4. It sets `LD_PRELOAD=/chaos/libchaos-net.so` (and others as declared) in the container environment. This means chaos testing works on `distroless` images, `scratch`-based images, UBI minimal, Alpine — any image that uses a standard ELF dynamic linker. No Dockerfile modification, no sidecar, no additional infrastructure.

                      // @SyscallLevelChaos declares which libraries to inject
// The framework handles the rest: image detection, binary selection, injection

@Testcontainers
@ExtendWith(ChaosTestingExtension.class)
@SyscallLevelChaos({LibchaosLib.NET, LibchaosLib.DNS})  // Inject both
class InventoryServiceChaosTest {

    @Container @AppContainer
    static GenericContainer<?> inventory =
        new GenericContainer<>("inventory-service:latest")  // Any image works
            .withExposedPorts(8080);

    @Container
    static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:16");

    @Test
    @CompositeChaosTransientDnsFailure  // L2: 15% EAI_AGAIN on DNS
    void inventory_lookup_resilient_to_dns_hiccups(String inventoryUrl) {
        // libchaos-dns.so is active inside the inventory-service container
        // DNS calls from the service will see 15% EAI_AGAIN failures
        var response = given().get(inventoryUrl + "/api/product/123");
        assertThat(response.statusCode()).isEqualTo(200);
    }
}
                    

Framework Integrations: Spring Boot, Quarkus, Micronaut

Each major Java framework gets a dedicated integration module that handles both the test-side orchestration and the optional runtime chaos control plane. **Spring Boot 3 and 4:** The test starter provides `@ChaosTest` as a composed annotation, registers `ChaosControlPlane` and `ChaosSession` as Spring beans, and provides JUnit 5 parameter resolution for both. It composes with `@SpringBootTest`, `@DataJpaTest`, `@WebMvcTest`, and any other Spring test slice annotation. The runtime starter exposes `/actuator/chaos` as a protected Actuator endpoint for live scenario management in non-production environments. **Quarkus:** The Quarkus extension provides `@QuarkusChaosTest` as a composed Quarkus test annotation with CDI injection of `ChaosControlPlane`. Integrates with `@QuarkusTest`, `@QuarkusIntegrationTest`, and native image tests. **Micronaut:** The Micronaut integration provides `@MicronautChaosTest` with `@Inject`-compatible `ChaosControlPlane` and `ChaosSession` beans. Works with `@MicronautTest` in both JVM and native test modes.
  • Spring Boot 3/4: @ChaosTest composed annotation, ChaosControlPlane as Spring bean, /actuator/chaos endpoint
  • Quarkus: @QuarkusChaosTest, CDI injection, works with @QuarkusIntegrationTest and native
  • Micronaut: @MicronautChaosTest, @Inject-compatible beans, JVM and native test support
  • Plain JUnit 5: @ExtendWith(ChaosTestingExtension.class) with manual ChaosControlPlane — no framework required
  • Gradle and Maven compatible: all modules published to Maven Central with standard coordinates

Resources and Constraints: @Resources Annotation

Beyond fault injection, the framework supports resource constraint testing via `@Resources`. This declares CPU and memory limits that the JUnit extension applies to the Docker container before it starts — without modifying the container definition in the test code. This enables a class of tests that's often skipped: "does my service behave correctly when it's resource-constrained?" This is a realistic scenario in Kubernetes, where pods run with CPU limits and memory limits, and where hitting those limits triggers throttling and OOM kills.

                      // Test your service under the same resource constraints as production
@Testcontainers
@ExtendWith(ChaosTestingExtension.class)
@SyscallLevelChaos(LibchaosLib.NET)
@Resources(cpuQuota = 0.5, memoryMb = 256)  // Same limits as production K8s pod
class ResourceConstrainedChaosTest {

    @Container @AppContainer
    static GenericContainer<?> app =
        new GenericContainer<>("my-service:latest")
            .withExposedPorts(8080);

    @Test
    @IncidentChaosJvmCodeCacheFull  // Code cache fills → JIT stops
    void service_degrades_gracefully_under_resource_pressure(String appUrl) {
        // CPU-throttled + code cache pressure = realistic production stress test
        var response = given().get(appUrl + "/api/health");
        // Service should return 200 or 503 — not hang forever
        assertThat(response.statusCode()).isIn(200, 503);
        assertThat(response.time()).isLessThan(5000);  // Under 5s even degraded
    }
}
                    

The Plugin Architecture: One Extension, All Containers

Early versions of the framework had separate JUnit extensions for each container type: one for Redis, one for PostgreSQL, one for Kafka, and so on. Each extension had 200+ lines of nearly-identical lifecycle code. Extending to a new container type meant copying and modifying. The current architecture uses a `ChaosPlugin` SPI discovered via `ServiceLoader.load(ChaosPlugin.class)`. Each container type registers one implementation. The single `ChaosTestingExtension` orchestrates all of them. The SPI contract is minimal: discover containers in the test class, provide connection information, handle resource application, and handle pre-start chaos setup. The universal extension handles everything else: annotation processing, lifecycle management, session isolation, parameter resolution. This eliminates ~8,000 lines of duplication and makes the framework extensible to new container types without modifying the core.

                      // Registering a custom container type
public class MyServiceChaosPlugin implements ChaosPlugin {

    @Override
    public boolean supportsContainer(GenericContainer<?> container) {
        return container.getDockerImageName().startsWith("my-service:");
    }

    @Override
    public ConnectionInfo extractConnectionInfo(GenericContainer<?> container,
                                               Annotation annotation) {
        return ConnectionInfo.of(
            "http://" + container.getHost() + ":" + container.getMappedPort(8080)
        );
    }

    @Override
    public Class<? extends Annotation> containerAnnotation() {
        return AppContainer.class;  // @AppContainer marks this container
    }
}
// META-INF/services/com.macstab.chaos.core.spi.ChaosPlugin:
// com.example.chaos.MyServiceChaosPlugin
                    

43 Modules, 11 Domains, One Mental Model

The framework is organized across 43 Gradle modules covering 11 fault domains. Here's the full picture: **Core infrastructure:** chaos-core (JUnit 5 extension, SPI, lifecycle), chaos-spi (plugin contract), chaos-api (selector and effect types) **Fault domain test packs:** testpacks-connection (TCP/UDP), testpacks-dns (DNS resolution), testpacks-memory (malloc/mmap), testpacks-process (fork/exec/signals), testpacks-time (clock, time skew), testpacks-filesystem (file I/O) **JVM integration:** chaos-java (JVM bytecode transport, container-side wiring) **L3 incident packs:** testpacks-l3-kubernetes (rolling updates, DNS storms), testpacks-l3-feign (retry amplification), testpacks-l3-spring (transactional deadlock, OSIV starvation), testpacks-l3-redis (failover storms), testpacks-l3-kafka (broker failover), testpacks-l3-grpc (GOAWAY storms) **Framework integrations:** java-spring-boot3, java-spring-boot3-test, java-spring-boot4, java-spring-boot4-test, java-quarkus, java-micronaut
  • 448 L1 annotations: raw syscall primitives across 6 OS-level fault domains
  • 92 L2 composites: named fault patterns with calibrated, production-derived defaults
  • 64 L3 incidents: real post-mortems as single annotations with Severity ratings
  • 43 Gradle modules: independently versioned, no mandatory transitive dependencies
  • 4 binary variants: glibc-amd64, glibc-arm64, musl-amd64, musl-arm64 — bundled in JAR
  • Spring Boot 3/4, Quarkus, Micronaut: full framework integration out of the box

tags

#chaos-engineering #java #junit5 #spring-boot #quarkus #micronaut #testcontainers #resilience #ci-cd #annotations

conclusion

The framework exists because incident post-mortems have action items that never get implemented. Not because engineers are lazy, but because the tooling gap between "we should test this failure mode" and "here is a test that tests this failure mode" was too wide. 448 L1 annotations for surgical control. 92 L2 composites for documented failure families. 64 L3 incident scenarios that let you turn a Slack post-mortem into a failing CI test in five minutes. The chaos layer doesn't replace unit tests or integration tests. It completes the picture. Phase 4 is the phase where your CI pipeline learns to reproduce production incidents before they happen again.
E

Engineering Team

Senior Solutions Architects

We've been building distributed systems since before 'microservices' was a thing. Our scars tell stories.