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 @Transactional actually 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 KafkaTemplate calls
  • 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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Java Code Quality in Practice — The Rules That Help and the Ones That Don't

Most Java code quality guidance is either too abstract to apply or applied too rigidly to improve real codebases. Here is a honest assessment of the rules that consistently improve maintainability and the ones that create friction without payoff.

Read more

Raleigh Has Great Backend Engineers — Apple, Google and Amazon Get to Them First

The Research Triangle produces serious engineering talent. The biggest companies in the world have known that longer than most Raleigh startups have existed.

Read more

Hong Kong's Backend Developer Market Is Contracting — Here Is How Smart Startups Are Responding

Hong Kong's tech talent pool has been shrinking for reasons that have nothing to do with the startup scene's ambitions.

Read more

Spring Boot Configuration Management — Profiles, @ConfigurationProperties, and Secrets

Spring Boot's externalized configuration is powerful and easy to misuse. Getting the property precedence wrong means production uses development values. Embedding secrets in properties files is a security incident waiting to happen. Here is the complete model and the configuration structure that holds up in production.

Read more