Unit tests check logic in isolation, but they don't tell you whether the application works correctly with a database, a cache, or external services. That's what integration tests are for — tests that run against real dependencies.
The problem with H2 and mocks
The simplest way to test a repository is to plug in the embedded H2 database. It's fast, it doesn't require Docker, and the tests run everywhere. But this approach has a serious flaw: H2 is not PostgreSQL.
The differences pile up unnoticed: the syntax of SQL functions, behavior on unique-constraint conflicts, how JSON types work, window functions. A test passes on H2 but fails in production — because the dialects differ.
The same goes for database mocks: a mock verifies that a method was called with the right arguments, but it doesn't verify the SQL query itself, the result mapping, or the transaction behavior. An integration test spins up the same PostgreSQL that runs in production and eliminates an entire class of bugs.
@SpringBootTest: the full context
@SpringBootTest boots the entire application context — exactly as if you were starting the app. This is the heaviest kind of test, but it's the closest to real behavior.
@SpringBootTest
@AutoConfigureMockMvc
class OrderApiTest {
@Autowired
MockMvc mockMvc;
@Test
void createsOrder() throws Exception {
mockMvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"productId": "abc", "quantity": 2}
"""))
.andExpect(status().isCreated());
}
}
By default the server doesn't start — MockMvc is used instead. If you need a real HTTP port, add webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT.
The full context is justified when you need to verify the end-to-end path: HTTP → service → database → response. For an isolated check of a single layer, there are slices.
Slices: @DataJpaTest and @WebMvcTest
A slice is a trimmed-down Spring context that boots only the layer you need. The rest of the beans aren't loaded, so a slice starts significantly faster than the full context.
@DataJpaTest boots only the data-access layer: repositories, the EntityManager, transactions. By default it connects an embedded database — but instead of H2 you can plug in Testcontainers (more on that below).
@DataJpaTest
class ProductRepositoryTest {
@Autowired
ProductRepository repository;
@Test
void findsActiveProducts() {
var saved = repository.save(new Product("Widget", true));
var found = repository.findAllActive();
assertThat(found).contains(saved);
}
}
@WebMvcTest boots only the controller layer: @Controller, @ControllerAdvice, filters, MockMvc. Services and repositories are not part of this context — you have to mock them with @MockitoBean.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
MockMvc mockMvc;
@MockitoBean
OrderService orderService;
@Test
void returnsBadRequestOnMissingBody() throws Exception {
mockMvc.perform(post("/orders"))
.andExpect(status().isBadRequest());
}
}
Testcontainers: a real PostgreSQL in Docker
Testcontainers is a Java library that launches Docker containers straight from your test code. The container starts before the test and stops after it. No manual environment setup — an installed Docker is all you need.
<!-- build.gradle or pom.xml -->
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:postgresql'
@SpringBootTest
@Testcontainers
class OrderRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
OrderRepository repository;
@Test
void savesAndReadsOrder() {
var order = repository.save(new Order(UUID.randomUUID(), "PENDING"));
assertThat(repository.findById(order.id())).isPresent();
}
}
@Container + static means the container is created once per class. @DynamicPropertySource passes the container's URL, username, and password into the Spring context before it starts.
@ServiceConnection: no manual URL setup
Spring Boot 3.1 introduced @ServiceConnection — an annotation that removes the boilerplate @DynamicPropertySource. Spring recognizes the container type itself and configures the right properties.
@SpringBootTest
@Testcontainers
class OrderRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
// @DynamicPropertySource is no longer needed
@Autowired
OrderRepository repository;
}
The short formula: @ServiceConnection = automatic configuration of the datasource, Redis, RabbitMQ, and other supported container types.
For Redis it works exactly the same way:
@Container
@ServiceConnection
static GenericContainer<?> redis =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
When to use the full context and when to use a slice
| Situation | What to use |
|---|---|
| End-to-end test HTTP → database → response | @SpringBootTest + Testcontainers |
| Check a repository's SQL queries | @DataJpaTest + @ServiceConnection |
| Check controller validation and responses | @WebMvcTest + @MockitoBean |
| Check a service's business logic | Unit test, without the Spring context |
The rule: boot exactly as much context as you need. Extra beans slow down startup and can introduce unexpected dependencies. A full @SpringBootTest is justified only for end-to-end scenarios.
If you have many Testcontainers tests, it's worth moving the container into a shared base class with @Container static — then the Docker image starts up once for the entire test suite instead of separately for each class.
In short
- H2 and database mocks hide bugs that only surface against a real PostgreSQL.
@SpringBootTestboots the entire context; the@DataJpaTestand@WebMvcTestslices boot only the layer you need.- Testcontainers launches a Docker container straight from the test — the same image as in production.
@ServiceConnection(Spring Boot 3.1+) removes@DynamicPropertySourceand configures the datasource itself.- Pick the minimal context: a slice is faster than a full
@SpringBootTest. - A shared base class with a
staticcontainer cuts build time when you have many tests.
Further reading
- The testing pyramid — how unit, integration, and end-to-end tests relate to each other.
- Mocks and external dependencies — when a mock is appropriate and when a real container is better.
- Testing in Spring —
@SpringBootTestand slices in more detail within the Spring ecosystem. - Testing standards — a style guide for test structure and naming.