De Incidentes en Producción a Anotaciones JUnit 5: Un Framework de Chaos Testing en Tres Niveles
Ingeniería category.testing 26 de junio de 2026

De Incidentes en Producción a Anotaciones JUnit 5: Un Framework de Chaos Testing en Tres Niveles

448 primitivas syscall de nivel L1. 92 composiciones de fallos de nivel L2. 64 escenarios de incidentes de producción de nivel L3 codificados como anotaciones únicas. Un framework de extensión JUnit 5 que convierte los post-mortems en puertas de CI para Spring Boot, Quarkus y Micronaut.

E
Engineering Team
Senior Solutions Architects
20 min de lectura

Una Anotación. Tu Último Incidente en Producción. Reproducido.

En algún lugar del historial de Slack de tu equipo hay un post-mortem. Tiene una sección llamada "Causa Raíz" que describe cómo una actualización progresiva de Kubernetes creó una ventana de 30 segundos en la que iptables no había propagado la eliminación del nuevo pod, y se devolvía ECONNRESET en las conexiones activas al pod antiguo. Tiene una sección de "Mitigación" que dice que configuraste reintentos con retroceso exponencial. Tiene una sección de "Elementos de Acción" con una casilla junto a "Añadir prueba de chaos para este escenario".

Esa casilla nunca se ha marcado.

No porque al equipo no le importe. Sino porque escribir una prueba de chaos para ese escenario específico requiere entender cómo el retraso de iptables se mapea a tasas de ECONNRESET, qué llamada a libc se intercepta, cuál es la probabilidad correcta y cómo inyectar el fallo en un contenedor Docker que corre en un entorno de CI.

Eso es mucho conocimiento previo para una casilla de verificación.

`@IncidentChaosK8sRollingUpdateRst(toxicity = 0.3)` es una importación y una anotación. Eso es marcar la casilla.

java
// 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);
}

La Jerarquía de Anotaciones en Tres Niveles

El framework organiza los escenarios de chaos en tres niveles, cada uno orientado a un nivel diferente de conocimiento y caso de uso:

**L1 — Primitivas Brutas (448 anotaciones):** Control directo a nivel de syscall. Una anotación corresponde a una regla de fallo: un errno, un tipo de operación, una probabilidad. Esta es la capa para ingenieros que saben exactamente qué llamada al kernel quieren fallar y a qué tasa. Máximo poder expresivo, sin abstracción.

**L2 — Composiciones Nombradas (92 anotaciones):** Familias de fallos documentadas, cada una componiendo entre 2 y 6 reglas L1 en un patrón con nombre. Esta es la capa para escenarios de fallo comunes que tienen una forma bien entendida pero no necesitan rastrearse hasta un incidente específico. Los valores predeterminados están calibrados a partir de análisis de fallos del mundo real.

**L3 — Escenarios de Incidentes (64 anotaciones):** Incidentes de producción reales codificados como composiciones multidominios. Cada anotación L3 lleva una valoración de Severidad, una referencia al incidente original o fuente de la industria, y una clase Composer que sabe cómo descomponer el escenario en la mezcla correcta de reglas L1 a través de múltiples dominios. Esta es la capa para "reproducir lo que sucedió en el post-mortem".

Los niveles no son exclusivos: puedes mezclar anotaciones L1, L2 y L3 en la misma clase o método de prueba, y se apilan correctamente. Las anotaciones de alcance de método anulan las anotaciones de alcance de clase durante el tiempo que dura el método de prueba, y luego restauran las reglas de alcance de clase cuando el método termina.

L1: Primitivas Syscall Brutas — 448 Anotaciones

El nivel L1 te da control directo sobre reglas de fallo individuales a nivel de syscall. Cada anotación sigue el mismo patrón: operación objetivo + errno o efecto + probabilidad + patrón de host o ruta opcional.

java
// 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: Composiciones de Fallos Nombradas — 92 Patrones Documentados

El nivel L2 codifica patrones de fallo documentados con valores predeterminados calibrados. Cada composición representa una forma de fallo que tiene un nombre en los post-mortems de incidentes y un impacto conocido y predecible en el comportamiento de la aplicación.

  • @CompositeChaosConnectionRefused — ECONNREFUSED en cada connect(), simulando un servicio descendente caído
  • @CompositeChaosTransientDnsFailure — EAI_AGAIN en el 15% de las llamadas getaddrinfo(), simulando sobrecarga de CoreDNS
  • @CompositeChaosLowMemoryPressure — ENOMEM en el 0,5% de las llamadas mmap(), simulando contención de memoria
  • @CompositeChaosNetworkFlap — alternancia entre ECONNRESET y éxito, simulando un enlace de red inestable
  • @CompositeChaosSlowDisk — latencia de 50–200 ms en write(), simulando almacenamiento saturado de I/O
  • @CompositeChaosClockDrift — desviación de reloj firmada de ±5 segundos, simulando deriva de NTP
  • @CompositeChaosConnectionTimeout — ETIMEDOUT en el 5% de connect() con 3 s de latencia, simulando timeout de firewall
  • @CompositeChaosShortWrite — TORN en el 10% de las escrituras, simulando el sistema de archivos devolviendo conteos cortos

