Spring Boot’s testing support is extensive and well-designed. It’s also easy to misuse in ways that produce slow, fragile test suites with poor diagnostic value.
The core question is not “how do I test with Spring Boot?” but “what needs to be tested, and at what level?”
The Testing Pyramid Applied to Spring Boot
The testing pyramid principle: favor many fast, isolated unit tests over fewer slow integration tests. Each level of the pyramid is slower and more expensive to run but tests more of the system.
In a Spring Boot application:
Unit tests (base): test a single class in isolation, no Spring context. Run in milliseconds. Cover business logic, domain models, utility classes.
Slice tests (middle): test a specific layer using a partial Spring context. @WebMvcTest for controllers, @DataJpaTest for repositories. Faster than full integration tests, test more than unit tests.
Integration tests (top): full Spring context, real or containerized infrastructure. Test the entire application or significant slices of it. Slow, but catch integration problems.
The mistake: writing @SpringBootTest tests for everything, including logic that could be tested with a plain unit test. This makes the suite slow and the slow tests provide no additional value over the fast ones.
Unit Tests: Test Domain Logic Without Spring
Business logic should be testable without Spring. If your domain objects and services require a Spring context to run, your architecture is telling you something.
// Domain logic — no Spring annotations, no Spring context needed
public class Order {
private final List<OrderLine> lines;
private OrderStatus status;
public void cancel(CancellationReason reason) {
if (status == OrderStatus.DELIVERED) {
throw new OrderCannotBeCancelledException("Cannot cancel delivered order");
}
this.status = OrderStatus.CANCELLED;
}
}
// Plain unit test — no Spring, runs in < 1ms
class OrderTest {
@Test
void cancellingDeliveredOrder_throwsException() {
Order order = OrderBuilder.aDeliveredOrder().build();
assertThatThrownBy(() -> order.cancel(CancellationReason.CUSTOMER_REQUEST))
.isInstanceOf(OrderCannotBeCancelledException.class)
.hasMessage("Cannot cancel delivered order");
}
}
This test is fast, reliable, and precisely targets the business rule. It doesn’t need a database, a message broker, or a Spring context.
Slice Tests: Test Layers in Isolation
@WebMvcTest starts only the web layer — controllers, filters, exception handlers. No service beans, no repositories. Use @MockBean to mock the service layer.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
MockMvc mockMvc;
@MockBean
OrderService orderService;
@Test
void getOrder_returnsOrder_whenFound() throws Exception {
var orderId = OrderId.of("ORD-001");
when(orderService.findOrder(orderId))
.thenReturn(Optional.of(new OrderSummary(orderId, Money.of(100), ACTIVE)));
mockMvc.perform(get("/orders/{id}", "ORD-001")
.accept(APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value("ORD-001"))
.andExpect(jsonPath("$.status").value("ACTIVE"));
}
@Test
void getOrder_returns404_whenNotFound() throws Exception {
when(orderService.findOrder(any())).thenReturn(Optional.empty());
mockMvc.perform(get("/orders/{id}", "NONEXISTENT"))
.andExpect(status().isNotFound());
}
}
This tests: request mapping, input deserialization, response serialization, HTTP status codes, error handling — without touching the service layer.
@DataJpaTest loads only JPA-related components. Uses an in-memory database (H2) by default.
@DataJpaTest
class OrderRepositoryTest {
@Autowired
OrderJpaRepository repository;
@Autowired
TestEntityManager entityManager;
@Test
void findByCustomer_returnsOrdersForCustomer() {
var customerId = "CUST-001";
entityManager.persist(OrderEntity.builder()
.customerId(customerId)
.status(ACTIVE)
.build());
entityManager.flush();
List<OrderEntity> orders = repository.findByCustomerId(customerId);
assertThat(orders).hasSize(1);
assertThat(orders.get(0).customerId()).isEqualTo(customerId);
}
}
Caution with H2: H2 is not your production database. Some PostgreSQL-specific behavior (JSON operators, window functions, specific index types) doesn’t work with H2. For those, use Testcontainers.
Integration Tests With Testcontainers
Testcontainers starts real Docker containers for your dependencies — PostgreSQL, Kafka, Redis, whatever your application uses.
@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@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
OrderService orderService;
@Test
@Transactional
void placingOrder_persists_andPublishesEvent() {
var command = new PlaceOrderCommand(
CustomerId.of("CUST-1"),
List.of(new OrderLine(ProductId.of("PROD-1"), 2))
);
OrderId orderId = orderService.placeOrder(command);
assertThat(orderId).isNotNull();
Optional<Order> found = orderService.findOrder(orderId);
assertThat(found).isPresent();
assertThat(found.get().status()).isEqualTo(PENDING);
}
}
Use @Testcontainers + @Container pattern with static containers to reuse the container across all tests in the class — avoiding the overhead of starting a new container per test.
Spring Boot 3.1+ has built-in Testcontainers support with @ServiceConnection:
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
// @ServiceConnection automatically configures the datasource — no @DynamicPropertySource needed
@SpringBootTest: Use Sparingly
@SpringBootTest starts the full application context. It’s the most realistic test type and the most expensive.
Use it for:
- Testing the full startup (does the application boot without errors?)
- Testing complete request-response cycles that span multiple layers
- Testing configuration and wiring
Don’t use it for:
- Testing individual domain logic
- Testing controller behavior (use
@WebMvcTest) - Testing repository queries (use
@DataJpaTestor Testcontainers)
A test suite where every test is @SpringBootTest is slow, brittle, and provides poor diagnostic value — when something breaks, you have to dig through the entire application to find what failed.
What Not to Test
Framework code: don’t test that Spring’s dependency injection works. Don’t test that JPA generates the SQL you expect (unless you’re verifying a specific query optimization or behavior).
Generated code: if you’re using Lombok, MapStruct, or another code generator, don’t write tests that essentially test the generator’s output.
Simple getters and setters: if a method has no logic, it has no test case.
Positive tests only: test the error paths. What happens when the database is unavailable? When the input is invalid? When a required external service returns an error? These paths are often where the real bugs are.
Contract Testing
When services call each other, the contract between them — the request and response format — is an implicit dependency. When one service changes its API, dependent services break.
Spring Cloud Contract generates both producer-side tests and consumer-side stubs from contract definitions. The producer verifies it fulfills the contract. The consumer tests against stubs that match the contract. Both can be done without the other service running.
// Contract definition
Contract.make {
request {
method 'GET'
url '/orders/ORD-001'
}
response {
status 200
headers {
contentType applicationJson()
}
body([
id: 'ORD-001',
status: 'ACTIVE'
])
}
}
Contract testing is most valuable in microservices architectures where many teams are independently deploying services.
Test Execution Speed
A test suite that takes 20 minutes to run is one where engineers stop running tests locally. Fix the root causes:
- Replace
@SpringBootTesttests with slice tests where possible - Share the Spring context across tests in the same test class (Spring caches contexts by default)
- Use
@Testcontainerswith static containers (one container per JVM, not per test) - Identify the 10 slowest tests and understand why they’re slow
Most slow test suites are slow because they start too many Spring contexts. Profile which context configurations are created and how many tests share each.
The Bottom Line
The right test strategy for Spring Boot: lots of fast unit tests for domain logic, targeted slice tests for web and persistence layers, selective integration tests for cross-layer behavior, and full integration tests for smoke testing the complete application.
The signal that your strategy is wrong: tests that take minutes and fail for reasons unrelated to the code being tested.