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 test | Integration test | |
|---|---|---|
| Context | none | Spring |
| Collaborators | mocks | real |
| Speed | milliseconds | seconds |
| Catches | logic errors | wiring, 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?
@SpringBootTestfinds your@SpringBootApplicationand loads the whole context — no@ContextConfigurationto write.- It brings the Spring JUnit 5 extension with it, so there is no
@RunWithand no@ExtendWith(SpringExtension.class). - Slice annotations load a fraction of the context.
spring-boot-starter-testsupplies 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.
| Annotation | Loads |
|---|---|
@WebMvcTest | controllers, converters, filters — no services or repositories |
@DataJpaTest | JPA, repositories, an embedded database; transactional and rolled back |
@JdbcTest | JdbcTemplate and a DataSource |
@JsonTest | Jackson serialisation only |
@RestClientTest | a 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
| Annotation | Does |
|---|---|
@SpringBootTest | loads the full context |
@AutoConfigureMockMvc | adds a MockMvc to it |
@MockitoBean | replaces a context bean with a mock |
@TestConfiguration | test-only beans, not picked up by component scanning |
@ActiveProfiles("test") | activates a profile |
@Sql("data.sql") | runs a script before or after a test |
@DirtiesContext | evicts the cached context |
@DisplayName | a 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
LazyInitializationExceptionand cannot see other threads. MockMvcfor the web layer without a server;RANDOM_PORTwhen you need a real one.