Stop Skipping Integration Tests in Spring Boot
by Eric Hanson, Backend Developer at Clean Systems Consulting
The Bug That Only Happens in Production
The service method is unit-tested. The repository has @DataJpaTest coverage. The controller has @WebMvcTest snapshots. Everything is green. Then you deploy, and a LazyInitializationException tears through a critical path because a @Transactional boundary wasn't where anyone thought it was.
Or a Flyway migration runs fine in isolation but conflicts with a JPA entity mapping that nobody caught because they were never tested together.
Or your @KafkaListener silently drops messages because the deserialization config in application-test.yml doesn't match what's actually on the consumer in production.
These are integration failures. Unit tests cannot catch them by design. The only tool that can is a test that runs the real stack — real database, real Spring context wiring, real serialization path — end to end.
Why Teams Skip Them
The most common excuse is speed. Integration tests are slower, and slow tests get deprioritized, then skipped, then nobody writes new ones.
The second excuse is complexity. Testcontainers used to be finicky. @SpringBootTest felt like black magic. Mocking external services seemed hard.
Both excuses are weaker than they were two years ago. Testcontainers has first-class Spring Boot integration since 3.1. Context startup overhead is manageable if you're deliberate about it. And the cost of a production incident from a missed integration failure is orders of magnitude higher than a CI pipeline that takes four extra minutes.
What Belongs in an Integration Test
Not everything. The failure mode on the other side is putting all your coverage into @SpringBootTest and having a 12-minute test run where 80% of the assertions could be done by a unit test in milliseconds.
Integration tests earn their cost when they cover:
- The full HTTP → service → database round trip for critical paths (payment processing, user registration, order submission)
- Transaction boundary behavior: does your
@Transactionalactually roll back on the right exceptions? Does lazy loading work within the expected session scope? - Database query correctness against a real engine: H2 will silently accept queries that fail on Postgres. Custom JPQL, native queries, and window functions all need a real dialect.
- Message consumption and production: Kafka consumers with real broker behavior, not mocked
KafkaTemplatecalls - Security filter chain behavior: does your JWT validation actually reject malformed tokens? Does the role-based access control wire up correctly end-to-end?
Setting Up the Stack
The baseline for most Spring Boot integration tests in 2024 is @SpringBootTest with Testcontainers. Here's the setup pattern that scales:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@ActiveProfiles("integration-test")
public abstract class IntegrationTestBase {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true); // keeps the container alive across test classes
@Container
static KafkaContainer kafka =
new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"))
.withReuse(true);
@DynamicPropertySource
static void overrideProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
}
The .withReuse(true) flag is worth highlighting. Without it, Testcontainers spins up a fresh Postgres and Kafka container for every test class that extends this base. With it, the containers stay alive for the entire JVM session. On a project with 20 integration test classes, this alone can cut test time in half. Enable it globally by adding testcontainers.reuse.enable=true to ~/.testcontainers.properties.
Your concrete test classes then extend this base and get the infrastructure for free:
class PaymentFlowTest extends IntegrationTestBase {
@Autowired TestRestTemplate restTemplate;
@Autowired PaymentRepository paymentRepository;
@Test
void submittedPaymentIsPersisted() {
var request = new PaymentRequest("USD", BigDecimal.valueOf(250));
var response = restTemplate.postForEntity("/api/payments", request, PaymentResponse.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
var saved = paymentRepository.findById(response.getBody().getId());
assertThat(saved).isPresent();
assertThat(saved.get().getStatus()).isEqualTo(PaymentStatus.PENDING);
}
}
This test proves the controller receives the request, the service processes it, the repository writes it, and the ID comes back correctly serialized in the response. No amount of unit testing gives you that confidence.
Testing Transaction Boundaries
This is the category most teams skip entirely, and it's where production surprises live.
@Test
void transactionRollsBackOnFraudCheckFailure() {
var request = new PaymentRequest("USD", BigDecimal.valueOf(999_999));
// fraud check threshold configured in application-integration-test.yml
assertThatThrownBy(() ->
paymentService.process(request)
).isInstanceOf(FraudCheckException.class);
// The key assertion: nothing was committed
assertThat(paymentRepository.count()).isZero();
}
This test would pass with mocks because you'd mock the repository and verify no save was called. But it catches a real class of bug: @Transactional is on the wrong method, or the exception type doesn't trigger rollback because it's checked, or a nested @Transactional(propagation = REQUIRES_NEW) is committing a record before the outer transaction can roll it back.
Kafka Consumer Tests
Kafka consumer logic is where the gap between unit tests and reality is widest. Deserializer config, consumer group behavior, and offset management all interact in ways that mocks can't replicate.
@Test
void consumesPaymentEventAndUpdatesStatus() throws Exception {
var eventJson = """
{
"paymentId": 42,
"status": "COMPLETED",
"processedAt": "2026-04-19T10:00:00Z"
}
""";
kafkaTemplate.send("payment-events", eventJson).get(5, SECONDS);
// Poll until the consumer processes it or we timeout
await()
.atMost(Duration.ofSeconds(10))
.untilAsserted(() -> {
var payment = paymentRepository.findById(42L);
assertThat(payment).isPresent();
assertThat(payment.get().getStatus()).isEqualTo(PaymentStatus.COMPLETED);
});
}
The await() call here is from Awaitility, which belongs in every project doing async integration tests. Spinning on a Thread.sleep is brittle; Awaitility polls with a configurable timeout and gives a clean assertion failure message when things don't converge.
Keeping the Suite Fast Enough
Reuse containers. Isolate test data with @Sql scripts or a DatabaseCleanupService that truncates tables before each test rather than relying on rollback (which doesn't work across the full HTTP stack where the transaction commits before your test assertion runs).
Separate integration tests into their own Gradle source set or Maven Surefire/Failsafe split so they run in a dedicated CI stage and don't block every local unit test run:
<!-- Maven Failsafe for integration tests -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<configuration>
<includes>
<include>**/*IntegrationTest.java</include>
<include>**/*IT.java</include>
</configuration>
</configuration>
</plugin>
With this in place, mvn test runs unit tests only. mvn verify runs both. Your pipeline can fail fast on unit tests and run integration tests in a parallel stage.
The Right Number of Integration Tests
Somewhere between five and fifty, depending on the application. Enough to cover every critical path and every non-obvious wiring decision. Not so many that you're duplicating unit test assertions through the full stack for every edge case.
The test that confirms your payment round-trip works is an integration test. The test that confirms the payment validator rejects negative amounts belongs in a unit test. Keep that separation explicit and your suite stays maintainable.
What you cannot afford is zero integration tests and a production environment that serves as the first real integration between your layers. That's not a testing strategy — it's a bet.