Testing Spring Boot Applications

Difficulty

Spring Boot's testing support offers a spectrum from "test everything, realistically" to "test just this one layer, fast":

@SpringBootTest boots the full ApplicationContext (or close to it) — every bean, every auto-configuration, exactly as the real application would start:

@SpringBootTest
class OrderServiceIntegrationTest {
    @Autowired OrderService orderService;
    @Autowired OrderRepository orderRepository;

    @Test
    void createsAndPersistsAnOrder() {
        Order order = orderService.createOrder(new CreateOrderRequest(...));
        assertThat(orderRepository.findById(order.getId())).isPresent();
    }
}

This gives the most realistic test — closest to how the application actually behaves in production — but comes at a real cost: booting the entire context is comparatively slow, and a test only checking, say, controller-to-service wiring doesn't need the entire persistence layer, security configuration, and every other bean loaded just to verify that.

"Slice" test annotations load only the auto-configuration relevant to one specific architectural layer, leaving everything else out:

  • @WebMvcTest(OrderController.class) — auto-configures Spring MVC infrastructure (MockMvc, message converters, validation) and scans just the specified controller(s), without starting a real embedded server or wiring up the persistence layer, security beans, etc.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
    @Autowired MockMvc mockMvc;
    @MockitoBean OrderService orderService; // service layer replaced with a mock — not a real bean

    @Test
    void returns404WhenOrderNotFound() throws Exception {
        when(orderService.getOrder(99L)).thenThrow(new OrderNotFoundException(99L));
        mockMvc.perform(get("/orders/99")).andExpect(status().isNotFound());
    }
}
  • @DataJpaTest — auto-configures an in-memory (or Testcontainers-backed) database, Spring Data repositories, and the EntityManager, without the web layer at all — great for testing repository query behavior in isolation.
  • @JsonTest — auto-configures just Jackson's ObjectMapper and related JSON serialization infrastructure, for testing custom serializers/deserializers without any web or persistence concerns.

Practical guidance: reach for the narrowest slice annotation that actually exercises what a given test cares about — it runs faster and fails with a clearer, more targeted signal when something's wrong — and reserve @SpringBootTest for genuinely full end-to-end integration tests where the interaction between multiple layers is specifically what's being verified.

Related Resources

MockMvc lets you test Spring MVC controllers by simulating the request-dispatch mechanism itself — routing, argument resolution, message conversion, exception handling — without starting a real embedded server or making an actual network call, which makes it both fast and focused purely on the web layer's behavior.

Typical setup with @WebMvcTest:

@WebMvcTest(OrderController.class)
class OrderControllerTest {
    @Autowired MockMvc mockMvc;
    @MockitoBean OrderService orderService; // real service replaced with a mock for this test

