Files
Claude a7b76243fa Add awaitility module: testing async @Async/@Scheduled/@KafkaListener code without Thread.sleep
Covers replacing Thread.sleep with await() across a void @Async method, an
@EmbeddedKafka-backed @KafkaListener, and an already-running @Scheduled job;
verifies Awaitility 4.3.0's real defaults (10s timeout, 100ms poll interval)
and its default-uncaught-exception-handler swap directly against the jar;
and documents a real pom.xml trap where Boot 4.1.1 split Kafka's
autoconfiguration (spring-boot-kafka) out of spring-kafka itself, which
silently leaves @KafkaListener beans with no running container.
2026-10-08 15:18:58 +00:00

5.3 KiB

awaitility

Companion code for the Awaitility article on ankurm.com. All intermediate and reference-grade depth lives in the post itself (accordions / "going deeper" paragraphs), not in a docs/NN-topic.md chapter folder — the one exception, as in the rest of this repository, is docs/output/, which holds real captured transcripts and nothing else.

What this is

Six real, runnable demonstrations of the same idea: a fixed Thread.sleep(...) in a test is a guess about timing the test does not control, and Awaitility.await() replaces the guess with a bounded poll loop. Each demonstration targets a different source of asynchrony:

Test What it waits for
AwaitAsyncConfirmationTest A void @Async method with no Future to block on
DefaultTimingTest Nothing — it proves Awaitility's own defaults (10s timeout, 100ms poll interval) against the real jar
KafkaListenerAwaitTest An @KafkaListener consuming a record a KafkaTemplate just sent, against a real in-process @EmbeddedKafka broker
ScheduledJobAwaitTest Three more executions of an already-running @Scheduled(fixedRate = 150) job
UncaughtExceptionHandlerSwapTest Nothing — it proves await() temporarily installs its own Thread.setDefaultUncaughtExceptionHandler and restores the original afterward
IgnoreExceptionsTest A resource that throws for its first 300ms, using ignoreExceptionsInstanceOf(...) to treat that as "not yet", not a failure

Two more transcripts in docs/output/ are real failures from tests that are not in the suite above: 01-sleep-guesses-wrong.txt (a fixed Thread.sleep(100) against a 220ms operation) and 08-exception-propagates-immediately.txt (the same resource as IgnoreExceptionsTest, minus ignoreExceptionsInstanceOf(...)). Both were real mvn test runs, captured once, then the failing test class was deleted — the mistake is preserved as a transcript, not as a permanently red test.

The pom.xml trap this module exists to document

The first version of this module's pom.xml depended on org.springframework.kafka:spring-kafka directly, the way every pre-Boot-4.1 tutorial does. It compiled. Every @SpringBootTest using Kafka then failed with an empty ConcurrentLinkedQueue and no error at context startup, because in Boot 4.1.1 the Kafka autoconfiguration classes (KafkaTemplate, ConsumerFactory, the KafkaListenerEndpointRegistry that @KafkaListener needs) moved out of the monolithic spring-boot-autoconfigure jar into their own module, org.springframework.boot:spring-boot-kafka — pulled in by the new org.springframework.boot:spring-boot-starter-kafka, not by spring-kafka alone. See pom.xml's own comments and the post for the full diagnosis.

Versions

Read from spring-boot-dependencies-4.1.1.pom and spring-boot-starter-test-4.1.1.pom on Maven Central, not from release notes: JDK 25 (Temurin 25.0.4.1+1), Spring Boot 4.1.1, Spring Kafka 4.1.1, Awaitility 4.3.0 (already on the classpath via spring-boot-starter-test — no explicit <dependency> for it anywhere in pom.xml).

Quickstart

mvn test

11 tests, 0 failures. KafkaListenerAwaitTest starts a real in-process Kafka broker (@EmbeddedKafka) and takes a few seconds; DefaultTimingTest deliberately waits out Awaitility's real 10-second default timeout once, so the suite as a whole takes about 30 seconds.

Captured output

File What it's from
00-full-test-run.txt The full mvn test run, 11/11 green
01-sleep-guesses-wrong.txt Real failure: a guessed Thread.sleep(100) against a 220ms operation (test since removed)
02-await-finds-it.txt The fixed version: await().untilAsserted(...) against the same operation
03-default-timeout-is-ten-seconds.txt await().until(() -> false) timing out at ~10,000ms with no override
04-default-poll-interval-is-100ms.txt Real poll timestamps, ~100ms apart, with no override
05-kafka-listener-await.txt await() for an @KafkaListener to consume a record just sent
06-scheduled-job-await.txt await() for 3 more executions of an already-running @Scheduled job
07-uncaught-exception-handler-swap.txt Proof that await() swaps and restores the JVM's default uncaught-exception handler
08-exception-propagates-immediately.txt Real failure: an exception from the polled condition, with no ignoreExceptionsInstanceOf(...), failing on the first poll (test since removed)
09-ignore-exceptions-waits-it-out.txt The fixed version: the same resource, with ignoreExceptionsInstanceOf(...)
10-missing-kafka-starter-failure.txt Real failure: the Kafka test with spring-kafka as a direct dependency instead of spring-boot-starter-kafka (pom.xml since fixed)
11-pom-diagnosis.txt The real curl/grep transcript that found the Boot 4.1 Kafka module split against Maven Central's own POMs

What's sourced from documentation, not run here

Awaitility's own @since Javadoc tags, Spring Boot's own migration notes for the Kafka module split, and the general shape of @KafkaListener/@EmbeddedKafka wiring are cited from the real jars and POMs on Maven Central (see the post for exact artifact coordinates), not re-derived from scratch in this module.

Licence

MIT — see LICENSE.