← Back to the section

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

SituationWhat 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 logicUnit 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.
  • @SpringBootTest boots the entire context; the @DataJpaTest and @WebMvcTest slices 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 @DynamicPropertySource and configures the datasource itself.
  • Pick the minimal context: a slice is faster than a full @SpringBootTest.
  • A shared base class with a static container 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 — @SpringBootTest and slices in more detail within the Spring ecosystem.
  • Testing standards — a style guide for test structure and naming.