- Published on
- · Updated
Testing Spring Boot Kafka Microservices with Testcontainers
- Authors

- Name
- Maria
Testing a microservice is not the same as starting every service and checking whether one happy path works. A useful test suite isolates different failure boundaries:
- business rules;
- Spring MVC and serialization;
- PostgreSQL mappings, constraints, and migrations;
- Kafka serialization, keys, headers, retries, and consumer behavior;
- producer-consumer contracts;
- the complete business flow across deployed services.
The goal is not to maximize the number of test types. The goal is to place each risk in the cheapest test that can detect it reliably.
This guide uses Spring Boot 4.1, Java 25, PostgreSQL, Apache Kafka, Testcontainers 2, and JUnit Jupiter.
TL;DR Keep domain tests independent of Spring. Use slice tests for HTTP and persistence boundaries. Use real PostgreSQL and Kafka containers for behavior that mocks cannot reproduce. Verify the actual Kafka record rather than only checking that a broker is reachable. Test contracts separately from end-to-end workflows, and keep the end-to-end suite small.
Start with Risks, Not a Pyramid Diagram
The traditional testing pyramid is still useful, but it can become too abstract for event-driven systems.
A more practical map is:
| Risk | Best first test |
|---|---|
| Price or validation rule | Pure unit test |
| HTTP status and JSON shape | MVC slice test |
| JPA mapping and SQL constraint | PostgreSQL integration test |
| Outbox transaction atomicity | PostgreSQL integration test |
| Kafka key, header, and payload | Producer integration test |
| Consumer retry and idempotency | Kafka + PostgreSQL integration test |
| Producer-consumer compatibility | Contract test |
| Cross-service business journey | Targeted end-to-end test |
| Broker outage or network delay | Failure-injection test |
A full Spring context is not automatically more valuable. It is slower, contains more unrelated behavior, and can make the real failure harder to locate.
Test Categories and Build Boundaries
Use explicit categories so developers know what runs where.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Tag("integration")
public @interface IntegrationTest {}
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Tag("e2e")
public @interface EndToEndTest {}
A common pipeline is:
Pull request
-> unit and slice tests
-> service integration tests
-> contract verification
Main branch or release candidate
-> broader integration suite
-> failure scenarios
-> selected end-to-end flows
Do not hide slow container tests inside a unit-test task without naming them. Slow feedback encourages developers to skip the suite.
Dependencies for Spring Boot 4.1
Spring Boot 4.1 manages compatible Testcontainers 2 modules, so versions do not need to be repeated.
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
<relativePath />
</parent>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-kafka</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-kafka-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Testcontainers 2 renamed its module artifacts. Use testcontainers-postgresql, testcontainers-kafka, and testcontainers-junit-jupiter, not the older artifact names from Testcontainers 1.x examples.
A Small Example Domain
Use money types that preserve decimal semantics.
public record CreateProductCommand(
String name,
BigDecimal price
) {}
public record ProductCreated(
UUID eventId,
UUID productId,
String name,
BigDecimal price,
int schemaVersion,
Instant occurredAt
) {}
The application stores the product and an outbox record in one transaction.
@Service
public class ProductApplicationService {
private final ProductRepository products;
private final ProductOutboxRepository outbox;
private final Clock clock;
public ProductApplicationService(
ProductRepository products,
ProductOutboxRepository outbox,
Clock clock
) {
this.products = products;
this.outbox = outbox;
this.clock = clock;
}
@Transactional
public ProductResult create(
CreateProductCommand command
) {
validate(command);
Product product = products.save(
Product.create(
UUID.randomUUID(),
command.name(),
command.price()
)
);
ProductCreated event = new ProductCreated(
UUID.randomUUID(),
product.getId(),
product.getName(),
product.getPrice(),
1,
clock.instant()
);
outbox.save(
ProductOutbox.from(event)
);
return ProductResult.from(product);
}
private void validate(
CreateProductCommand command
) {
if (command.name() == null
|| command.name().isBlank()) {
throw new InvalidProductException(
"Product name is required"
);
}
if (command.price() == null
|| command.price()
.signum() < 0) {
throw new InvalidProductException(
"Product price cannot be negative"
);
}
}
}
Injecting Clock makes time deterministic in tests.
Pure Unit Tests for Business Rules
A unit test should not load Spring when Spring behavior is not under test.
@ExtendWith(MockitoExtension.class)
class ProductApplicationServiceTest {
@Mock
ProductRepository products;
@Mock
ProductOutboxRepository outbox;
Clock clock = Clock.fixed(
Instant.parse(
"2026-06-26T09:00:00Z"
),
ZoneOffset.UTC
);
ProductApplicationService service;
@BeforeEach
void setUp() {
service = new ProductApplicationService(
products,
outbox,
clock
);
}
@Test
void rejectsNegativePrice() {
CreateProductCommand command =
new CreateProductCommand(
"Keyboard",
new BigDecimal("-1.00")
);
assertThatThrownBy(
() -> service.create(command)
)
.isInstanceOf(
InvalidProductException.class
)
.hasMessage(
"Product price cannot be negative"
);
verifyNoInteractions(
products,
outbox
);
}
@Test
void storesProductAndOutboxIntent() {
when(products.save(any()))
.thenAnswer(invocation ->
invocation.getArgument(0)
);
ProductResult result = service.create(
new CreateProductCommand(
"Keyboard",
new BigDecimal("99.90")
)
);
assertThat(result.name())
.isEqualTo("Keyboard");
ArgumentCaptor<ProductOutbox> event =
ArgumentCaptor.forClass(
ProductOutbox.class
);
verify(outbox).save(event.capture());
assertThat(event.getValue()
.getOccurredAt())
.isEqualTo(clock.instant());
}
}
This verifies decisions and collaboration. It does not prove that PostgreSQL commits the two records atomically. That belongs in an integration test.
MVC Slice Tests
Use an MVC slice to verify routing, validation, authentication wiring, status codes, and JSON without starting PostgreSQL or Kafka.
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
MockMvc mvc;
@MockitoBean
ProductApplicationService products;
@Test
void returnsCreatedProduct() throws Exception {
UUID productId = UUID.randomUUID();
when(products.create(any()))
.thenReturn(
new ProductResult(
productId,
"Keyboard",
new BigDecimal("99.90")
)
);
mvc.perform(
post("/products")
.contentType(
MediaType.APPLICATION_JSON
)
.content("""
{
"name": "Keyboard",
"price": 99.90
}
""")
)
.andExpect(status().isCreated())
.andExpect(
jsonPath("$.id")
.value(
productId.toString()
)
)
.andExpect(
jsonPath("$.price")
.value(99.90)
);
}
}
Do not make this test verify JPA or Kafka. A slice test should remain focused.
Repository Tests Must Use PostgreSQL
H2 cannot reproduce every PostgreSQL behavior:
jsonb;- partial indexes;
ON CONFLICT;- row-level locking;
SKIP LOCKED;- transaction isolation;
- PostgreSQL-specific constraints and functions.
Use a real PostgreSQL container and run the same Flyway migrations as production.
@Testcontainers
@DataJpaTest
@Import(ProductOutboxRepository.class)
class ProductRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>(
"postgres:17-alpine"
);
@Autowired
ProductRepository products;
@Autowired
TestEntityManager entityManager;
@Test
void rejectsDuplicateExternalKey() {
products.saveAndFlush(
Product.create(
UUID.randomUUID(),
"sku-42",
"Keyboard",
new BigDecimal("99.90")
)
);
assertThatThrownBy(() ->
products.saveAndFlush(
Product.create(
UUID.randomUUID(),
"sku-42",
"Second keyboard",
new BigDecimal("109.90")
)
)
)
.isInstanceOf(
DataIntegrityViolationException.class
);
}
}
When the application depends on Flyway, prefer a full integration test that allows Boot to run migrations instead of letting Hibernate create tables from entities. Entity-generated schemas can hide migration mistakes.
Service Connections Remove Manual Property Wiring
Spring Boot's @ServiceConnection creates connection-details beans from supported containers. PostgreSQL and Kafka connection properties then override ordinary configuration automatically.
@TestConfiguration(proxyBeanMethods = false)
public class IntegrationTestContainers {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgres() {
return new PostgreSQLContainer<>(
"postgres:17-alpine"
);
}
@Bean
@ServiceConnection
KafkaContainer kafka() {
return new KafkaContainer(
"apache/kafka-native:4.3.1"
);
}
}
The current Testcontainers Kafka classes are in org.testcontainers.kafka. The older org.testcontainers.containers.KafkaContainer is deprecated.
Use @DynamicPropertySource when a container has no service-connection factory or when the test needs unusual property mapping. Do not duplicate both mechanisms for the same service.
Test the Transaction Boundary with PostgreSQL
The most important product-service guarantee is:
product row commits
if and only if
outbox row commits
Use the real transaction manager and production migrations.
@Testcontainers
@SpringBootTest
@IntegrationTest
class ProductTransactionIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>(
"postgres:17-alpine"
);
@Autowired
ProductApplicationService service;
@Autowired
ProductRepository products;
@Autowired
ProductOutboxRepository outbox;
@MockitoBean
OutboxPayloadFactory payloadFactory;
@Test
void rollsBackProductWhenOutboxCreationFails() {
when(payloadFactory.create(any()))
.thenThrow(
new IllegalStateException(
"Serialization failed"
)
);
assertThatThrownBy(() ->
service.create(
new CreateProductCommand(
"Keyboard",
new BigDecimal("99.90")
)
)
)
.isInstanceOf(
IllegalStateException.class
);
assertThat(products.count())
.isZero();
assertThat(outbox.count())
.isZero();
}
}
Do not mock the repository in a transaction test. A mocked save() cannot prove commit or rollback behavior.
A Full Producer Integration Test
A producer integration test should verify the record that a real Kafka broker received:
- topic;
- message key;
- headers;
- serialized payload;
- schema version.
The test should not stop at “Kafka was reachable.”
@Testcontainers
@SpringBootTest(properties = {
"outbox.scheduler.enabled=false"
})
@IntegrationTest
class ProductEventPublishingIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>(
"postgres:17-alpine"
);
@Container
@ServiceConnection
static KafkaContainer kafka =
new KafkaContainer(
"apache/kafka-native:4.3.1"
);
@Autowired
ProductApplicationService products;
@Autowired
ProductOutboxRelay relay;
@Autowired
ObjectMapper objectMapper;
@Test
void publishesCommittedOutboxEvent() {
ProductResult product = products.create(
new CreateProductCommand(
"Keyboard",
new BigDecimal("99.90")
)
);
int published = relay.publishOnce(10);
assertThat(published).isEqualTo(1);
try (KafkaConsumer<String, String> consumer =
createConsumer()) {
consumer.subscribe(
List.of("product.events.v1")
);
ConsumerRecord<String, String> record =
pollOne(
consumer,
Duration.ofSeconds(10)
);
assertThat(record.key())
.isEqualTo(
product.id().toString()
);
ProductCreated event =
objectMapper.readValue(
record.value(),
ProductCreated.class
);
assertThat(event.productId())
.isEqualTo(product.id());
assertThat(event.schemaVersion())
.isEqualTo(1);
} catch (JsonProcessingException exception) {
throw new AssertionError(exception);
}
}
private KafkaConsumer<String, String>
createConsumer() {
Properties properties = new Properties();
properties.put(
ConsumerConfig
.BOOTSTRAP_SERVERS_CONFIG,
kafka.getBootstrapServers()
);
properties.put(
ConsumerConfig.GROUP_ID_CONFIG,
"product-publisher-test-"
+ UUID.randomUUID()
);
properties.put(
ConsumerConfig
.AUTO_OFFSET_RESET_CONFIG,
"earliest"
);
properties.put(
ConsumerConfig
.ENABLE_AUTO_COMMIT_CONFIG,
false
);
return new KafkaConsumer<>(
properties,
new StringDeserializer(),
new StringDeserializer()
);
}
private ConsumerRecord<String, String>
pollOne(
KafkaConsumer<String, String> consumer,
Duration timeout
) {
Instant deadline =
Instant.now().plus(timeout);
while (Instant.now().isBefore(deadline)) {
ConsumerRecords<String, String> records =
consumer.poll(
Duration.ofMillis(250)
);
if (!records.isEmpty()) {
return records.iterator().next();
}
}
throw new AssertionError(
"No Kafka record received"
);
}
}
The scheduler is disabled so the test controls when the relay runs. Deterministic tests are easier to debug than tests racing a background scheduler.
Test the Consumer's Observable Effect
A consumer test should send a real Kafka record and verify the resulting database state.
@Testcontainers
@SpringBootTest
@IntegrationTest
class ProductProjectionConsumerTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>(
"postgres:17-alpine"
);
@Container
@ServiceConnection
static KafkaContainer kafka =
new KafkaContainer(
"apache/kafka-native:4.3.1"
);
@Autowired
KafkaTemplate<String, ProductCreated> kafkaTemplate;
@Autowired
ProductProjectionRepository projections;
@Test
void storesProductProjection() {
UUID productId = UUID.randomUUID();
ProductCreated event =
new ProductCreated(
UUID.randomUUID(),
productId,
"Keyboard",
new BigDecimal("99.90"),
1,
Instant.parse(
"2026-06-26T09:00:00Z"
)
);
kafkaTemplate.send(
"product.events.v1",
productId.toString(),
event
).join();
await()
.atMost(Duration.ofSeconds(10))
.pollInterval(
Duration.ofMillis(100)
)
.untilAsserted(() -> {
ProductProjection saved =
projections
.findById(productId)
.orElseThrow();
assertThat(saved.getName())
.isEqualTo("Keyboard");
});
}
}
Use Awaitility rather than a fixed Thread.sleep(). A fixed sleep is either too short and flaky or unnecessarily long.
Verify Idempotency and Redelivery
Kafka can redeliver a record. The consumer must produce the same durable result.
@Test
void duplicateEventIsAppliedOnce() {
UUID eventId = UUID.randomUUID();
UUID productId = UUID.randomUUID();
ProductCreated event =
new ProductCreated(
eventId,
productId,
"Keyboard",
new BigDecimal("99.90"),
1,
Instant.now()
);
kafkaTemplate.send(
"product.events.v1",
productId.toString(),
event
).join();
kafkaTemplate.send(
"product.events.v1",
productId.toString(),
event
).join();
await()
.atMost(Duration.ofSeconds(10))
.untilAsserted(() -> {
assertThat(
projections.countByProductId(
productId
)
).isEqualTo(1);
assertThat(
processedEvents.countByEventId(
eventId
)
).isEqualTo(1);
});
}
Also test the failure window in which the business update commits but offset acknowledgement does not. Restarting the consumer should redeliver the record without duplicating the projection.
Contract Tests Protect Independent Deployment
An integration test proves that one service can publish or consume a record. It does not prove that another independently released service still understands the contract.
A message contract should cover:
- topic;
- key semantics;
- required headers;
- field names and types;
- optional and defaulted fields;
- schema version;
- compatibility expectations.
A lightweight JSON contract test can compare the serialized event with a checked-in fixture.
@JsonTest
class ProductCreatedJsonContractTest {
@Autowired
ObjectMapper objectMapper;
@Test
void matchesVersionOneContract() throws Exception {
ProductCreated event =
new ProductCreated(
UUID.fromString(
"6a4016b5-31e2-4e91-8b52-00cce7e30f32"
),
UUID.fromString(
"44d22e88-9a8d-4fea-a975-2f6c8d761748"
),
"Keyboard",
new BigDecimal("99.90"),
1,
Instant.parse(
"2026-06-26T09:00:00Z"
)
);
JsonNode actual =
objectMapper.valueToTree(event);
JsonNode expected =
objectMapper.readTree(
new ClassPathResource(
"contracts/product-created-v1.json"
).getInputStream()
);
assertThat(actual)
.isEqualTo(expected);
}
}
Fixture:
{
"eventId": "6a4016b5-31e2-4e91-8b52-00cce7e30f32",
"productId": "44d22e88-9a8d-4fea-a975-2f6c8d761748",
"name": "Keyboard",
"price": 99.90,
"schemaVersion": 1,
"occurredAt": "2026-06-26T09:00:00Z"
}
For larger organizations, Spring Cloud Contract can generate producer verification tests and consumer stubs for messaging contracts. Keep the contract focused on externally observable bytes and headers. Do not simulate a durable outbox by mocking ApplicationEventPublisher and then claim that Kafka delivery was verified.
Contract tests do not replace broker integration tests. They answer different questions:
Contract test:
Can both services understand the same message shape?
Kafka integration test:
Does the application really send and consume through Kafka?
Test Schema Evolution
An event change should be tested against both old and new readers.
Example evolution:
{
"eventId": "...",
"productId": "...",
"name": "Keyboard",
"price": 99.90,
"schemaVersion": 2,
"category": "ACCESSORIES"
}
Tests should verify:
- an old reader can ignore the additive field;
- a new reader can read a version-one record;
- required-field removal is rejected;
- semantic changes use a new event type or schema version;
- replayed historical records remain processable.
When using Avro, Protobuf, or JSON Schema, add Schema Registry compatibility checks to the build. A Java class compiling is not evidence of wire compatibility.
Embedded Kafka or Testcontainers Kafka?
Use one tool per test boundary.
Embedded Kafka
Spring Kafka 4 uses the KRaft-based embedded broker. It is useful for:
- fast listener tests;
- Spring Kafka configuration checks;
- tests that do not depend on container networking;
- local feedback where broker-image fidelity is not required.
Testcontainers Kafka
Use a Kafka container for:
- the broker distribution used by the platform;
- container networking;
- TLS, SASL, or listener configuration;
- broker restart and network failure;
- tests shared with non-Spring components;
- closer production fidelity.
Do not combine @EmbeddedKafka and a Kafka Testcontainer in the same test class. The application can connect to one broker while the assertion observes the other.
The old ZooKeeper-based examples are no longer a good default. Current Kafka and Spring Kafka testing support use KRaft.
End-to-End Tests Should Stay Small
An end-to-end test verifies a business journey through deployed boundaries.
Example:
POST /products
-> product row committed
-> outbox event published
-> Kafka consumer updates catalog projection
-> GET /catalog/products/{id}
-> new product is visible
Keep only critical flows:
- one successful purchase or order journey;
- one authentication journey;
- one compensation or cancellation journey;
- one migration or compatibility smoke test.
Do not reproduce every validation rule in E2E tests. Those belong in faster tests.
Spring Boot 4.1 can start a real server on a random port and provide a RestTestClient.
@SpringBootTest(
webEnvironment =
SpringBootTest.WebEnvironment.RANDOM_PORT
)
@AutoConfigureRestTestClient
class ProductApiIntegrationTest {
@Autowired
RestTestClient client;
@Test
void createsProductThroughHttp() {
client.post()
.uri("/products")
.body(
new CreateProductRequest(
"Keyboard",
new BigDecimal("99.90")
)
)
.exchange()
.expectStatus()
.isCreated()
.expectBody()
.jsonPath("$.name")
.isEqualTo("Keyboard");
}
}
For a system-wide suite, run packaged service images in an ephemeral environment or a dedicated Testcontainers Compose module. Treat that suite as a separate product with its own diagnostics and ownership.
Failure Injection Tests
Happy-path containers do not prove recovery behavior.
Use a TCP proxy such as Toxiproxy to inject:
- latency;
- connection reset;
- bandwidth restriction;
- temporary broker isolation;
- database connection loss.
Example topology:
application
-> Toxiproxy
-> Kafka container
Test scenarios:
Kafka unavailable during outbox publication
- product transaction commits;
- outbox row remains pending;
- relay records a retry;
- Kafka returns;
- event is eventually published once or duplicated safely.
Consumer crashes after DB commit
- projection transaction commits;
- consumer stops before offset progress is durable;
- record is redelivered;
- inbox or upsert prevents duplication.
PostgreSQL unavailable
- request fails without publishing a Kafka event;
- connection-pool recovery is bounded;
- no partial outbox intent appears.
Slow consumer
- consumer lag grows;
- readiness policy remains intentional;
- alerts trigger before retention risk.
A failure test should assert durable state, not merely that an exception was thrown.
Deterministic Asynchronous Tests
Avoid timing guesses.
Bad:
Thread.sleep(5000);
assertThat(repository.count()).isEqualTo(1);
Better:
await()
.atMost(Duration.ofSeconds(10))
.pollInterval(Duration.ofMillis(100))
.untilAsserted(() ->
assertThat(repository.count())
.isEqualTo(1)
);
Other determinism rules:
- inject
Clock; - use unique Kafka group IDs;
- create topics explicitly;
- disable unrelated schedulers;
- wait for send futures;
- clean database state;
- do not depend on test method order;
- avoid shared mutable static fixtures;
- use stable event IDs for duplicate tests;
- assert eventual state and intermediate state separately.
Awaitility is not a cure for an unbounded workflow. The timeout should reflect a known service objective.
Database Cleanup
Choose one cleanup strategy and make it visible.
Transaction rollback
Useful for repository tests that stay on the test thread.
It does not roll back work performed by Kafka listener threads or asynchronous tasks.
Truncate
Useful for full integration tests.
TRUNCATE TABLE
processed_events,
product_projection,
product_outbox,
products
RESTART IDENTITY
CASCADE;
Run it before each test through @Sql or a dedicated cleaner.
Fresh container per class
Provides strong isolation but increases startup time.
Static containers normally run once per test class. Reusing a container across classes can be efficient when every test cleans state correctly.
Do not set ddl-auto=create-drop when the purpose is to verify production Flyway migrations.
CI Execution
Container tests require a Docker-compatible runtime. Verify that the CI runner supports Testcontainers rather than disabling cleanup or weakening tests until they pass.
A useful split:
test
-> unit and slice tests
integration-test
-> PostgreSQL and Kafka containers
contract-test
-> producer and consumer contract verification
e2e-test
-> packaged services in an ephemeral environment
Cache Docker images at the runner level when supported, but do not depend on mutable latest tags. Pin images that the team has validated.
Testcontainers container reuse is an opt-in experimental feature. It can help local development, but it weakens isolation and is not a default CI strategy.
Do not disable Ryuk permanently just to hide cleanup problems. Fix the runtime permissions or use a supported Testcontainers environment.
Observability for Failing Tests
A failed asynchronous test should preserve enough evidence to answer:
- Was the event published?
- Which topic and partition received it?
- Did the consumer deserialize it?
- Did the transaction commit?
- Was the record retried or sent to a DLT?
- What was the consumer lag?
- Which container became unhealthy?
On test failure, capture:
application logs
Kafka container logs
PostgreSQL container logs
topic records
outbox rows
processed-event rows
DLT records
container health and mapped ports
Use correlation IDs and event IDs in assertions and logs. Do not print credentials or complete sensitive payloads.
Common Mistakes
“The integration test started Kafka, so Kafka publishing works”
A reachable broker proves connectivity only. Consume and verify the actual record.
“The outbox works because ApplicationEventPublisher was called”
A mocked Spring event does not prove database atomicity, relay behavior, or Kafka delivery.
“create-drop is enough for database integration”
It tests Hibernate's generated schema, not the production migration history.
“One giant @SpringBootTest replaces all lower-level tests”
It produces slower feedback and less precise failures. Keep pure logic and slices small.
“Thread.sleep() makes asynchronous tests stable”
It makes timing dependent on machine speed. Poll an observable condition with a deadline.
“Embedded Kafka and Testcontainers Kafka can run together”
They can, but the application and assertion may connect to different brokers. Use one intentionally.
“Every microservice flow needs an E2E test”
Most compatibility and business-rule failures are cheaper to detect below E2E.
“Container reuse is free speed”
Reused containers preserve state unless cleanup is rigorous.
Troubleshooting
Docker is not detected
Check:
- a Docker-API compatible runtime is running;
- the test process can access its socket or endpoint;
- the CI runner supports privileged or rootless container execution as configured;
- corporate proxies allow image pulls;
- disk and memory limits are sufficient.
PostgreSQL container starts, but migrations fail
Check:
- migration order;
- extensions;
- database user privileges;
- schema ownership;
- production-specific SQL;
- whether a previous test left state in a reused container.
Kafka records are not received
Check:
- the test consumer subscribed before the record was produced, or uses
auto.offset.reset=earliest; - the topic name;
- bootstrap servers;
- unique group ID;
- serializers and deserializers;
- record key and headers;
- send future completion;
- whether the application connected to embedded Kafka while the test inspected the container broker.
Consumer tests are flaky
Check:
- fixed sleeps;
- shared consumer groups;
- old topic data;
- database cleanup;
- background schedulers;
- transaction visibility;
- DLT and retry delays;
- application-context reuse;
- parallel tests sharing infrastructure.
Tests pass locally but fail in CI
Check:
- container architecture;
- image availability;
- slower startup;
- memory pressure;
- filesystem permissions;
- DNS and networking;
- implicit local services that are not present in CI;
- tests depending on execution order.
Contract tests pass, but production consumers fail
Check whether the contract verifies the actual serialized bytes, topic, headers, and schema compatibility. A Java DTO comparison alone may miss serializer configuration and header differences.
Review Checklist
Before calling the service test suite complete:
- Are core business rules tested without Spring?
- Do controller tests verify validation and error mapping?
- Do repository tests use PostgreSQL?
- Do integration tests run production migrations?
- Is product-plus-outbox atomicity verified?
- Is the actual Kafka record consumed and asserted?
- Are message keys, headers, and schema versions checked?
- Are duplicate and out-of-order records tested?
- Are listener failures allowed to reach retry handling?
- Is asynchronous waiting bounded and observable?
- Are producer-consumer contracts verified?
- Are only critical flows covered by E2E?
- Are broker and database failure windows tested?
- Does CI separate fast and slow suites?
- Are failure logs and durable state preserved?
Conclusion
A reliable microservice test strategy mirrors the system's real boundaries.
For Spring Boot, PostgreSQL, and Kafka:
- keep business tests fast and framework-free;
- use slices for HTTP and persistence wiring;
- use Testcontainers service connections for real dependencies;
- verify production migrations rather than entity-generated tables;
- test the database transaction and Kafka publication as separate durable boundaries;
- consume and inspect real Kafka records;
- make consumers idempotent and test redelivery;
- protect independent deployments with message contracts;
- reserve end-to-end tests for critical journeys;
- inject failure and test recovery;
- replace sleeps with bounded observation.
Testcontainers narrows the gap between test and production infrastructure. It does not decide what guarantee should be tested. That decision still begins with the failure window the service must survive.