Spring Study Guide – Testing

August 18, 20267 min readUpdated 8/18/2026

The first question is always some version of "do you need Spring for this test?" — and the right answer is usually no. Knowing when it is yes, and what it costs, is the rest of the topic.

When you need Spring, and when you do not

Do you use Spring in a unit test?

No. A unit test exercises one class with its collaborators replaced by mocks. If the class takes its dependencies through a constructor, new is all you need — and the test runs in milliseconds.

That is one of the concrete payoffs of constructor injection: field injection makes this impossible without reflection, which is how tests end up loading a context they did not need.

What kind of test does use Spring?

An integration test — one that exercises how components fit together: wiring, transactions, proxies, security filters, the web layer, real SQL. Anything where the framework's behaviour is part of what you are checking.

Unit testIntegration test
ContextnoneSpring
Collaboratorsmocksreal
Speedmillisecondsseconds
Catcheslogic errorswiring, SQL, transaction and security errors

Write many of the first and enough of the second.

Mocking

How are frameworks like Mockito used?

To stand in for a collaborator so you can control what it returns and assert how it was called. spring-boot-starter-test brings Mockito, JUnit 5, AssertJ and Hamcrest.

@ExtendWith(MockitoExtension.class)   // no Spring context at all
class PricingServiceUnitTest {

    @Mock private ProductDAO productDAO;
    @InjectMocks private PricingService pricingService;

    @Test
    void rejectsAnUnknownProduct() {
        // Stub the method the service ACTUALLY calls. Stubbing findByPublicId here
        // instead would compile, never match, and the test would fail for a reason
        // that has nothing to do with the behaviour under test.
        when(productDAO.findByPublicIdWithSizes(any())).thenReturn(Optional.empty());

        assertThatThrownBy(() -> pricingService.price(orderRequest))
                .isInstanceOf(ApiException.class)
                .hasMessageContaining("Unknown product");

        verify(productDAO).findByPublicIdWithSizes(any());
    }
}

Mock what you do not own or cannot afford to call — a payment gateway, a mail server, a slow dependency. Do not mock the class under test, and do not mock value objects.

What replaced @MockBean?

@MockitoBean. @MockBean was deprecated in Spring Boot 3.4; the replacement lives in org.springframework.test.context.bean.override.mockito. Both replace a bean in the application context with a mock — which is what you want when a @SpringBootTest must not call the real Stripe:

@SpringBootTest
class OrderApiTest {

    @MockitoBean private StripeService stripeService;   // was @MockBean before Boot 3.4
}

⚠️ Every distinct set of bean overrides is a different context, so a @MockitoBean that appears in only one test class costs a whole extra context load. That is the main reason a fast suite becomes a slow one.

The test context, and why it is cached

How do you share an application context across tests?

You do not have to — the Spring TestContext framework caches it automatically, keyed on the configuration. Every test class asking for the same configuration gets the same context. Starting a context is the expensive part of an integration test; the cache is what makes a suite of 60 of them take seconds rather than minutes.

What evicts it? Any change to the key: different configuration classes, different active profiles, different properties, different bean overrides — or an explicit @DirtiesContext.

The practical rule: keep your test configurations few and identical. Adding one property to one test class silently doubles the number of contexts.

@DirtiesContext is the escape hatch for a test that genuinely corrupts the context. Use it rarely; it throws away the cache.

How Boot simplifies this

How does Spring Boot simplify writing tests?

  • @SpringBootTest finds your @SpringBootApplication and loads the whole context — no @ContextConfiguration to write.
  • It brings the Spring JUnit 5 extension with it, so there is no @RunWith and no @ExtendWith(SpringExtension.class).
  • Slice annotations load a fraction of the context.
  • spring-boot-starter-test supplies the whole toolchain in one dependency.

How is @ContextConfiguration used? To name the configuration classes or locations for a test in plain Spring. In Boot you rarely write it — @SpringBootTest finds the application class by walking up the package tree from the test.

Slice tests

Each loads only the layer it names, so it starts far faster than a full context.

AnnotationLoads
@WebMvcTestcontrollers, converters, filters — no services or repositories
@DataJpaTestJPA, repositories, an embedded database; transactional and rolled back
@JdbcTestJdbcTemplate and a DataSource
@JsonTestJackson serialisation only
@RestClientTesta client, with the server stubbed

@WebMvcTest creates no service beans, so every collaborator the controller needs must be supplied with @MockitoBean. That is the trade: fast, focused, and it will not catch a wiring mistake below the controller.

The full-context test

@SpringBootTest
@Transactional
@DisplayName("ProductService")
class ProductServiceImplTest {

    @Autowired
    private ProductService productService;