L3: Incidentes de Producción Reales — 64 Escenarios Anotados

El nivel L3 es donde el chaos engineering se convierte en memoria institucional. Cada anotación codifica un modo de fallo real, documentado en informes de incidentes de producción, rastreadores de issues de Kubernetes o análisis de fallos de sistemas distribuidos bien conocidos.

El patrón Composer gestiona la descomposición: cada anotación L3 referencia una clase Composer que sabe cómo traducir una descripción de incidente de alto nivel en la mezcla correcta de reglas L1 a través de múltiples dominios de fallo.

java
// 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 en Cualquier Imagen de Producción

Para pruebas que ejecutan código de aplicación dentro de contenedores Docker (patrón Testcontainers), el framework inyecta automáticamente las bibliotecas C99 de LD_PRELOAD en el contenedor antes de que arranque.

El mecanismo de inyección utiliza el flujo tar de la API de Docker, no shell exec, ni volúmenes, ni contenedores init:

1. La extensión JUnit detecta el sistema operativo base de la imagen del contenedor (Alpine → musl, Debian/RHEL/Ubuntu → glibc). 2. Selecciona el binario precompilado correcto (glibc/musl × amd64/arm64 — cuatro variantes, incluidas en el JAR). 3. Copia el archivo `.so` en el contenedor mediante `DockerClient.copyArchiveToContainerCmd()` antes de `container.start()`. 4. Establece `LD_PRELOAD=/chaos/libchaos-net.so` (y otros según lo declarado) en el entorno del contenedor.

Esto significa que el chaos testing funciona en imágenes `distroless`, imágenes basadas en `scratch`, UBI minimal, Alpine — cualquier imagen que use un enlazador dinámico ELF estándar. Sin modificaciones en el Dockerfile, sin sidecar, sin infraestructura adicional.

java
// @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);
    }
}

Integraciones con Frameworks: Spring Boot, Quarkus, Micronaut

Cada framework Java principal dispone de un módulo de integración dedicado que gestiona tanto la orquestación del lado de las pruebas como el plano de control de chaos en tiempo de ejecución opcional.

**Spring Boot 3 y 4:** El starter de pruebas proporciona `@ChaosTest` como anotación compuesta, registra `ChaosControlPlane` y `ChaosSession` como beans de Spring, y ofrece resolución de parámetros JUnit 5 para ambos. Se compone con `@SpringBootTest`, `@DataJpaTest`, `@WebMvcTest` y cualquier otra anotación de segmento de prueba de Spring.

El starter en tiempo de ejecución expone `/actuator/chaos` como un endpoint de Actuator protegido para la gestión de escenarios en tiempo real en entornos no productivos.

**Quarkus:** La extensión de Quarkus proporciona `@QuarkusChaosTest` como anotación de prueba Quarkus compuesta con inyección CDI de `ChaosControlPlane`. Se integra con `@QuarkusTest`, `@QuarkusIntegrationTest` y pruebas de imagen nativa.

**Micronaut:** La integración de Micronaut proporciona `@MicronautChaosTest` con beans `ChaosControlPlane` y `ChaosSession` compatibles con `@Inject`. Funciona con `@MicronautTest` tanto en modo de prueba JVM como nativo.

  • Spring Boot 3/4: anotación compuesta @ChaosTest, ChaosControlPlane como bean de Spring, endpoint /actuator/chaos
  • Quarkus: @QuarkusChaosTest, inyección CDI, compatible con @QuarkusIntegrationTest y nativo
  • Micronaut: @MicronautChaosTest, beans compatibles con @Inject, soporte de pruebas JVM y nativo
  • JUnit 5 puro: @ExtendWith(ChaosTestingExtension.class) con ChaosControlPlane manual — no se requiere ningún framework
  • Compatible con Gradle y Maven: todos los módulos publicados en Maven Central con coordenadas estándar

Recursos y Restricciones: La Anotación @Resources