    @Test
    void createOrder_returnsCreatedOrderWithLocationHeader() throws Exception {
        Order created = new Order(1L, "PENDING");
        when(orderService.create(any())).thenReturn(created);

        mockMvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"customerId": "cust-1", "quantity": 2}
                    """))
            .andExpect(status().isCreated())
            .andExpect(header().string("Location", "/orders/1"))
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.status").value("PENDING"));
    }

    @Test
    void getOrder_returns404WhenMissing() throws Exception {
        when(orderService.getOrder(99L)).thenThrow(new OrderNotFoundException(99L));

        mockMvc.perform(get("/orders/99"))
            .andExpect(status().isNotFound());
    }
}

Key building blocks:

  • perform(...) builds and dispatches a simulated request (get, post, put, delete static helpers from MockMvcRequestBuilders), with fluent methods for headers, body content, and request parameters.
  • andExpect(...) chains assertions on the result — HTTP status (status().isOk()), headers (header().string(...)), and response body content, including jsonPath(...) for asserting on specific fields within a JSON response without deserializing it into a full object first.
  • @MockitoBean (the modern replacement for the deprecated @MockBean) swaps out a real collaborator bean (here, OrderService) for a Mockito mock within the test's slice of the ApplicationContext, letting the test isolate the controller's own behavior (routing, validation, serialization, error handling) from the actual business logic underneath.

Why this is preferable to spinning up a real server and making real HTTP calls for most controller tests: it's dramatically faster (no real server startup, no real sockets/ports), and it still exercises the genuine Spring MVC dispatch pipeline (validation, exception handling, JSON serialization) — closer to a true integration test of the web layer than calling the controller method directly in plain Java would be, while remaining much cheaper than a full @SpringBootTest with a real running server.

Both ultimately create a Mockito mock object, but they differ in whether that mock is registered inside a Spring ApplicationContext:

Plain Mockito.mock(SomeType.class) creates a mock with no Spring involvement whatsoever — appropriate for a pure, Spring-free unit test where you construct the class under test directly:

@Test
void placesOrderSuccessfully() {
    PaymentGateway mockGateway = mock(PaymentGateway.class); // plain Mockito, no Spring context at all
    when(mockGateway.charge(any(), any())).thenReturn(true);

    OrderService service = new OrderService(mockGateway); // constructed directly, not via Spring
    assertTrue(service.placeOrder(new Order(...)));
}

This is the fastest possible test — no ApplicationContext loads at all — and is exactly the kind of test constructor injection is specifically designed to make easy (see the constructor-injection question).

@MockitoBean (the current annotation; @MockBean served the same purpose in older Spring Boot versions and is now deprecated in favor of it) is used inside a Spring test context — it creates a Mockito mock and registers it as a bean in the test's ApplicationContext, replacing whatever real bean of that type would otherwise have been created/wired there:

@WebMvcTest(OrderController.class)
class OrderControllerTest {
    @Autowired MockMvc mockMvc;

    @MockitoBean
    OrderService orderService; // Spring wires this mock into OrderController instead of a real OrderService

    @Test
    void ...() throws Exception {
        when(orderService.getOrder(1L)).thenReturn(new Order(1L, "PENDING"));
        mockMvc.perform(get("/orders/1")).andExpect(status().isOk());
    }
}

When you need @MockitoBean instead of plain mock(): whenever the class under test is itself resolved and injected by Spring rather than constructed directly by your test code — e.g., testing a controller via MockMvc inside a @WebMvcTest, where OrderController is instantiated by the Spring test context and needs its OrderService dependency supplied through that same context, not through a constructor call your test code controls directly.

Rule of thumb: default to plain Mockito.mock() plus direct object construction wherever possible (it's simpler and faster); reach for @MockitoBean specifically when the object under test is managed by a Spring test context and you need to replace one of its dependencies within that context.

A long-standing tension in integration testing: testing against a real database/message broker gives high confidence, but historically meant either maintaining a shared test environment (fragile, hard to keep clean between test runs, a source of flaky tests from shared state) or substituting a lighter-weight in-memory stand-in (e.g., H2 configured to mimic PostgreSQL) — which inevitably behaves subtly differently from the real production database in edge cases (specific SQL dialect features, constraint enforcement details, data type quirks).

Testcontainers solves this by spinning up real, disposable Docker containers of the actual external dependency, scoped to the test run itself — a genuine PostgreSQL, Kafka, Redis, or Elasticsearch instance, started fresh before the tests run and torn down afterward, with no persistent shared infrastructure to maintain:

@SpringBootTest
@Testcontainers
class OrderRepositoryIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");

    @DynamicPropertySource
    static void configureProperties(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 orderRepository;

    @Test
    void savesAndRetrievesAnOrder() {
        Order saved = orderRepository.save(new Order(...));
        assertThat(orderRepository.findById(saved.getId())).isPresent();
    }
}

Why this is a genuine improvement over an in-memory substitute:

  • Real behavioral fidelity — the test runs against the actual database engine (same SQL dialect, same constraint behavior, same JSON/array column support if used) that production uses, catching bugs an H2-based test would miss entirely.
  • Works for more than just databases — Testcontainers modules exist for Kafka, RabbitMQ, Redis, Elasticsearch, and many other common infrastructure dependencies, letting integration tests exercise messaging or caching behavior against the real thing too.
  • Fully automated and reproducible — each test run gets a clean, freshly-started container; there's no shared state to accidentally leak between test runs or between developers' machines, and no manually-provisioned test environment to keep in sync with production versions.

Practical trade-off: Testcontainers tests are slower than pure in-memory or mocked tests (starting a real container takes real time), so they're best reserved for a focused set of genuine integration tests — verifying repository queries, migration scripts, or messaging integration against the real technology — rather than being used for every single test in the suite; unit tests with plain mocks remain the right tool for testing business logic in isolation.

Related Resources

Database-backed integration tests face a classic isolation problem: if Test A inserts a row and it stays committed, Test B (or a later run of Test A itself) might see stale or conflicting data, unless every test carefully cleans up after itself.

Spring's test framework addresses this directly: annotating a test method or class with @Transactional wraps that test in a real database transaction that is, by default, automatically rolled back once the test completes — regardless of whether the test's own code ever explicitly commits, and regardless of whether the test passed or failed:

@SpringBootTest
@Transactional // every test method below runs in its own transaction, rolled back afterward
class OrderRepositoryTest {

    @Autowired OrderRepository orderRepository;

    @Test
    void savingAnOrderMakesItFindable() {
        Order saved = orderRepository.save(new Order(...));
        assertThat(orderRepository.findById(saved.getId())).isPresent();
        // no cleanup code needed — this insert is rolled back automatically after the test method returns
    }

    @Test
    void countIsUnaffectedByPreviousTest() {
        // starts from a clean slate — the previous test's insert never actually persisted
        assertThat(orderRepository.count()).isEqualTo(0);
    }
}

Why this matters for isolation: each @Test method gets its own transaction (started before the test, rolled back after), so tests never leak state into one another through the database — no manual DELETE FROM orders cleanup code, no ordering dependency between tests, and no risk of one test's leftover data silently breaking an unrelated test run later.

Important limitation to be aware of: this specifically tests behavior as observed from within a single transaction — it doesn't verify what actually happens once a transaction genuinely commits (e.g., whether a database trigger, a separate connection's read, or an actual cross-transaction concurrency scenario behaves correctly), since the rollback means nothing is ever really committed at all. For a test that specifically needs to verify true commit/cross-transaction behavior, you'd need to explicitly avoid the auto-rollback (e.g., via @Commit, used sparingly) or structure the test to genuinely span separate transactions.

Also worth noting: this rollback-based isolation happens at the database transaction level — it has no bearing on isolating things like static/shared in-memory state, external HTTP calls to third-party services, or message broker interactions, which need their own isolation strategy (mocking, Testcontainers with cleanup, etc.) regardless of @Transactional on the test.