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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Why Denver Startups Are Turning to Async Remote Backend Contractors to Stay Cost-Competitive

Denver's backend hiring market has gotten expensive fast. The startups staying lean without slowing down have changed how they think about getting work done.

Read more

How to Decide What Skills Will Actually Get You More Work

Not every skill you learn brings more projects or higher pay. Here’s how to pick the ones that truly make you marketable.

Read more

Belgrade's Tech Scene Is Growing Fast — Its Senior Backend Talent Is Already Spoken For

Serbia's startup ecosystem has real momentum. The senior backend engineers it needs to keep growing are largely committed elsewhere.

Read more

Seattle Has Amazon and Microsoft. Everyone Else Competes for the Same Engineers — or Goes Remote

You found a backend engineer who loved your product, aced the technical screen, and seemed genuinely excited. Then Amazon matched with a $50K signing bonus.

Read more