Spring · 14

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.

12
Chapters
4
Test levels
6
Slice annotations
1
Plain-Java build
01
Chapter One - Why It Matters

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.

Testing pyramid for a Spring application
E2E / UIfew, minutes @SpringBootTestfull context, Testcontainers Slice tests@WebMvcTest @DataJpaTest @JsonTest Plain unit testsJUnit + Mockito, no Spring Cost per test (rough) E2E: seconds to minutes Full context: 1-10 s boot, then ms per test if cached Slice: ~0.5-3 s boot, loads only one layer Unit: milliseconds no container at all Rule of thumb: test logic with unit tests, test wiring and mapping with slices, test a few critical journeys with @SpringBootTest against real infrastructure.
Before: every test boots everything
Java - works, but 5 seconds to check one if-statement
@SpringBootTest class DiscountServiceTest { @Autowired DiscountService service; // pulls in the web layer, JPA, security, Kafka... @Test void goldCustomersGetTenPercent() { assertThat(service.discountFor(new Customer("GOLD"), 200)).isEqualTo(20); } }
⚠ What is wrong with this
  • Starts Tomcat-less MOCK web env, Hibernate, Flyway, the connection pool and every @Component to test arithmetic.
  • Needs a database / broker to be reachable, so it fails on a laptop without Docker or VPN.
  • Failure messages are walls of BeanCreationException stack trace unrelated to the logic under test.
After: test the cheapest thing that can fail
Java - plain unit test, 5 milliseconds, zero Spring
class DiscountServiceTest { private final DiscountService service = new DiscountService(new FixedClock()); // constructor injection pays off @Test void goldCustomersGetTenPercent() { assertThat(service.discountFor(new Customer("GOLD"), 200)).isEqualTo(20); } }

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.

Key insight: Unit tests check that each gear is cut correctly. Slice tests check that gears mesh in one subassembly (web, data). A @SpringBootTest checks that the whole machine runs on the factory floor. You want many gear checks and few factory-floor runs.
02
Chapter Two - Mental Model

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.

JUnit 5 sees @ExtendWith(SpringExtension.class)
▶
The extension creates (or reuses) a TestContextManager for the test class and registers TestExecutionListeners
Spring computes a MergedContextConfiguration
▶
A value object made of: configuration classes, active profiles, property sources, context customizers (including the set of mock beans), the web environment type - its equals/hashCode is the cache key
The key is looked up in the ContextCache
▶
Same key as an earlier test class: the same live ApplicationContext is reused. New key: SpringBootContextLoader boots a new one
Listeners post-process each test
▶
DependencyInjectionTestExecutionListener autowires the test instance; TransactionalTestExecutionListener opens and rolls back a transaction; SqlScriptsTestExecutionListener runs @Sql; DirtiesContextTestExecutionListener evicts a context
Contexts outlive test classes
▶
Beans in a cached context keep their state (caches, counters, in-memory maps) across test classes - a classic source of order-dependent tests
How @SpringBootTest obtains its ApplicationContext
JUnit 5 SpringExtension TestContextManager listeners ContextLoader SpringBootContextLoader ContextCache key = merged config 1 JUnit calls SpringExtension.postProcessTestInstance() 2 TestContextManager builds MergedContextConfiguration: classes, profiles, properties, mock-bean set, web env, initializers 3 ContextCache.get(key): HIT -> reuse the same ApplicationContext (ms) 4 MISS -> SpringBootContextLoader boots a new ApplicationContext (seconds), stored in the cache 5 DependencyInjectionTestExecutionListener injects @Autowired / @MockitoBean fields into the test instance 6 TransactionalTestExecutionListener starts a test transaction (if @Transactional) before each method 7 Test runs; after each method: rollback. @DirtiesContext marks the context for removal from the cache Default cache size: 32 contexts (spring.test.context.cache.maxSize), least recently used evicted first.
What each test slice puts in the context
@WebMvcTest LOADS @Controller / @RestController @ControllerAdvice Converter, Filter, WebMvcConfigurer Jackson modules MockMvc auto-config NOT LOADED @Service @Repository @Component DataSource @DataJpaTest LOADS @Entity scan Spring Data repos EntityManager, TestEntityManager Flyway/Liquibase embedded DB (default) NOT LOADED @Controller @Service web layer security @JsonTest LOADS ObjectMapper Jackson modules @JsonComponent JacksonTester NOT LOADED everything else Anything the slice does not load must be supplied: @MockitoBean for collaborators, @Import for your own config.
Slice tests in one table
AnnotationAuto-configuresTypical injected typesDefault DB
@WebMvcTestSpring MVC, Jackson, Bean Validation, MockMvc, Spring SecurityMockMvc, @MockitoBean services-
@WebFluxTestWebFlux, WebTestClientWebTestClient-
@DataJpaTestJPA, Spring Data, Flyway/Liquibase, transaction managerTestEntityManager, repositoriesEmbedded (H2/HSQL/Derby) replaces your DataSource
@DataJdbcTest, @JdbcTestJDBC, JdbcTemplateJdbcTemplateEmbedded
@JsonTestJackson/Gson, @JsonComponentJacksonTester<T>-
@RestClientTestRestClient/RestTemplate builders, MockRestServiceServerthe client under test-
@DataRedisTest, @DataMongoTeststore-specific repositoriesrepositories, templatesReal server (use Testcontainers)
03
Chapter Three - Unit Tests

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.

Java - orchestration logic with mocked collaborators
@ExtendWith(MockitoExtension.class) class PlaceOrderServiceTest { @Mock InventoryClient inventory; @Mock PaymentGateway payments; @Mock OrderRepository orders; @Captor ArgumentCaptor<Order> saved; @InjectMocks PlaceOrderService service; // built with the constructor, mocks passed in @Test void reservesStockChargesAndPersists() { given(inventory.reserve("SKU-1", 2)).willReturn(true); given(payments.charge(any(), eq(Money.of("40.00")))).willReturn(new Receipt("r-1")); OrderConfirmation result = service.place(new PlaceOrder("c-9", "SKU-1", 2, Money.of("40.00"))); then(orders).should().save(saved.capture()); assertThat(saved.getValue().status()).isEqualTo(OrderStatus.PAID); assertThat(result.receiptId()).isEqualTo("r-1"); } @Test void doesNotChargeWhenStockIsUnavailable() { given(inventory.reserve("SKU-1", 2)).willReturn(false); assertThatThrownBy(() -> service.place(new PlaceOrder("c-9", "SKU-1", 2, Money.of("40.00")))) .isInstanceOf(OutOfStockException.class); then(payments).shouldHaveNoInteractions(); // the important assertion: money never moved } }
ToolUse it forNotes
@Mock / @InjectMocksCollaborators and the object under test@InjectMocks tries constructor, then setter, then field injection - prefer calling new yourself when the constructor is simple
@SpyPartial mock of a real objectUse sparingly; usually a sign the class does too much
ArgumentCaptorAssert on what you passed to a collaboratorPrefer capturing over any() matchers that verify nothing
BDDMockito (given/then)Arrange-Act-Assert readabilitySame engine as when/verify
AssertJ assertThatAll assertionsFluent, rich failure messages; assertThatThrownBy for exceptions
AwaitilityAsserting asynchronous outcomesawait().atMost(5, SECONDS).untilAsserted(...) instead of Thread.sleep
⚠ Production gotchas
  • UnnecessaryStubbingException (org.mockito.exceptions.misusing.UnnecessaryStubbingException: Unnecessary stubbings detected): MockitoExtension runs 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.Clock and an id generator; tests then use Clock.fixed(...). A test using LocalDate.now() fails at midnight on the CI box.
  • Field injection forces Spring: a @Autowired private field with no constructor cannot be satisfied by new; the test needs ReflectionTestUtils.setField or a container. Move to constructor injection.
04
Chapter Four - Slice Tests

@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 and MockMvc

@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.

Java - a complete controller slice test
@WebMvcTest(OrderController.class) @Import(SecurityConfig.class) // slices skip @Configuration classes found by scanning class OrderControllerTest { @Autowired MockMvc mvc; @MockitoBean OrderService orders; // Boot 3.4+/Framework 6.2; use @MockBean on older versions @Test @WithMockUser(roles = "USER") void returnsOrder() throws Exception { given(orders.find(7L)).willReturn(new OrderView(7L, "PAID", new BigDecimal("40.00"))); mvc.perform(get("/api/orders/7").accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.id").value(7)) .andExpect(jsonPath("$.status").value("PAID")); } @Test @WithMockUser void rejectsInvalidBodyWith400() throws Exception { mvc.perform(post("/api/orders").with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{"sku":"","quantity":0}")) .andExpect(status().isBadRequest()); verifyNoInteractions(orders); // validation stopped it before the service } @Test void anonymousIs401() throws Exception { mvc.perform(get("/api/orders/7")).andExpect(status().isUnauthorized()); } }
MockMvc stepWhat 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 resolutionIdentical 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
@DataJpaTest

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.

Java - repository slice test with a derived query and a constraint
@DataJpaTest class OrderRepositoryTest { @Autowired TestEntityManager em; @Autowired OrderRepository repo; @Test void findsOpenOrdersForCustomerNewestFirst() { em.persist(new Order("c-1", OrderStatus.PAID, Instant.parse("2025-01-01T10:00:00Z"))); em.persist(new Order("c-1", OrderStatus.OPEN, Instant.parse("2025-01-02T10:00:00Z"))); em.persist(new Order("c-2", OrderStatus.OPEN, Instant.parse("2025-01-03T10:00:00Z"))); em.flush(); em.clear(); // force SQL, then force a re-read from the database List<Order> open = repo.findByCustomerIdAndStatusOrderByCreatedAtDesc("c-1", OrderStatus.OPEN); assertThat(open).hasSize(1); assertThat(open.get(0).customerId()).isEqualTo("c-1"); } @Test void duplicateReferenceViolatesUniqueConstraint() { repo.saveAndFlush(new Order("REF-1", "c-1")); assertThatThrownBy(() -> repo.saveAndFlush(new Order("REF-1", "c-2"))) .isInstanceOf(DataIntegrityViolationException.class); } }

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
Java - pin the wire format of a DTO
@JsonTest class OrderViewJsonTest { @Autowired JacksonTester<OrderView> json; @Test void serializesWithSnakeCaseDatesAndMoney() throws Exception { OrderView v = new OrderView(7L, "PAID", new BigDecimal("40.00")); assertThat(json.write(v)).extractingJsonPathNumberValue("$.id").isEqualTo(7); assertThat(json.write(v)).extractingJsonPathStringValue("$.status").isEqualTo("PAID"); } @Test void ignoresUnknownPropertiesOnInput() throws Exception { OrderView v = json.parseObject("{"id":7,"status":"PAID","amount":40.00,"extra":true}"); assertThat(v.id()).isEqualTo(7L); } }

@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".

⚠ Production gotchas
  • NoSuchBeanDefinitionException / UnsatisfiedDependencyException in a @WebMvcTest: the controller needs OrderService, 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 @Import your own filter chain; for state-changing requests add .with(csrf()).
  • Your SecurityFilterChain is silently not applied: it lives in a scanned @Configuration class that slices exclude; the default security config protects everything instead. @Import(SecurityConfig.class).
  • @DataJpaTest passes 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.
05
Chapter Five - Full Context

@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.

webEnvironmentServer?HTTP clientUse for
MOCK (default)No real server; mock servlet environment@AutoConfigureMockMvc then MockMvcWhole-app tests that do not need real HTTP
RANDOM_PORTEmbedded server on a random portTestRestTemplate, WebTestClient, @LocalServerPortReal HTTP: filters, serialization, TLS-free end-to-end
DEFINED_PORTEmbedded server on server.portSameRare; port clashes in CI
NONENo web environment at all-Batch jobs, messaging, services only
Java - a full-stack test against a real port and a real database
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Testcontainers class OrderFlowIT { @Container @ServiceConnection static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine"); @Autowired TestRestTemplate rest; @Autowired OrderRepository orders; @Test void createThenFetchOrder() { ResponseEntity<OrderView> created = rest.withBasicAuth("alice", "secret") .postForEntity("/api/orders", new PlaceOrder("c-9", "SKU-1", 2), OrderView.class); assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat(created.getHeaders().getLocation()).isNotNull(); assertThat(orders.findById(created.getBody().id())).isPresent(); // visible: server thread COMMITTED } }
What invalidates the context cache
Difference between two test classesNew context?Why
Different @MockitoBean / @MockBean / @SpyBean setsYesThe mock set is part of the key, so each distinct combination boots separately
@ActiveProfilesYesDifferent environment, different bean graph
@TestPropertySource / properties= / @DynamicPropertySourceYes (different values)Property sources are in the key
@Import / @TestConfiguration / different classes=YesDifferent configuration classes
webEnvironmentYesMOCK vs RANDOM_PORT
@DirtiesContextEvicts itMarks the context unusable; the next test boots again
Only the test method bodies differNoSame key, shared context
Why one extra @MockitoBean costs a whole application boot
Context cache after the run in chapter 09 (key -> ApplicationContext) #1 MOCK, no profiles, no mocks -> OrderServiceIT, CustomerServiceIT #2 MOCK + @MockitoBean PaymentGateway -> PaymentIT, RefundIT #3 MOCK + @MockitoBean ShippingClient -> ShippingIT #4 RANDOM_PORT -> ApiIT #5 MOCK + @ActiveProfiles("admin") -> AdminIT 5 distinct keys = 5 Spring boots. Every difference in mocks, profiles, properties or web env is a different key. Same shape of test class = shared context = fast suite.
⚠ Production gotchas
  • Suite slows down gradually: each new @MockitoBean in a different class adds one application boot. Diagnose with logging.level.org.springframework.test.context.cache=DEBUG, which prints cache statistics like size = 5, maxSize = 32, hitCount = 3, missCount = 5. Fix: move shared mocks into a common abstract base class or a shared @TestConfiguration so classes produce the same key.
  • @DirtiesContext as a cleanup tool: it throws away a 6-second boot to reset a map. Clean the state explicitly in @AfterEach or design beans without hidden state.
  • State leaks through cached contexts: an in-memory cache, a @Scheduled job, 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. Use RANDOM_PORT and @LocalServerPort.
  • MockMvc not autowired in a @SpringBootTest: add @AutoConfigureMockMvc.
06
Chapter Six - Replacing Beans

@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)
Packageorg.springframework.boot.test.mock.mockitoorg.springframework.test.context.bean.override.mockito
SinceBoot 1.4Spring Framework 6.2 / Boot 3.4
StatusDeprecated from Boot 3.4 (removal planned in later majors)Supported replacement
WhereFields, or on a class with @MockBean(Foo.class)Fields of the test class (including inherited and nested classes)
Context cacheMock set part of the keySame - the override definitions are part of the key
ResetMock reset after each test methodSame default
Java - the same test in both styles, plus a replacement bean without Mockito
@WebMvcTest(OrderController.class) class OrderControllerStylesTest { @MockitoBean OrderService orders; // new (Boot 3.4+) // @MockBean OrderService orders; // old, deprecated since Boot 3.4 } // A hand-written fake is often clearer than a mock for stateful collaborators: @TestConfiguration(proxyBeanMethods = false) class FakeClockConfig { @Bean @Primary Clock fixedClock() { return Clock.fixed(Instant.parse("2025-06-01T12:00:00Z"), ZoneOffset.UTC); } } @SpringBootTest @Import(FakeClockConfig.class) // nested @TestConfiguration is picked up automatically; top-level needs @Import class SubscriptionExpiryIT { /* ... */ }
Choosing between them:
  • 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 @TestConfiguration when 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.
07
Chapter Seven - Real Infrastructure

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.

Java - Spring Boot 3.1+ with @ServiceConnection (recommended)
@DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // keep the real datasource @Testcontainers class OrderRepositoryPostgresTest { @Container @ServiceConnection // Boot derives url/user/password from the container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine"); @Autowired OrderRepository repo; @Test void jsonbColumnRoundTrips() { Order o = repo.saveAndFlush(new Order("c-1", Map.of("gift", true))); assertThat(repo.findById(o.id()).orElseThrow().attributes()).containsEntry("gift", true); } }
Java - before @ServiceConnection: @DynamicPropertySource
@SpringBootTest @Testcontainers class LegacyStyleIT { @Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine"); @DynamicPropertySource // static method, runs after the container started static void props(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); } }
One container for the whole suite (singleton pattern)
Java - share a container across test classes
abstract class AbstractPostgresIT { static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); static { POSTGRES.start(); } // started once per JVM; Ryuk removes it at exit @DynamicPropertySource static void props(DynamicPropertyRegistry r) { r.add("spring.datasource.url", POSTGRES::getJdbcUrl); r.add("spring.datasource.username", POSTGRES::getUsername); r.add("spring.datasource.password", POSTGRES::getPassword); } } @SpringBootTest class OrderApiIT extends AbstractPostgresIT { /* ... */ } @SpringBootTest class BillingApiIT extends AbstractPostgresIT { /* same properties -> same cached context */ }

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.

ApproachContainer lifetimeContext impactWhen
Non-static @Container fieldPer test methodProperties change every time: new context each methodAlmost never what you want
Static @Container fieldPer test classOne context per classA few IT classes
Singleton in abstract basePer JVMOne shared contextMany IT classes (recommended)
Reusable container (withReuse(true))Survives JVM exit on a developer machineNeeds testcontainers.reuse.enable=true in ~/.testcontainers.propertiesFast local re-runs; not for CI
⚠ Production gotchas
  • 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 @DynamicPropertySource the changed JDBC URL forces a new application context each time - a 1-second test becomes 15 seconds.
  • @DataJpaTest ignores your container: without replace = NONE it 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-drop in 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()) or Wait.forLogMessage.
  • Image versions: pin a tag matching production. postgres:latest will fail your build the day a major version changes.
08
Chapter Eight - Security and Transactions

@WithMockUser, jwt() and the @Transactional Trap

Two features that look simple in tests and behave surprisingly: security and @Transactional.

Testing security

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.

Java - role rules, CSRF and JWT in a slice test
@WebMvcTest(AdminController.class) @Import(SecurityConfig.class) class AdminControllerSecurityTest { @Autowired MockMvc mvc; @MockitoBean AdminService admin; @Test void anonymousGets401() throws Exception { mvc.perform(get("/admin/users")).andExpect(status().isUnauthorized()); } @Test @WithMockUser(roles = "USER") // authenticated but not ADMIN void plainUserGets403() throws Exception { mvc.perform(get("/admin/users")).andExpect(status().isForbidden()); } @Test @WithMockUser(roles = "ADMIN") void adminCanList() throws Exception { mvc.perform(get("/admin/users")).andExpect(status().isOk()); } @Test @WithMockUser(roles = "ADMIN") void postWithoutCsrfIs403ButWithCsrfWorks() throws Exception { mvc.perform(post("/admin/users/5/disable")).andExpect(status().isForbidden()); mvc.perform(post("/admin/users/5/disable").with(csrf())).andExpect(status().isNoContent()); } @Test void jwtScopeMapsToAuthority() throws Exception { // resource-server style endpoint mvc.perform(get("/api/reports") .with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_reports.read")))) .andExpect(status().isOk()); } }
⚠ Production gotchas
  • @WithMockUser is not your UserDetailsService: it fabricates an authentication (default username user, role USER) and skips the real login path. Test the login/authentication flow separately with httpBasic(), formLogin() or a full @SpringBootTest.
  • Method security is not triggered: @PreAuthorize only fires on a proxied bean in a context that has method security enabled. A @WebMvcTest with 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.
@Transactional on tests: convenient and dangerous

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.

Test transaction scope: MockMvc vs a real server
MOCK web env (MockMvc): same thread test method TX begins MockMvc call service joins TX ROLLBACK The service runs inside the test transaction, sees uncommitted test data, and everything rolls back. RANDOM_PORT (real HTTP): two threads test thread TX begins TestRestTemplate Tomcat thread own TX, COMMIT The server thread does not see the test transaction (it is bound to the test thread): data it writes is committed for real and survives the test. @Transactional on the test class gives a false sense of cleanup here.
PitfallWhat you observeFix
No flushConstraint violations, wrong SQL and @PrePersist-dependent behaviour never surface; the test is greenCall flush() (and clear()) before asserting
First-level cache hides lazy loadingEntities are still attached, so a lazy collection loads fine. In production the same code throws LazyInitializationException: could not initialize proxy - no SessionUse @Transactional(propagation = NOT_SUPPORTED) on the test, or em.clear() and assert on a detached entity
Propagation REQUIRES_NEW in the serviceThe inner transaction commits for real, not rolled back; data leaks into later testsClean up explicitly or avoid @Transactional on that test
@TransactionalEventListener(AFTER_COMMIT)Never fires: the test transaction never commitsTest the listener in a non-transactional test or use @Commit and clean up
RANDOM_PORT + @Transactional testServer thread commits independently; test-side rollback does nothingDo not rely on rollback; clean up via @Sql or repository calls
@Transactional on the test hides a missing one in productionService method forgot @Transactional but the test supplied oneTest services without a test transaction when transaction boundaries matter
Java - proving the service owns its transaction boundary
@SpringBootTest @Testcontainers class TransferServiceIT extends AbstractPostgresIT { @Autowired TransferService transfers; @Autowired AccountRepository accounts; @Test // deliberately NOT @Transactional void failedTransferRollsBackBothLegs() { accounts.save(new Account("A", 100)); accounts.save(new Account("B", 0)); assertThatThrownBy(() -> transfers.transfer("A", "B", 40, /* fail after debit */ true)) .isInstanceOf(IllegalStateException.class); assertThat(accounts.balanceOf("A")).isEqualTo(100); // debit rolled back by the SERVICE's transaction assertThat(accounts.balanceOf("B")).isEqualTo(0); } @AfterEach void clean() { accounts.deleteAll(); } }

See Transactions for the production semantics this test is protecting.

09
Chapter Nine - Build It Yourself

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.

ContextCacheSim.java
import java.util.*; public class ContextCacheSim { /** What Spring's MergedContextConfiguration boils down to: everything that changes the context's bean graph. */ record ContextKey(String configClasses, Set<String> profiles, Set<String> properties, Set<String> mockBeans, String webEnv) {} static class FakeContext { static int created = 0; final int id = ++created; final ContextKey key; FakeContext(ContextKey key) { this.key = key; } } static final int MAX_SIZE = 32; // spring.test.context.cache.maxSize default static final Map<ContextKey, FakeContext> cache = new LinkedHashMap<>(16, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry<ContextKey, FakeContext> e) { return size() > MAX_SIZE; } }; static int hits, misses; static FakeContext load(String test, ContextKey key) { FakeContext ctx = cache.get(key); if (ctx != null) { hits++; System.out.printf("%-26s HIT context #%d%n", test, ctx.id); return ctx; } misses++; ctx = new FakeContext(key); cache.put(key, ctx); System.out.printf("%-26s MISS -> boot new context #%d (slow)%n", test, ctx.id); return ctx; } static ContextKey key(String web, Set<String> profiles, Set<String> mocks) { return new ContextKey("App", profiles, Set.of(), mocks, web); } public static void main(String[] args) { Set<String> none = Set.of(); load("OrderServiceIT", key("MOCK", none, none)); load("CustomerServiceIT", key("MOCK", none, none)); // identical config -> reuse load("PaymentIT (@MockBean A)", key("MOCK", none, Set.of("PaymentGateway"))); // extra mock -> new key load("RefundIT (@MockBean A)", key("MOCK", none, Set.of("PaymentGateway"))); // same mock set -> reuse load("ShippingIT (@MockBean B)", key("MOCK", none, Set.of("ShippingClient"))); // different mock -> new key load("ApiIT (RANDOM_PORT)", key("RANDOM_PORT", none, none)); // web env differs -> new key load("AdminIT (@ActiveProfiles)", key("MOCK", Set.of("admin"), none)); // profile differs -> new key load("OrderServiceIT again", key("MOCK", none, none)); // still cached System.out.println("hits=" + hits + " misses=" + misses + " contexts booted=" + FakeContext.created); } }
$ javac ContextCacheSim.java && java ContextCacheSim
OrderServiceIT MISS -> boot new context #1 (slow) CustomerServiceIT HIT context #1 PaymentIT (@MockBean A) MISS -> boot new context #2 (slow) RefundIT (@MockBean A) HIT context #2 ShippingIT (@MockBean B) MISS -> boot new context #3 (slow) ApiIT (RANDOM_PORT) MISS -> boot new context #4 (slow) AdminIT (@ActiveProfiles) MISS -> boot new context #5 (slow) OrderServiceIT again HIT context #1 hits=3 misses=5 contexts booted=5
What the simulation teaches:
  • Eight test classes caused five boots. PaymentIT and RefundIT shared a context because their mock sets were equal - order and class name do not matter, only the key.
  • OrderServiceIT again hit 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 @TestConfiguration so 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.
10
Chapter Ten - Practices

Keeping a Large Suite Fast and Honest

Habits that keep a Spring test suite fast and trustworthy as it grows.

PracticeWhyHow
Name unit tests *Test, integration tests *ITMaven Surefire runs *Test/*Tests; Failsafe runs *IT in verifyFast feedback on mvn test, full run on CI
One shared abstract base for ITsSame annotations and properties = same context keyPut @SpringBootTest, profile, container setup in the base class
Replace sleeps with AwaitilitySleeps are slow when generous and flaky when tightawait().atMost(Duration.ofSeconds(5)).untilAsserted(() -> ...)
Inject ClockDeterministic time@TestConfiguration fixed Clock bean
Disable noise in testsSchedulers and listeners mutate shared contextsspring.task.scheduling.enabled=false (property), stub message consumers
Do not assert on log textBrittleAssert on state, return values and metrics; use OutputCaptureExtension only for startup logging
Test the error pathsProduction incidents live there4xx mapping, constraint violations, timeouts, breaker fallbacks
Keep test data explicitHidden data.sql couples testsCreate data in the test or with @Sql next to it
⚠ Production gotchas
  • Parallel execution with shared contexts: enabling junit.jupiter.execution.parallel.enabled while 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 @SpringBootTest smoke tests that only assert "context loads" gives confidence in nothing. Assert behaviour.
11
Chapter Eleven - Interview Angles

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?
    @SpringBootTest loads the full application context; @WebMvcTest loads 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 calls DispatcherServlet in 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?
    The ContextCache keys on the merged context configuration (config classes, profiles, properties, mock beans, web environment). Any difference - a distinct @MockitoBean set, @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; @MockBean is 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, or clear() 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; @DynamicPropertySource is manual and works for anything you can express as properties.
  • Q: How do you test that a secured endpoint returns 401 vs 403?
    MockMvc with spring-security-test: no auth → 401, @WithMockUser with the wrong role → 403, correct role → 200; POST without csrf() → 403. For JWT resource servers use the jwt() post-processor.
  • Q: Why is constructor injection better for testing?
    Dependencies are visible and mandatory, so a unit test can instantiate the class with new and 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.
12
Chapter Twelve - Summary

Cheat Sheet and Recap

Which test for which job
I want to verify...UseLoads
Business rules, mappers, calculationsPlain JUnit + MockitoNothing
URL mapping, validation, status codes, JSON shape, advice@WebMvcTest + MockMvcWeb layer
Wire format of DTOs@JsonTestJackson
Queries, mappings, constraints@DataJpaTest + TestcontainersJPA
Outbound HTTP client@RestClientTest + MockRestServiceServerClient builders
Security rulesMockMvc + spring-security-testWeb + security
A real user journey through the stack@SpringBootTest(RANDOM_PORT) + TestcontainersEverything
∑ Testing - what to remember
  • 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.
  • @MockitoBean replaces @MockBean from Boot 3.4.
  • Use the real database via Testcontainers (@ServiceConnection) for anything query- or dialect-related; keep replace = 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).
Related pages: