← Back to the section

Tests in a Spring project usually live on three levels, and it is important not to confuse their purpose:

  • Unit tests — no Spring context at all. Just new MyService(mock(...)). The fastest, isolated.
  • Slice tests — a partial context for a specific layer: only MVC, only JPA, only JSON serialization.
  • Integration tests — the full context via @SpringBootTest + TestContainers.

Most test problems come from trying to do with one type what is better done with another: writing everything through @SpringBootTest (slow, flaky), or not using slices at all.

Unit tests — no Spring

The fastest level. Spring is not needed if the class is a plain POJO or a Spring @Service whose mocks can be passed into the constructor:

class CreateOrderUseCaseHandlerTest {

    private final OrderRepository orderRepo = mock(OrderRepository.class);
    private final EventPublisher events = mock(EventPublisher.class);
    private final CreateOrderHandler handler = new CreateOrderHandler(orderRepo, events);

    @Test
    void creates_order_and_publishes_event() {
        var cmd = new CreateOrderCommand(UUID.randomUUID(), List.of(...));

        handler.handle(cmd);

        verify(orderRepo).save(any(Order.class));
        verify(events).publish(any(OrderCreatedEvent.class));
    }
}

The test runs in milliseconds. Any class that does not depend on infrastructure is tested this way.

Slice tests

When you need a partial context — for example, to test a controller's serialization but without a database.

@WebMvcTest — controllers and the MVC layer

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired private MockMvc mvc;
    @MockitoBean private CreateOrderUseCase createOrder;

    @Test
    void create_order_returns_201() throws Exception {
        when(createOrder.handle(any())).thenReturn(new OrderId(UUID.fromString("...")));

        mvc.perform(post("/api/v1/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    { "customerName": "Ivan", "lines": [...] }
                """))
            .andExpect(status().isCreated())
            .andExpect(header().exists("Location"));
    }
}

@WebMvcTest brings up only the MVC layer (controllers, validators, exception handlers, Jackson). JPA, security, and custom beans are not loaded. Fast.

@DataJpaTest — the repository layer

@DataJpaTest
class OrderRepositoryTest {

    @Autowired private OrderRepository repo;
    @Autowired private TestEntityManager em;

    @Test
    void finds_by_customer_id() {
        var customer = em.persist(new Customer("Ivan"));
        em.persist(new Order(customer, BigDecimal.valueOf(100)));
        em.flush();

        var found = repo.findByCustomerId(customer.getId());

        assertThat(found).hasSize(1);
    }
}

@DataJpaTest brings up only JPA: EntityManager, repositories, the datasource (by default an embedded H2). Each test runs in a transaction that is rolled back at the end — isolation for free.

Swapping H2 for a real PostgreSQL via TestContainers — see below.

Other slices

  • @JsonTest — Jackson serialization.
  • @RestClientTestRestClient / WebClient + MockRestServiceServer.
  • @DataMongoTest — Spring Data Mongo.
  • @JdbcTest — bare JDBC + datasource.

The full list is in spring-boot-test-autoconfigure.

@SpringBootTest — the full context

When you need the entire application context — security, custom beans, application events, the real wiring of all layers together:

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class OrderE2ETest {

    @Autowired private MockMvc mvc;
    @Autowired private OrderRepository repo;

    @Test
    void create_then_fetch() throws Exception {
        var response = mvc.perform(post("/api/v1/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(...))
            .andReturn().getResponse();

        var location = response.getHeader("Location");
        mvc.perform(get(location))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.status").value("DRAFT"));
    }
}

webEnvironment:

  • MOCK (default)MockMvc, without starting Tomcat.
  • RANDOM_PORT — starts Tomcat on a random port. Used with TestRestTemplate or WebTestClient.
  • DEFINED_PORT — on a specific port (for e2e with an external client).

MockMvc vs WebTestClient

Both are for testing the HTTP layer. The differences:

MockMvcWebTestClient
StackMVC (Servlet)MVC + WebFlux
Under the hoodDispatcherServlet through mock objectsReal Tomcat (with RANDOM_PORT) or WebTestClient.bindToController
SyntaxJava DSL with a lot of andExpectFluent, with better readability
SpeedSlightly fasterSlightly slower, but not dramatically

In MVC projects you take MockMvc, in WebFlux — WebTestClient. If you have both — WebTestClient covers both.

TestContainers — real infrastructure

H2 in @DataJpaTest saves time, but its behavior differs from a real PostgreSQL: different functions, different type semantics, different behavior of concurrent transactions. When it comes to an integration test, the bugs will surface on staging or in production.

The solution is TestContainers: a real PG / Kafka / RabbitMQ / Mongo in a Docker container for the duration of the test.

@SpringBootTest
@Testcontainers
class OrderIntegrationTest {

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

    @Container
    @ServiceConnection
    static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.5.0"));

    @Test
    void writes_to_postgres_and_publishes_to_kafka() { ... }
}

@ServiceConnection (Spring Boot 3.1+) automatically sets spring.datasource.url, spring.kafka.bootstrap-servers and other connection properties from the container. Without it you need @DynamicPropertySource.

Reusing containers

Containers start slowly. To reuse a single container across tests:

# ~/.testcontainers.properties
testcontainers.reuse.enable=true
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
    .withReuse(true);

The container is not stopped after the test — the next run connects to the same one. It saves minutes during the development cycle.

Testing transactions

By default @SpringBootTest does not wrap the test in a transaction (it used to, now it does not). @Transactional on the test adds that:

@SpringBootTest
@Transactional
class OrderServiceTest {
    @Autowired private OrderService service;

    @Test
    void creates_order() {
        service.create(...);
        // at the end of the test — rollback, the test DB is clean
    }
}

With @DataJpaTest the transaction wraps by default.

The downside: if your Handler does REQUIRES_NEW or publishes events via @TransactionalEventListener(AFTER_COMMIT), tests under @Transactional will not let you verify it, because the main transaction is never committed. For such scenarios, disable @Transactional on the test and clean the DB by hand (or use TestContainers reuse + truncate).

Testing @Async

@Async creates a different thread. In a test this is rarely convenient. An alternative:

@TestConfiguration
class AsyncTestConfig {
    @Bean
    @Primary
    public Executor taskExecutor() {
        return Runnable::run;  // execute synchronously
    }
}

@SpringBootTest
@Import(AsyncTestConfig.class)
class MyAsyncTest { ... }

All @Async methods run right in the caller's thread — the test sees the result immediately.

Running tests in parallel

JUnit 5 supports parallel execution. With TestContainers:

# junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent

The pitfall: different test classes may start different contexts. The Spring Test Context Cache keeps contexts in memory — if you have 5 different @SpringBootTest classes with different @MockitoBeans, there will be 5 contexts in memory at once. Parallel execution speeds things up but requires memory.

  • @Transactional in depth — the specifics of testing with different propagation modes.
  • Spring MVC — what exactly to test in controllers.
  • Spring Data JPA — what @DataJpaTest covers.
  • TestContainers docs — the official documentation.