You're Probably Overcomplicating Your Spring Boot Tests

by Arif Ikhsanudin, Backend Developer

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 Miami Startups Cannot Rely on Local Hiring Alone for Backend Engineering

Miami has built a real startup scene. It hasn't yet built the backend engineering depth to staff it locally.

Read more

APIs Are Not Just CRUD: Why Complex Systems Need Domain-Driven Architecture

APIs are often treated as simple CRUD endpoints, but real-world systems are more tangled. Domain-driven architecture (DDA) helps keep complexity under control.

Read more

How to Handle Contract Termination Professionally

Hearing “we need to end the contract” can feel like a punch in the gut. It’s awkward, stressful, and sometimes confusing.

Read more

When Coffee is Your Only Coworker

Working solo has perks, but when your main companion is a steaming cup of coffee, you start to notice the little things. Here’s how to survive and thrive in a one-coffee office.

Read more