Más allá de la inyección de fallos, el framework admite pruebas de restricción de recursos mediante `@Resources`. Esto declara límites de CPU y memoria que la extensión JUnit aplica al contenedor Docker antes de que arranque, sin modificar la definición del contenedor en el código de prueba.

Esto habilita una clase de pruebas que a menudo se omite: "¿Se comporta correctamente mi servicio cuando tiene recursos restringidos?" Este es un escenario realista en Kubernetes, donde los pods se ejecutan con límites de CPU y memoria, y donde alcanzar esos límites desencadena throttling y muertes por OOM.

java
// 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
    }
}

La Arquitectura de Plugins: Una Extensión, Todos los Contenedores

Las primeras versiones del framework tenían extensiones JUnit separadas para cada tipo de contenedor: una para Redis, una para PostgreSQL, una para Kafka, y así sucesivamente. Cada extensión tenía más de 200 líneas de código de ciclo de vida casi idéntico. Extender a un nuevo tipo de contenedor implicaba copiar y modificar.

La arquitectura actual utiliza un SPI `ChaosPlugin` descubierto mediante `ServiceLoader.load(ChaosPlugin.class)`. Cada tipo de contenedor registra una implementación. La única `ChaosTestingExtension` los orquesta a todos.

El contrato SPI es mínimo: descubrir contenedores en la clase de prueba, proporcionar información de conexión, gestionar la aplicación de recursos y gestionar la configuración de chaos previa al arranque. La extensión universal se encarga de todo lo demás: procesamiento de anotaciones, gestión del ciclo de vida, aislamiento de sesiones y resolución de parámetros.

Esto elimina aproximadamente 8.000 líneas de duplicación y hace que el framework sea extensible a nuevos tipos de contenedores sin modificar el núcleo.

java
// 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 Módulos, 11 Dominios, Un Único Modelo Mental

El framework está organizado en 43 módulos Gradle que cubren 11 dominios de fallo. Aquí está el panorama completo:

**Infraestructura central:** chaos-core (extensión JUnit 5, SPI, ciclo de vida), chaos-spi (contrato de plugin), chaos-api (tipos de selector y efecto)

**Paquetes de prueba por dominio de fallo:** testpacks-connection (TCP/UDP), testpacks-dns (resolución DNS), testpacks-memory (malloc/mmap), testpacks-process (fork/exec/señales), testpacks-time (reloj, desviación de tiempo), testpacks-filesystem (I/O de archivos)

**Integración JVM:** chaos-java (transporte de bytecode JVM, cableado del lado del contenedor)

**Paquetes de incidentes L3:** testpacks-l3-kubernetes (actualizaciones progresivas, tormentas DNS), testpacks-l3-feign (amplificación de reintentos), testpacks-l3-spring (deadlock transaccional, inanición OSIV), testpacks-l3-redis (tormentas de failover), testpacks-l3-kafka (failover de broker), testpacks-l3-grpc (tormentas GOAWAY)

**Integraciones de frameworks:** java-spring-boot3, java-spring-boot3-test, java-spring-boot4, java-spring-boot4-test, java-quarkus, java-micronaut

  • 448 anotaciones L1: primitivas syscall brutas en 6 dominios de fallo a nivel de SO
  • 92 composiciones L2: patrones de fallo nombrados con valores predeterminados calibrados derivados de producción
  • 64 incidentes L3: post-mortems reales como anotaciones únicas con valoraciones de Severidad
  • 43 módulos Gradle: con versiones independientes, sin dependencias transitivas obligatorias
  • 4 variantes binarias: glibc-amd64, glibc-arm64, musl-amd64, musl-arm64 — incluidas en el JAR
  • Spring Boot 3/4, Quarkus, Micronaut: integración completa con los frameworks lista para usar

Key Takeaways

El framework existe porque los post-mortems de incidentes tienen elementos de acción que nunca se implementan. No porque los ingenieros sean perezosos, sino porque la brecha de herramientas entre "deberíamos probar este modo de fallo" y "aquí hay una prueba que lo verifica" era demasiado grande.

448 anotaciones L1 para control quirúrgico. 92 composiciones L2 para familias de fallos documentadas. 64 escenarios de incidentes L3 que te permiten convertir un post-mortem de Slack en una prueba de CI fallida en cinco minutos.

La capa de chaos no reemplaza las pruebas unitarias ni las pruebas de integración. Completa el cuadro. La fase 4 es la fase en la que tu pipeline de CI aprende a reproducir incidentes de producción antes de que vuelvan a ocurrir.

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

Engineering Team

Senior Solutions Architects

Llevamos construyendo sistemas distribuidos desde antes de que 'microservicios' fuera una palabra de moda. Nuestras cicatrices cuentan historias.