    /**
     * The regression this class exists for. Updating used to clear the sizes and re-add
     * them, which made Hibernate schedule the INSERTs before the DELETEs in one flush:
     * "Duplicate entry '42-SMALL' for key 'uk_product_size'". Every product edit 500'd.
     */
    @Test
    @DisplayName("updates prices in place without a unique-constraint violation")
    void updatesPricesInPlace() {
        ProductDTO created = productService.createProduct(create());

        ProductDTO updated = productService.updateProduct(
                created.id(), dto(created.name() + " edited", 8.49, 10.49, 12.49));

        assertThat(updated.sizes()).hasSize(3);
    }
}

That bug is a good argument for integration tests in one paragraph: nothing about it is visible in a unit test, because the fault is in the SQL Hibernate generates and the order it generates it in.

@Transactional in tests

When and where do you use it?

On a test class or method, to get every test rolled back afterwards. That keeps tests independent and leaves the database as it was found — which is what lets these tests mutate seeded rows freely:

/**
 * @Transactional rolls every test back, so the soft-delete cases below can mutate the
 * seeded orders without leaving the demo database dirty.
 */
@SpringBootTest
@Transactional
class ReportServiceImplTest { ... }

What is the default rollback policy in a test?

Roll back — always. Note that this is the opposite of production, where the default is to commit unless a RuntimeException escapes. In a test, Spring rolls back even on success. Override with @Rollback(false) or @Commit if you really need the data to persist.

⚠️ Two traps. A transactional test hides LazyInitializationException, because the persistence context stays open for the whole test and the lazy load that fails in production succeeds here. And a transactional test cannot see work done by another thread, so anything @Async is invisible to it — and a @SpringBootTest(webEnvironment = RANDOM_PORT) test making real HTTP calls runs the server on a different thread, so @Transactional will not roll its work back.

Testing the web layer

MockMvc drives the full MVC stack — routing, binding, converters, security filters — with no port bound and no container started.

@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@DisplayName("API security")
class ApiSecurityIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @DisplayName("the menu is public")
    void menuIsPublic() throws Exception {
        mockMvc.perform(get("/api/products")).andExpect(status().isOk());
    }

    @Test
    @DisplayName("admin endpoints are closed without a token")
    void adminRequiresToken() throws Exception {
        mockMvc.perform(get("/api/admin/products")).andExpect(status().isForbidden());
    }

    @Test
    @DisplayName("a CUSTOMER token does not open admin endpoints")
    void customerCannotReachAdmin() throws Exception {
        String token = tokenFor("customer@pizza.test", "pizza123");

        mockMvc.perform(get("/api/admin/products").header("Authorization", "Bearer " + token))
                .andExpect(status().isForbidden());
    }
}

Security rules are exactly the kind of thing that has to be tested at this level: a matcher ordering mistake is invisible in any unit test and obvious here.

Note the import moved in Boot 4@AutoConfigureMockMvc is now org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc, because Boot 4 split auto-configuration into per-technology modules.

For a real HTTP call use @SpringBootTest(webEnvironment = RANDOM_PORT) with TestRestTemplate or the newer RestTestClient. That exercises the actual server and serialisation, at the cost of a real port and, as noted above, no automatic rollback.

Testing as a particular user without logging in: @WithMockUser(roles = "ADMIN"), from spring-security-test.

Useful test annotations

AnnotationDoes
@SpringBootTestloads the full context
@AutoConfigureMockMvcadds a MockMvc to it
@MockitoBeanreplaces a context bean with a mock
@TestConfigurationtest-only beans, not picked up by component scanning
@ActiveProfiles("test")activates a profile
@Sql("data.sql")runs a script before or after a test
@DirtiesContextevicts the cached context
@DisplayNamea readable name in the report

Two things worth saying in an interview

Test against the database you deploy on. An in-memory H2 standing in for MySQL will not reproduce its SQL dialect, its collation or its isolation default. Testcontainers starts a real database in Docker for the test run, and @ServiceConnection wires Boot to it with no property plumbing.

Assert on behaviour, not on internals. The best test in the pizza suite checks that the reported daily revenue sums back to the reported total — it would catch a wrong SQL rewrite that any assertion on "the method called the DAO once" would sail straight past.

What to remember

  • Unit tests need no Spring. Constructor injection is what makes that true.
  • The test context is cached on its configuration — every variation costs another context load.
  • @MockitoBean, not @MockBean, since Boot 3.4.
  • Tests roll back by default; production commits by default. Opposite rules.
  • A transactional test hides LazyInitializationException and cannot see other threads.
  • MockMvc for the web layer without a server; RANDOM_PORT when you need a real one.