Testing Spring Applications
A fast, trustworthy Spring test suite is a pyramid: plain unit tests for logic, slice tests for one layer, and a few full-context tests on real infrastructure. This page explains how the TestContext framework builds and caches ApplicationContexts, what each slice loads, how MockMvc, @MockitoBean, Testcontainers and spring-security-test fit together, why @Transactional tests lie, and simulates the context cache in plain Java.
Fast Feedback in a Wiring-Heavy Framework
A Spring application is mostly wiring: annotations that only mean something when a container reads them. That tempts teams to test everything with @SpringBootTest - boot the whole application, then call a method. It works, and it is slow: each distinct configuration starts a complete ApplicationContext (component scan, auto-configuration, connection pools, sometimes an embedded server). A suite of 400 such tests easily takes ten minutes, so developers stop running it.
- Starts Tomcat-less MOCK web env, Hibernate, Flyway, the connection pool and every
@Componentto test arithmetic. - Needs a database / broker to be reachable, so it fails on a laptop without Docker or VPN.
- Failure messages are walls of
BeanCreationExceptionstack trace unrelated to the logic under test.
This is the single most important testing advantage of constructor injection (IoC & DI): a bean with its dependencies in the constructor can be built with new in a test. A field-injected bean cannot, which pushes the test toward @SpringBootTest or reflection hacks.
@SpringBootTest checks that the whole machine runs on the factory floor. You want many gear checks and few factory-floor runs.
The TestContext Framework and Context Caching
When a test class carries Spring annotations, JUnit 5's extension mechanism hands control to Spring. @SpringBootTest and every slice annotation is meta-annotated with @ExtendWith(SpringExtension.class). From there the Spring TestContext Framework takes over.
@ExtendWith(SpringExtension.class)TestContextManager for the test class and registers TestExecutionListenersMergedContextConfigurationequals/hashCode is the cache keyContextCacheSpringBootContextLoader boots a new oneDependencyInjectionTestExecutionListener autowires the test instance; TransactionalTestExecutionListener opens and rolls back a transaction; SqlScriptsTestExecutionListener runs @Sql; DirtiesContextTestExecutionListener evicts a context| Annotation | Auto-configures | Typical injected types | Default DB |
|---|---|---|---|
@WebMvcTest | Spring MVC, Jackson, Bean Validation, MockMvc, Spring Security | MockMvc, @MockitoBean services | - |
@WebFluxTest | WebFlux, WebTestClient | WebTestClient | - |
@DataJpaTest | JPA, Spring Data, Flyway/Liquibase, transaction manager | TestEntityManager, repositories | Embedded (H2/HSQL/Derby) replaces your DataSource |
@DataJdbcTest, @JdbcTest | JDBC, JdbcTemplate | JdbcTemplate | Embedded |
@JsonTest | Jackson/Gson, @JsonComponent | JacksonTester<T> | - |
@RestClientTest | RestClient/RestTemplate builders, MockRestServiceServer | the client under test | - |
@DataRedisTest, @DataMongoTest | store-specific repositories | repositories, templates | Real server (use Testcontainers) |
Plain JUnit and Mockito: No Spring
Prefer the plain unit test for anything with logic: pricing rules, mappers, validators, state machines, orchestration with mocked collaborators. You need no Spring annotation at all - only JUnit 5, Mockito and AssertJ, all included in spring-boot-starter-test.
| Tool | Use it for | Notes |
|---|---|---|
@Mock / @InjectMocks | Collaborators and the object under test | @InjectMocks tries constructor, then setter, then field injection - prefer calling new yourself when the constructor is simple |
@Spy | Partial mock of a real object | Use sparingly; usually a sign the class does too much |
ArgumentCaptor | Assert on what you passed to a collaborator | Prefer capturing over any() matchers that verify nothing |
BDDMockito (given/then) | Arrange-Act-Assert readability | Same engine as when/verify |
AssertJ assertThat | All assertions | Fluent, rich failure messages; assertThatThrownBy for exceptions |
| Awaitility | Asserting asynchronous outcomes | await().atMost(5, SECONDS).untilAsserted(...) instead of Thread.sleep |
UnnecessaryStubbingException(org.mockito.exceptions.misusing.UnnecessaryStubbingException: Unnecessary stubbings detected):MockitoExtensionruns in strict-stubs mode. A stub the code path never used is reported. It is usually telling the truth - delete the stub or fix the test's scenario.- Over-mocking: mocking value objects, mappers and records produces tests that mirror the implementation line by line and break on every refactor. Mock boundaries (network, database, clock), instantiate everything else.
- Verifying instead of asserting:
verify(repo).save(any())proves a call happened, not that the right data was saved. Capture and assert on the argument. - Time and randomness: inject
java.time.Clockand an id generator; tests then useClock.fixed(...). A test usingLocalDate.now()fails at midnight on the CI box. - Field injection forces Spring: a
@Autowired privatefield with no constructor cannot be satisfied bynew; the test needsReflectionTestUtils.setFieldor a container. Move to constructor injection.
@WebMvcTest, @DataJpaTest and @JsonTest
A slice test boots only the beans of one layer by applying a restricted set of auto-configurations and a TypeExcludeFilter to component scanning. It is the right level to test things unit tests cannot: request mapping, JSON (de)serialization, validation, exception handlers, security rules, JPA mappings and queries.
@WebMvcTest(OrderController.class) loads @Controller/@RestController beans (all, or only the listed), @ControllerAdvice, Converters, Filters, HandlerInterceptors, WebMvcConfigurer and Jackson modules. It does not load @Service, @Repository or other @Component beans - any such collaborator of the controller must be provided as a mock. Component-scanned @Configuration classes are also excluded from slices, so your SecurityFilterChain config needs an explicit @Import.
| MockMvc step | What really happens |
|---|---|
perform(get(...)) | Builds a MockHttpServletRequest; no socket, no Tomcat. The request goes through the full filter chain (including Spring Security) and DispatcherServlet |
| Handler mapping and argument resolution | Identical to production: @RequestBody is read by the real HttpMessageConverters, validation runs, exception handlers apply |
andExpect(...) | Assertions on the MockHttpServletResponse: status, headers, body, jsonPath (from the JsonPath library) |
andDo(print()) | Dumps request and response - the fastest way to see why a test returns 404 or 403 |
Loads entities, Spring Data repositories, Hibernate and a transaction manager - and, by default, replaces your DataSource with an embedded database if one is on the classpath. Every test method runs in a transaction that is rolled back.
The flush() and clear() calls matter. Without flush, Hibernate may never send the INSERT (so constraint violations and wrong SQL go unnoticed). Without clear, the query returns the same entity instances from the first-level cache, so you are testing the persistence context, not the database. See Spring Data JPA.
@JsonTest loads the Spring-configured ObjectMapper, so it catches the mismatch between "works with a bare new ObjectMapper()" and "works with the application's modules, naming strategy and spring.jackson.* settings".
NoSuchBeanDefinitionException/UnsatisfiedDependencyExceptionin a@WebMvcTest: the controller needsOrderService, which the slice did not load. Add@MockitoBean OrderService. If the message names a repository, a controller depends on something it should not.- Everything returns 401/403: Spring Security is active in the slice. Add
@WithMockUser, or@Importyour own filter chain; for state-changing requests add.with(csrf()). - Your
SecurityFilterChainis silently not applied: it lives in a scanned@Configurationclass that slices exclude; the default security config protects everything instead.@Import(SecurityConfig.class). @DataJpaTestpasses on H2, fails on PostgreSQL: dialect differences (JSONB, native queries, sequences, case-sensitivity, locking). Use Testcontainers and@AutoConfigureTestDatabase(replace = Replace.NONE)- chapter 07.- Controller test needs the whole app: a sign the controller holds logic. Move it to a service and unit-test it.
@SpringBootTest, Web Environments and Context Caching
@SpringBootTest loads the full application context the way SpringApplication.run would. It locates your @SpringBootConfiguration by searching upward from the test's package (failing with IllegalStateException: Unable to find a @SpringBootConfiguration by searching packages upwards from the test if your test is in an unrelated package). Use it sparingly, for end-to-end wiring checks.
| webEnvironment | Server? | HTTP client | Use for |
|---|---|---|---|
MOCK (default) | No real server; mock servlet environment | @AutoConfigureMockMvc then MockMvc | Whole-app tests that do not need real HTTP |
RANDOM_PORT | Embedded server on a random port | TestRestTemplate, WebTestClient, @LocalServerPort | Real HTTP: filters, serialization, TLS-free end-to-end |
DEFINED_PORT | Embedded server on server.port | Same | Rare; port clashes in CI |
NONE | No web environment at all | - | Batch jobs, messaging, services only |
| Difference between two test classes | New context? | Why |
|---|---|---|
Different @MockitoBean / @MockBean / @SpyBean sets | Yes | The mock set is part of the key, so each distinct combination boots separately |
@ActiveProfiles | Yes | Different environment, different bean graph |
@TestPropertySource / properties= / @DynamicPropertySource | Yes (different values) | Property sources are in the key |
@Import / @TestConfiguration / different classes= | Yes | Different configuration classes |
webEnvironment | Yes | MOCK vs RANDOM_PORT |
@DirtiesContext | Evicts it | Marks the context unusable; the next test boots again |
| Only the test method bodies differ | No | Same key, shared context |
- Suite slows down gradually: each new
@MockitoBeanin a different class adds one application boot. Diagnose withlogging.level.org.springframework.test.context.cache=DEBUG, which prints cache statistics likesize = 5, maxSize = 32, hitCount = 3, missCount = 5. Fix: move shared mocks into a common abstract base class or a shared@TestConfigurationso classes produce the same key. @DirtiesContextas a cleanup tool: it throws away a 6-second boot to reset a map. Clean the state explicitly in@AfterEachor design beans without hidden state.- State leaks through cached contexts: an in-memory cache, a
@Scheduledjob, a message listener or a static field in a cached context affects later test classes. Symptom: tests pass alone, fail in the full run. Clear caches in@BeforeEach; disable scheduling in tests. - Port clashes with
DEFINED_PORT:java.net.BindException: Address already in use. UseRANDOM_PORTand@LocalServerPort. MockMvcnot autowired in a@SpringBootTest: add@AutoConfigureMockMvc.
@MockitoBean, @MockBean and @TestConfiguration
Slice and full-context tests replace beans with Mockito mocks so that one layer can be tested alone. For years that was @MockBean and @SpyBean from org.springframework.boot.test.mock.mockito. Spring Framework 6.2 introduced the bean override mechanism and Spring Boot 3.4 deprecated the old annotations in favour of @MockitoBean and @MockitoSpyBean from org.springframework.test.context.bean.override.mockito.
| @MockBean / @SpyBean (old) | @MockitoBean / @MockitoSpyBean (new) | |
|---|---|---|
| Package | org.springframework.boot.test.mock.mockito | org.springframework.test.context.bean.override.mockito |
| Since | Boot 1.4 | Spring Framework 6.2 / Boot 3.4 |
| Status | Deprecated from Boot 3.4 (removal planned in later majors) | Supported replacement |
| Where | Fields, or on a class with @MockBean(Foo.class) | Fields of the test class (including inherited and nested classes) |
| Context cache | Mock set part of the key | Same - the override definitions are part of the key |
| Reset | Mock reset after each test method | Same default |
- Mock when you want to script return values and verify interactions on a boundary you do not own (payment gateway, remote client).
- Fake bean via
@TestConfigurationwhen behaviour is stateful or used by many tests (in-memory repository, fixed clock). It also shares one context across all tests that import it. - Treat deprecated-API warnings as a migration task, not noise: the old annotations are slated for removal in a future major version.
Testcontainers, @ServiceConnection and @DynamicPropertySource
H2 is fast, but it is not your production database. Differences in SQL dialect, JSON types, sequence behaviour, locking, case sensitivity and index semantics mean a green H2 suite can hide a red production. Testcontainers starts a throwaway Docker container of the real thing (PostgreSQL, Kafka, Redis, ...) for the test run, so a repository test exercises the real engine. It needs a Docker-compatible runtime on the machine and CI agent.
Subclasses with the same properties produce the same context key and share one Spring context and one database. If tests do not clean up after themselves, they also share data - use unique ids per test, or truncate tables in @BeforeEach.
| Approach | Container lifetime | Context impact | When |
|---|---|---|---|
Non-static @Container field | Per test method | Properties change every time: new context each method | Almost never what you want |
Static @Container field | Per test class | One context per class | A few IT classes |
| Singleton in abstract base | Per JVM | One shared context | Many IT classes (recommended) |
Reusable container (withReuse(true)) | Survives JVM exit on a developer machine | Needs testcontainers.reuse.enable=true in ~/.testcontainers.properties | Fast local re-runs; not for CI |
Could not find a valid Docker environment: no Docker daemon reachable. On CI, use an agent with Docker (or a remote daemon / Testcontainers Cloud); gate ITs behind a Maven profile for machines without Docker.- Non-static
@Container: the container restarts per test method, and with@DynamicPropertySourcethe changed JDBC URL forces a new application context each time - a 1-second test becomes 15 seconds. @DataJpaTestignores your container: withoutreplace = NONEit swaps in an embedded database when one is on the classpath, and your Postgres container sits idle. Verify with the log line that prints the JDBC URL.- Schema drift:
spring.jpa.hibernate.ddl-auto=create-dropin tests hides missing migrations. Run Flyway/Liquibase against the container so tests execute the same DDL as production. - Flaky readiness: a container is "started" before the app inside accepts connections; the built-in wait strategies handle the common images - for custom ones add
waitingFor(Wait.forListeningPort())orWait.forLogMessage. - Image versions: pin a tag matching production.
postgres:latestwill fail your build the day a major version changes.
@WithMockUser, jwt() and the @Transactional Trap
Two features that look simple in tests and behave surprisingly: security and @Transactional.
Add spring-security-test. In MockMvc tests the Spring Security filter chain is applied, so a controller test also verifies authentication and authorization rules. Remember the two failure codes: 401 means "I do not know who you are" (no or bad credentials), 403 means "I know you, and you may not do that" - or a state-changing request without a CSRF token.
@WithMockUseris not yourUserDetailsService: it fabricates an authentication (default usernameuser, roleUSER) and skips the real login path. Test the login/authentication flow separately withhttpBasic(),formLogin()or a full@SpringBootTest.- Method security is not triggered:
@PreAuthorizeonly fires on a proxied bean in a context that has method security enabled. A@WebMvcTestwith a mocked service never reaches it - test it in a test that loads the real secured bean. - Forgetting
csrf(): POST/PUT/DELETE tests return 403 and the failure looks like an authorization bug. See Spring Security Architecture.
TransactionalTestExecutionListener wraps each test method in a transaction and rolls it back afterwards (override with @Commit or @Rollback(false)). That makes data cleanup free, which is why @DataJpaTest is transactional. But the test transaction changes what the code under test sees.
| Pitfall | What you observe | Fix |
|---|---|---|
| No flush | Constraint violations, wrong SQL and @PrePersist-dependent behaviour never surface; the test is green | Call flush() (and clear()) before asserting |
| First-level cache hides lazy loading | Entities are still attached, so a lazy collection loads fine. In production the same code throws LazyInitializationException: could not initialize proxy - no Session | Use @Transactional(propagation = NOT_SUPPORTED) on the test, or em.clear() and assert on a detached entity |
Propagation REQUIRES_NEW in the service | The inner transaction commits for real, not rolled back; data leaks into later tests | Clean up explicitly or avoid @Transactional on that test |
@TransactionalEventListener(AFTER_COMMIT) | Never fires: the test transaction never commits | Test the listener in a non-transactional test or use @Commit and clean up |
RANDOM_PORT + @Transactional test | Server thread commits independently; test-side rollback does nothing | Do not rely on rollback; clean up via @Sql or repository calls |
@Transactional on the test hides a missing one in production | Service method forgot @Transactional but the test supplied one | Test services without a test transaction when transaction boundaries matter |
See Transactions for the production semantics this test is protecting.
A Simulated Test Context Cache
Context caching is the biggest lever on suite speed, so it is worth seeing as plain code. This 40-line simulation models what the ContextCache does: the key is everything that changes the bean graph, a hit reuses a context, a miss boots a new one (the slow step), and an LRU bound evicts old ones. Nothing here depends on Spring - compile and run it with javac.
- Eight test classes caused five boots.
PaymentITandRefundITshared a context because their mock sets were equal - order and class name do not matter, only the key. OrderServiceIT againhit context #1 even after four other contexts were created: the cache is keyed, not sequential, and only the LRU limit (32 by default) evicts.- To cut boots: move the common mocks into a shared base class or
@TestConfigurationso many classes produce one key (here, one extra key per mock set is the cost). - The same reasoning explains why a static, shared Testcontainers container matters: a container with a changing URL would be a new key every time.
Keeping a Large Suite Fast and Honest
Habits that keep a Spring test suite fast and trustworthy as it grows.
| Practice | Why | How |
|---|---|---|
Name unit tests *Test, integration tests *IT | Maven Surefire runs *Test/*Tests; Failsafe runs *IT in verify | Fast feedback on mvn test, full run on CI |
| One shared abstract base for ITs | Same annotations and properties = same context key | Put @SpringBootTest, profile, container setup in the base class |
| Replace sleeps with Awaitility | Sleeps are slow when generous and flaky when tight | await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> ...) |
Inject Clock | Deterministic time | @TestConfiguration fixed Clock bean |
| Disable noise in tests | Schedulers and listeners mutate shared contexts | spring.task.scheduling.enabled=false (property), stub message consumers |
| Do not assert on log text | Brittle | Assert on state, return values and metrics; use OutputCaptureExtension only for startup logging |
| Test the error paths | Production incidents live there | 4xx mapping, constraint violations, timeouts, breaker fallbacks |
| Keep test data explicit | Hidden data.sql couples tests | Create data in the test or with @Sql next to it |
- Parallel execution with shared contexts: enabling
junit.jupiter.execution.parallel.enabledwhile tests share a database or cached beans produces order-dependent flakiness. Parallelize only unit tests, or isolate data per test. - Tests that depend on execution order: pass in the IDE, fail on CI. Run them in random order locally (JUnit 5
MethodOrderer.Random) to flush them out. - Coverage as a goal: 90% line coverage from
@SpringBootTestsmoke tests that only assert "context loads" gives confidence in nothing. Assert behaviour.
Questions You Should Be Able to Answer
Questions that come up on this topic, with the answer an interviewer wants.
- Q: What is the difference between @SpringBootTest and @WebMvcTest?
@SpringBootTestloads the full application context;@WebMvcTestloads only the web layer (controllers, advice, converters, filters, Jackson, security) and needs collaborators mocked. The slice is faster and tests mapping/serialization/validation in isolation. - Q: How does MockMvc differ from a real HTTP test?
MockMvc builds a mock servlet request and callsDispatcherServletin the same thread - no socket, no server. A real HTTP test (RANDOM_PORT+TestRestTemplate/WebTestClient) starts the embedded server and runs on separate threads. - Q: How does Spring cache test contexts, and what breaks the cache?
TheContextCachekeys on the merged context configuration (config classes, profiles, properties, mock beans, web environment). Any difference - a distinct@MockitoBeanset,@ActiveProfiles, a property,@DirtiesContext- causes a new boot. Default maximum is 32 contexts. - Q: @MockBean vs @MockitoBean?
Same idea (replace a bean with a Mockito mock).@MockitoBean(Framework 6.2, Boot 3.4+) is part of the core bean-override mechanism;@MockBeanis Boot-specific and deprecated since Boot 3.4. - Q: Why does a @Transactional test pass but production throws LazyInitializationException?
The test transaction keeps one open persistence context, so lazy associations load. In production the transaction ended before the view layer touched the proxy. Test without a test transaction, orclear()and use detached entities. - Q: What does @DataJpaTest do to my DataSource?
It replaces it with an embedded database by default and makes each test transactional with rollback. Use@AutoConfigureTestDatabase(replace = NONE)and Testcontainers to test against the real database. - Q: @ServiceConnection vs @DynamicPropertySource?
Both feed container coordinates to Spring.@ServiceConnection(Boot 3.1+) lets Boot derive the connection details automatically from the container type;@DynamicPropertySourceis manual and works for anything you can express as properties. - Q: How do you test that a secured endpoint returns 401 vs 403?
MockMvc withspring-security-test: no auth → 401,@WithMockUserwith the wrong role → 403, correct role → 200; POST withoutcsrf()→ 403. For JWT resource servers use thejwt()post-processor. - Q: Why is constructor injection better for testing?
Dependencies are visible and mandatory, so a unit test can instantiate the class withnewand mocks - no Spring, no reflection. - Q: How do you test an @Async method or an event listener?
Assert the eventual outcome with Awaitility, or inject a synchronous executor in tests. For@TransactionalEventListener(AFTER_COMMIT)the test must actually commit (non-transactional test or@Commit). - Q: What is the testing pyramid for Spring?
Many plain unit tests, a moderate number of slice tests for wiring and mapping, and few full-context tests with real infrastructure for critical journeys.
Cheat Sheet and Recap
| I want to verify... | Use | Loads |
|---|---|---|
| Business rules, mappers, calculations | Plain JUnit + Mockito | Nothing |
| URL mapping, validation, status codes, JSON shape, advice | @WebMvcTest + MockMvc | Web layer |
| Wire format of DTOs | @JsonTest | Jackson |
| Queries, mappings, constraints | @DataJpaTest + Testcontainers | JPA |
| Outbound HTTP client | @RestClientTest + MockRestServiceServer | Client builders |
| Security rules | MockMvc + spring-security-test | Web + security |
| A real user journey through the stack | @SpringBootTest(RANDOM_PORT) + Testcontainers | Everything |
- Cheapest test that can fail wins. Constructor injection makes most logic testable without Spring.
- Slices load one layer and need collaborators supplied (
@MockitoBean) and your own config@Imported. - The context cache key is the whole configuration: mocks, profiles, properties, web env. Keep it uniform to keep the suite fast.
@MockitoBeanreplaces@MockBeanfrom Boot 3.4.- Use the real database via Testcontainers (
@ServiceConnection) for anything query- or dialect-related; keepreplace = NONE. - Test transactions roll back - which also hides lazy-loading bugs, missing flushes and after-commit behaviour.
- 401 = who are you, 403 = not allowed (or no CSRF token).
- IoC & DI - constructor injection and why it makes classes testable.
- REST Controllers & MVC - what MockMvc exercises.
- Spring Data JPA and Transactions - the semantics behind repository and rollback tests.
- Spring Security Architecture and JWT, OAuth2 & OIDC - what the security test helpers simulate.
- Caching & Resilience - testing breakers and cache behaviour.
- Interview Q&A - more questions across all topics.