You're Probably Overcomplicating Your Spring Boot Tests
by Eric Hanson, Backend Developer at Clean Systems Consulting
The Pain Is Self-Inflicted
You've seen the project. The test suite takes four minutes to run locally, fifteen in CI. Half the tests are annotated @SpringBootTest, which boots the entire application context — datasource, security config, Kafka listeners, scheduled tasks, the whole thing — just to verify that a service method maps a DTO correctly.
Nobody intended this. It happens incrementally. One developer adds @SpringBootTest because it's what they know. Others copy the pattern. Eventually you have 200 tests all doing context startup, and your CI pipeline becomes a liability.
The fix isn't "write better tests." It's knowing which test slice to use and when.
Three Layers, Three Tools
Spring Boot's testing support is actually well-designed once you stop defaulting to the nuclear option. The framework gives you targeted slices that load only the parts of the context a given layer needs. Most teams underuse them.
Unit Tests: Just Use JUnit + Mockito
If you're testing a class that has no Spring dependencies — no @Autowired, no @Value, no lifecycle hooks — don't load a Spring context at all. Use plain JUnit 5 and Mockito.
class PaymentValidatorTest {
private final FraudCheckClient fraudClient = mock(FraudCheckClient.class);
private final PaymentValidator validator = new PaymentValidator(fraudClient);
@Test
void rejectsTransactionAboveLimit() {
when(fraudClient.check(any())).thenReturn(FraudResult.CLEAR);
var result = validator.validate(new Payment(BigDecimal.valueOf(100_001)));
assertThat(result.isRejected()).isTrue();
assertThat(result.getReason()).isEqualTo(RejectionReason.AMOUNT_LIMIT_EXCEEDED);
}
}
No annotations. No context. Starts in milliseconds. This is the right tool for 60–70% of your business logic tests.
Service Layer: @ExtendWith(MockitoExtension.class)
When your service class uses constructor injection and you want Mockito to manage lifecycle, MockitoExtension is enough. It processes @Mock and @InjectMocks without starting Spring.
@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {
@Mock PaymentRepository repository;
@Mock NotificationService notificationService;
@InjectMocks PaymentService paymentService;
@Test
void persistsAndNotifiesOnSuccess() {
var payment = aValidPayment();
when(repository.save(payment)).thenReturn(payment.withId(42L));
paymentService.process(payment);
verify(notificationService).sendConfirmation(42L);
}
}
Still no Spring context. Still fast.
Web Layer: @WebMvcTest
When you need to test controller logic — request mapping, validation constraints, serialization, security rules — use @WebMvcTest. It loads only the MVC infrastructure: controllers, @ControllerAdvice, filters, and MockMvc. No datasource, no service beans (you mock those).
@WebMvcTest(PaymentController.class)
class PaymentControllerTest {
@Autowired MockMvc mvc;
@MockBean PaymentService paymentService;
@Test
void returns422ForInvalidAmount() throws Exception {
mvc.perform(post("/payments")
.contentType(APPLICATION_JSON)
.content("""
{ "amount": -50, "currency": "USD" }
"""))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.field").value("amount"));
}
}
This test runs in ~1–2 seconds on a mid-range laptop because it doesn't boot your Kafka consumer or connect to a database.
Persistence Layer: @DataJpaTest
For repository tests — custom JPQL queries, specifications, pagination — use @DataJpaTest. It configures an in-memory H2 instance (or Testcontainers if you configure it), loads only JPA infrastructure, and rolls back after each test.
@DataJpaTest
class PaymentRepositoryTest {
@Autowired PaymentRepository repository;
@Test
void findsPendingPaymentsOlderThanOneHour() {
repository.save(pendingPaymentCreatedAt(Instant.now().minus(2, HOURS)));
repository.save(pendingPaymentCreatedAt(Instant.now().minus(30, MINUTES)));
var stale = repository.findPendingOlderThan(Instant.now().minus(1, HOURS));
assertThat(stale).hasSize(1);
}
}
If your queries are complex enough that H2 dialect differences bite you — and they will, eventually — switch to @DataJpaTest with Testcontainers using the @Testcontainers + @Container approach with a real Postgres image. It's slower but eliminates the dialect mismatch class of failure.
Integration Tests: Earn Your @SpringBootTest
@SpringBootTest is not the enemy. It's the right tool when you're testing how the full stack behaves together: HTTP in, database out, external service called. The mistake is using it for everything.
When you do use it, be deliberate:
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Testcontainers
class PaymentFlowIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@DynamicPropertySource
static void configureDataSource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired TestRestTemplate restTemplate;
@Test
void completePaymentRoundTrip() {
var response = restTemplate.postForEntity("/payments", validPaymentRequest(), PaymentResponse.class);
assertThat(response.getStatusCode()).isEqualTo(CREATED);
assertThat(response.getBody().getStatus()).isEqualTo("PENDING");
}
}
Have maybe a dozen of these. Not two hundred.
The Context Caching Trap
One thing that catches people: Spring does cache the application context across tests in the same suite, so multiple @SpringBootTest tests don't each pay the full startup cost — as long as they use identical context configurations. The moment one test adds @MockBean, it creates a new context configuration, and Spring boots a fresh context for that group.
In a project with 50 @SpringBootTest tests where 30 of them @MockBean different things, you might be starting 30 distinct contexts per run. JetBrains did internal measurements showing context startup overhead dominates total test time well before suite size does, at around 2–5 seconds per unique context for a moderately complex application.
What to Do This Week
Audit your test suite. Find every @SpringBootTest that's actually just testing a service method in isolation or checking a controller's validation behavior. Replace them with the appropriate slice or a plain unit test. You don't need a big refactor — just stop the bleeding on new tests and chip away at the existing ones.
Your build will thank you. So will the developer who's waiting on CI before merging at 4pm on a Friday.