Not every API answers immediately. Many systems accept a request, return 202 Accepted, and do the work later through a message broker such as Kafka: an order event triggers payment, inventory and email services. Testing these flows means producing or consuming messages and waiting correctly for eventual results.
What Makes Async APIs Different
Modern microservices use event-driven architecture: API call → event published → consumers react.
Example: POST /orders creates order record AND publishes OrderCreated event to Kafka.
The Kafka consumer then sends confirmation email, updates inventory, notifies warehouse.
Testing only the REST API misses the entire downstream event chain.
Senior SDETs verify both the API response AND the events published.
Kafka Testing with Testcontainers
// Kafka integration test using Testcontainers
@Testcontainers
@SpringBootTest
public class OrderEventTest {
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
);
@DynamicPropertySource
static void kafkaProperties(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
@Test
void createOrder_publishesOrderCreatedEvent() throws Exception {
// Setup Kafka consumer to capture events
Properties props = new Properties();
props.put("bootstrap.servers", kafka.getBootstrapServers());
props.put("group.id", "test-consumer-" + UUID.randomUUID());
props.put("auto.offset.reset", "earliest");
props.put("key.deserializer", StringDeserializer.class.getName());
props.put("value.deserializer", StringDeserializer.class.getName());
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(List.of("order-events"));
// Trigger: create order via API
String orderId = given().spec(withAuth("customer"))
.body(TestDataFactory.validOrder())
.when().post("/api/orders")
.then().statusCode(201)
.extract().jsonPath().getString("id");
// Poll Kafka for the event
ConsumerRecords<String, String> records = null;
long deadline = System.currentTimeMillis() + 10_000; // wait up to 10s
while (System.currentTimeMillis() < deadline) {
records = consumer.poll(Duration.ofMillis(500));
if (!records.isEmpty()) break;
}
consumer.close();
// Verify event published
assertThat(records).isNotNull().isNotEmpty();
ConsumerRecord<String, String> event = records.iterator().next();
JsonNode payload = new ObjectMapper().readTree(event.value());
assertThat(payload.get("eventType").asText()).isEqualTo("ORDER_CREATED");
assertThat(payload.get("orderId").asText()) .isEqualTo(orderId);
assertThat(payload.get("timestamp").asText()).isNotEmpty();
}
}
Awaitility + Kafka: A Cleaner Approach
// Use Awaitility to poll for the Kafka event cleanly
@Test
void createOrder_eventConsumedByInventoryService_stockDecremented() {
int productId = 101;
int initialStock = getStockFromApi(productId);
// Place order (triggers Kafka event → inventory service consumes it)
given().spec(withAuth("customer"))
.body(Map.of("items", List.of(Map.of("productId", productId, "qty", 2))))
.when().post("/api/orders")
.then().statusCode(201);
// Wait until inventory service processes the event and updates stock
await()
.atMost(15, TimeUnit.SECONDS)
.pollInterval(1, TimeUnit.SECONDS)
.untilAsserted(() -> {
int currentStock = getStockFromApi(productId);
assertThat(currentStock).isEqualTo(initialStock - 2);
});
}
private int getStockFromApi(int productId) {
return given().spec(reqSpec).pathParam("id", productId)
.when().get("/api/products/{id}")
.then().statusCode(200)
.extract().jsonPath().getInt("stock");
}
What to Test in Event-Driven Flows
- Message contract: event name, required fields, types and versioning (consumers break when producers change events).
- Eventual result: poll the API or database with a timeout until the downstream effect appears.
- Duplicates: most brokers deliver at least once, so consumers must handle the same event twice safely.
- Ordering: events for one entity (for example by order ID used as the key) arrive in order; others may not.
- Failures: malformed messages go to a dead-letter topic, retries back off, and nothing is silently lost.
- Observability: a correlation ID travels with the event so a failure can be traced across services.
FAQs
How do you test an API that returns 202 Accepted?
Assert the 202 and any job or status URL, then poll that status endpoint (or the resulting resource) with Awaitility until it reaches the expected state or a timeout fails the test.
What is Testcontainers used for?
Starting real dependencies such as Kafka, PostgreSQL or Redis in Docker containers from a test, so integration tests run against real software without a shared environment.
Why not use Thread.sleep in async tests?
A fixed sleep is either too short (flaky failures) or too long (slow suites). Awaitility polls until the condition is true, so tests are as fast as the system allows and fail clearly on timeout.