Skip to content

Spring Boot · Testing

Spring Boot Integration Testing with @SpringBootTest and Testcontainers

6 min read Updated 2026-06-26 Share:

Practice Integration Testing interview questions

When you actually need the whole thing

Unit tests prove a class works in isolation. Slices prove one layer works. But sometimes the risk is in the seams — does a POST /orders really flow through the controller, into the service, down to the repository, and persist a row? That's an integration test, and it's what @SpringBootTest is for:

@SpringBootTest
class OrderFlowIT {
    @Autowired OrderService service;   // the real service
    @Autowired OrderRepository repo;   // the real repository + DB
}

It loads the full context with real beans, often against a real database. That makes it the slowest and most realistic tier — the top of the test pyramid. Keep these few and high-value; lean on faster unit/slice tests for breadth.

Choosing a web environment

@SpringBootTest's webEnvironment decides how the web layer runs:

@SpringBootTest(webEnvironment = WebEnvironment.MOCK)        // default, no real port
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) // real server, random port
@SpringBootTest(webEnvironment = WebEnvironment.NONE)        // no web env

MOCK pairs with @AutoConfigureMockMvc to reuse MockMvc. RANDOM_PORT starts a real embedded server on a free port — which is what you want for true HTTP tests. The random port avoids "address already in use" clashes on CI and under parallel execution.

Real HTTP with TestRestTemplate

With RANDOM_PORT, Spring auto-configures a TestRestTemplate (or WebTestClient) pointed at the running server, so you make actual requests over a socket:

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class OrderApiIT {
    @Autowired TestRestTemplate rest;

    @Test void createsOrder() {
        var resp = rest.postForEntity("/orders", new OrderRequest("widget"), Order.class);
        assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(resp.getBody().getStatus()).isEqualTo("NEW");
    }
}

Unlike MockMvc, this goes through the real servlet container — filters, connection handling, serialization, the lot. TestRestTemplate is fault-tolerant: it won't throw on 4xx/5xx, so you can assert on error responses too. Need the resolved port elsewhere? Inject it with @LocalServerPort — never hard-code it.

Test on the real database with Testcontainers

The biggest fidelity win in integration testing is running against the same database you use in production. Testcontainers spins up Postgres (or Redis, Kafka, RabbitMQ) in a Docker container for the test and tears it down afterward:

@Testcontainers
@SpringBootTest
class OrderIT {
    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:16");
}

The @ServiceConnection annotation (Boot 3.1+) is the modern magic: it auto-configures Spring's datasource from the container, so you don't hand-wire the JDBC URL, username, and password. Before 3.1 you needed the verbose @DynamicPropertySource:

@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
    r.add("spring.datasource.url", db::getJdbcUrl);
    r.add("spring.datasource.username", db::getUsername);
    r.add("spring.datasource.password", db::getPassword);
}

A static container is shared across the test class, keeping startup cost manageable. The payoff: no more "passed on H2, failed on Postgres" surprises with JSON columns, sequences, or native SQL.

Context caching is your performance budget

Booting the context is expensive, so the Spring TestContext framework caches and reuses it across test classes that request the same configuration. The cache key is the whole config: classes, active profiles, properties, @MockBean sets. Change any of those and you get a brand-new context — another full, slow boot:

// Share one cached context — fast:
@SpringBootTest class ATest { }
@SpringBootTest class BTest { }

// A different profile = a separate context = another boot:
@SpringBootTest @ActiveProfiles("special") class CTest { }

A suite that accidentally creates dozens of distinct contexts can take minutes longer than necessary. Keep test configuration uniform. And treat @DirtiesContext with suspicion — it closes and rebuilds the context, paying the full startup cost again:

@Test
@DirtiesContext   // rebuild after this test — expensive
void mutatesSingletonState() { /* ... */ }

Usually the better fix is to reset the mutated state in an @AfterEach rather than nuking the context.

The @Transactional rollback caveat

A @Transactional test wraps each method in a transaction and rolls back at the end, so the database stays clean with no manual cleanup:

@SpringBootTest
@Transactional
class OrderServiceIT {
    @Autowired OrderService service;
    @Test void createsOrder() { service.place("widget"); /* rolled back */ }
}

Convenient, but it has teeth. The open session can let lazy associations resolve that would throw LazyInitializationException in production — the test passes, prod doesn't. And a RANDOM_PORT HTTP test runs the server on a different thread and transaction, so the test's transaction doesn't wrap it and rollback won't apply. For those, clean up explicitly (repo.deleteAll() in @AfterEach, or a fresh Testcontainers database).

Test profiles and data setup

Give tests their own configuration with a profile:

@SpringBootTest
@ActiveProfiles("test")
class OrderIT { /* uses application-test.yml */ }
# src/test/resources/application-test.yml
spring:
  jpa:
    hibernate:
      ddl-auto: create-drop

For one-off overrides use @TestPropertySource or @Sql scripts to seed and clean data:

@Sql("/seed-orders.sql")
@Sql(scripts = "/clean.sql", executionPhase = AFTER_TEST_METHOD)
class OrderIT { /* ... */ }

The cardinal rule is isolation: each test sets up its own data and cleans up after itself. Tests that share mutable state fail mysteriously when reordered or parallelized.

Testing async behavior without sleeping

Never Thread.sleep to wait for async work — too short is flaky, too long is slow. Poll with Awaitility, which retries an assertion until it passes or times out:

service.processAsync(orderId);

await().atMost(Duration.ofSeconds(5))
       .untilAsserted(() ->
           assertThat(repo.findById(orderId).get().getStatus()).isEqualTo("DONE"));

It checks frequently and stops the instant the condition holds — robust against timing variance.

Name and run them separately

Fast and slow tests want different cadences. The common convention names unit tests *Test and integration tests *IT, then wires the build to run them in different phases:

<!-- Maven: Surefire runs *Test (unit), Failsafe runs *IT (integration) -->
<plugin><artifactId>maven-surefire-plugin</artifactId></plugin>   <!-- mvn test -->
<plugin><artifactId>maven-failsafe-plugin</artifactId></plugin>   <!-- mvn verify -->

Developers run mvn test constantly for sub-second feedback; the heavier *IT suite (Testcontainers, full context) runs on verify/CI.

Recap

Integration tests prove the layers work together. Use @SpringBootTest with RANDOM_PORT + TestRestTemplate for real HTTP, Testcontainers + @ServiceConnection to test on the real database, and keep configuration uniform so context caching does its job. Watch the @Transactional rollback caveats with HTTP tests and lazy loading, isolate every test's data, poll async outcomes with Awaitility instead of sleeping, and separate *IT from *Test so the everyday loop stays fast. Keep these tests few, focused, and faithful to production.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel