Testing Spring Applications - Complete Notes
A practical and interview-focused guide to test strategy, JUnit, Mockito, Spring test slices, full application tests, web and data testing, PostgreSQL Testcontainers, security, contracts, isolation, performance, and reliable production test suites.
00. The testing mental model
A valuable test gives fast, trustworthy evidence about one important behavior at the lowest cost that can prove that behavior.
A test is not valuable merely because it executes code. It must detect a meaningful regression, explain what broke, run repeatably, and cost less to maintain than the risk it controls. Spring applications have several boundaries: plain Java decisions, bean wiring, proxy advice, HTTP mapping, serialization, security filters, persistence mappings, SQL behavior, and communication with external systems. No single test style proves all of them efficiently.
Testing a Spring application is like inspecting a building. A material sample can be checked quickly in a laboratory, a room can be inspected with its wiring and plumbing, and the complete building can be tested through its entrances. Inspecting only individual bricks misses assembly failures. Rebuilding and inspecting the whole building for every brick is too slow.
Start by naming the risk. If the risk is a discount formula, use a plain unit test. If it is JSON validation or authorization on an endpoint, use a web slice. If it is a PostgreSQL query, test against PostgreSQL. If it is complete bootstrapping and network behavior, use a full context and a real server. The smallest useful context is the smallest environment that contains the behavior under test and the infrastructure needed to observe it.
Do not ask, "Which annotation should I use?" first. Ask, "Which failure must this test detect?" The answer determines whether Spring, a database, a server, or a real external protocol belongs in the test.
01. Testing pyramid and risk coverage
Keep many focused tests near the code, fewer boundary tests around framework integration, and a small number of broad end-to-end tests.
| Layer | What it proves | Typical tools | Main cost |
|---|---|---|---|
| Unit | One class or cohesive domain unit behaves correctly. | JUnit, assertions, Mockito when a boundary must be replaced. | Cannot prove Spring wiring or infrastructure behavior. |
| Component or slice | One application boundary works with focused framework infrastructure. | @WebMvcTest, @DataJpaTest, MockMvc. |
Excluded beans and auto-configuration can differ from the full app. |
| Integration | Several real components collaborate through actual infrastructure. | @SpringBootTest, Testcontainers, real clients. |
More startup time, resources, failure causes, and cleanup. |
| Contract | A producer and consumer agree on an HTTP or message boundary. | Spring Cloud Contract, schema validation, published stubs. | Contract ownership and version discipline. |
| End-to-end | A critical user journey crosses deployed system boundaries. | Real server, browser or HTTP client, production-like services. | Slow, expensive, and harder to diagnose. |
The pyramid is a portfolio shape, not a quota. A persistence-heavy service may need many PostgreSQL integration tests. A mathematical library may need almost none. The useful idea is to avoid proving every small rule through the largest environment. Broad tests should cover a few important seams and journeys, while focused tests explain most failures quickly.
Useful categories of risk
- Business logic: calculations, state transitions, invariants, and policies.
- Framework integration: scanning, binding, validation, proxies, and advice.
- Data integration: mappings, queries, constraints, isolation, and migrations.
- Protocol integration: HTTP status, JSON, headers, authentication, and schemas.
- Operational behavior: startup, health, retries, timeouts, and observability.
02. JUnit Jupiter fundamentals
JUnit supplies test discovery and lifecycle; Spring integrates with it only when a test needs a Spring-managed environment.
class PriceCalculatorTest {
private final PriceCalculator calculator = new PriceCalculator();
@Test
void appliesMemberDiscountAfterQuantityDiscount() {
Money result = calculator.price(
new OrderLine(money("100.00"), 10),
CustomerTier.MEMBER);
assertEquals(money("855.00"), result);
}
@ParameterizedTest
@CsvSource({
"STANDARD, 100.00",
"MEMBER, 95.00",
"PREMIUM, 90.00"
})
void appliesTierRate(CustomerTier tier, String expected) {
assertEquals(money(expected), calculator.priceOne(money("100.00"), tier));
}
}
Use names that describe behavior and outcome. Arrange the relevant state, act once, then assert
observable results. This is a thinking aid, not a requirement to add comments. Prefer one
behavioral reason for failure per test. Multiple assertions are appropriate when they describe one
outcome, especially with assertAll.
Lifecycle and state
JUnit creates a new test instance for each test method by default. @BeforeEach and
@AfterEach surround every test. @BeforeAll and
@AfterAll normally use static methods. @TestInstance(PER_CLASS) changes
that lifecycle, but shared mutable fields can then leak state between methods. Test order should
not be a hidden dependency.
-
Use
assertThrowsand inspect the exception when the message or fields matter. - Use parameterized tests for the same rule over representative inputs and boundaries.
- Use
@Nestedto group scenarios, not to create a complicated class hierarchy. - Use
@TempDirinstead of fixed machine-specific filesystem paths. - Use deterministic clocks, IDs, and random seeds through injected collaborators.
- A timeout guards hangs, but an unrealistically tight timeout creates machine-dependent tests.
@SpringBootTest does not make a unit test better. If the class can be constructed
with new, use plain JUnit. This keeps the feedback fast and also proves that the
design is not unnecessarily coupled to the container.
03. Mockito and interaction boundaries
A mock is a programmable substitute used to control or observe a collaborator, not a copy of its implementation.
@ExtendWith(MockitoExtension.class)
class RegistrationServiceTest {
@Mock UserRepository users;
@Mock DomainEventPublisher events;
@Captor ArgumentCaptor<UserRegistered> event;
@InjectMocks RegistrationService service;
@Test
void savesUserAndPublishesRegistration() {
given(users.existsByEmail("ana@example.com")).willReturn(false);
UserId id = service.register("ana@example.com");
then(users).should().save(argThat(user ->
user.id().equals(id) && user.email().equals("ana@example.com")));
then(events).should().publish(event.capture());
assertEquals(id, event.getValue().userId());
}
}
Stub only behavior the test reaches. Modern strict Mockito sessions report unused or mismatched stubbing, which often reveals a test that no longer describes the implementation path. Verify an interaction only when the interaction is part of the contract, such as publishing an event, charging a gateway, or never invoking a repository after validation fails.
| Test double | Purpose | Example |
|---|---|---|
| Stub | Returns controlled answers. | A gateway returns a declined payment. |
| Mock | Records calls for verification. | Verify an audit event was published once. |
| Fake | Has a lightweight working implementation. | An in-memory repository implementing the port. |
| Spy | Wraps a real object while observing or replacing selected calls. | Rarely observe one method on a legacy collaborator. |
Mocking guidelines
- Mock owned application ports and slow or nondeterministic boundaries.
- Do not mock value objects, collections, DTOs, entities, or the class under test.
- Do not mock JDBC, JPA, or Spring internals to claim that a query or mapping works.
- Avoid deep stubs because they encode an implementation call chain.
- Prefer state assertions when the externally visible result is sufficient.
- Use a real object when it is simple, deterministic, and cheap.
- Resetting a mock usually signals that one test is trying to cover several scenarios.
In a Spring test context, current Spring test support can replace a bean with
@MockitoBean and wrap one with @MockitoSpyBean. Older Spring Boot
versions commonly use @MockBean and @SpyBean. Use the API supported by
the project version, and remember that each distinct override contributes to context configuration
and can reduce context-cache reuse.
04. Spring TestContext internals
The TestContext framework connects JUnit lifecycle events to Spring context loading, dependency injection, transactions, SQL scripts, security setup, and cleanup listeners.
With JUnit Jupiter, SpringExtension integrates Spring with the extension model.
Composed annotations such as @SpringJUnitConfig, @SpringBootTest, and
Boot test slices include the required integration. The framework bootstraps a
TestContextManager, selects a bootstrapper, builds a merged context configuration,
asks a context loader to create or find an ApplicationContext, and invokes registered
TestExecutionListener callbacks around the test.
- JUnit discovers the test class and its extensions.
- Spring determines configuration classes, locations, profiles, properties, and customizers.
- The context cache is checked using the resulting configuration key.
- On a miss, Spring creates and refreshes the application context.
- Listeners inject the test instance and prepare transactions or security context.
- JUnit lifecycle and test methods execute.
- Listeners perform rollback, cleanup, and after-test work.
- The context usually stays cached for another compatible test class.
Test instances are not Spring beans by default, but dependency injection listeners can inject
fields, constructors, or methods from the test context. Constructor injection is clear, but a test
constructor fully autowired by Spring interacts with JUnit lifecycle choices, especially when
@DirtiesContext closes a context between methods. Keep the arrangement simple.
05. Context caching and suite speed
A Spring context is cached by its effective configuration, not only by the test annotation name.
The cache key includes configuration locations and classes, initializers, loader, parent context, active profiles, test property sources, resource base path, and context customizers. Dynamic properties and bean overrides are customizers, so changing them can create a different key. Two test classes reuse one context only when their keys match.
The cache is static and therefore belongs to one JVM process. Forking a new process discards it.
The default maximum is 32 contexts and least recently used entries are evicted when full. Set
org.springframework.test.context.cache logging to DEBUG to inspect
hit, miss, and size statistics.
- Standardize a small number of test configurations and profiles.
- Avoid unique inline properties for every test class.
- Group tests that need the same bean overrides.
- Do not fork one JVM per test class when context reuse matters.
- Use
@DirtiesContextonly when a test truly corrupts context state. - Reset mutable application data instead of rebuilding the entire context.
- Measure cache statistics before increasing the cache maximum.
@DirtiesContext removes a context before or after the selected class or method. It is
correct when a test mutates bean definitions, closes the context, or irreversibly changes a
singleton. It is an expensive cleanup shortcut for ordinary database rows, mocks, or caches.
06. Choosing the smallest useful context
Add infrastructure only when the assertion would be invalid without it.
| Question | Smallest likely test |
|---|---|
| Does this domain rule handle boundaries? | Plain JUnit with real domain objects. |
| Does this service coordinate its ports? | Plain JUnit with small mocks or fakes. |
| Does Spring construct this custom bean graph? | @SpringJUnitConfig with explicit configuration. |
| Does the MVC contract bind, validate, secure, and serialize? | @WebMvcTest with MockMvc. |
| Does a JPA mapping or repository query work? | @DataJpaTest with PostgreSQL. |
| Does auto-configuration and the complete bean graph start? | @SpringBootTest(webEnvironment = NONE). |
| Does real HTTP transport behavior work? | @SpringBootTest(RANDOM_PORT) and a real client. |
A slice should not be expanded one missing bean at a time until it silently becomes a full
context. If the assertion genuinely spans the whole application, say so with
@SpringBootTest. If only one adapter is relevant, import that adapter and its focused
configuration into the slice.
07. Spring Boot slice tests
A slice uses type filters and selected auto-configuration to test one technical layer.
Common slices include @WebMvcTest, @WebFluxTest,
@DataJpaTest, @JdbcTest, @DataJdbcTest,
@JsonTest, @RestClientTest, and @GraphQlTest. Exact modules
and package names depend on the Spring Boot generation, so follow the dependency management and
reference guide for the project version.
- A slice excludes ordinary application components outside its focus.
- Use
@Importfor a small required user configuration or component. - Use a test bean override for a collaborator intentionally outside the slice.
- Do not assume
@ConfigurationPropertiesclasses are scanned by every slice. - Main-application component scan customization can interfere with slice filters.
- Test the full context separately so slices are not the only wiring evidence.
A web slice can prove mapping, conversion, validation, exception handling, filters, and security. It does not prove that the mocked service has a real bean, that the database is reachable, or that the whole application starts.
08. Full contexts with @SpringBootTest
Use @SpringBootTest when the test needs Spring Boot startup, auto-configuration, and
the complete application bean graph.
Unlike a basic @ContextConfiguration, it creates the context through
SpringApplication, so Boot external properties, environment preparation, and other
Boot behavior participate. It searches upward from the test package for the primary
@SpringBootConfiguration unless explicit sources are supplied.
| Web environment | Behavior | Use |
|---|---|---|
MOCK |
Default mock web environment; no embedded server. | Full context with MockMvc or mock WebTestClient support. |
RANDOM_PORT |
Starts a real server on an allocated port. | Real HTTP stack without fixed-port collisions. |
DEFINED_PORT |
Starts a server on the configured port. | Rare external-system test that requires a known port. |
NONE |
Loads a non-web Boot application context. | Batch, messaging, startup, or service integration. |
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class OrderApiIntegrationTest {
@Autowired WebTestClient client;
@Test
void createsAndReadsOrder() {
String location = client.post()
.uri("/orders")
.bodyValue(new CreateOrderRequest("SKU-42", 2))
.exchange()
.expectStatus().isCreated()
.expectHeader().valueMatches("Location", "/orders/.+")
.returnResult(Void.class)
.getResponseHeaders()
.getLocation()
.toString();
client.get().uri(location)
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.sku").isEqualTo("SKU-42")
.jsonPath("$.quantity").isEqualTo(2);
}
}
Do not use DEFINED_PORT for normal parallel tests. With a real server, the test
client and request handler run on separate threads. A transaction started around the test method
does not wrap the server transaction, so automatic test rollback does not clean server writes. Use
explicit cleanup, isolated schemas, or API-level deletion.
09. Testing Spring MVC with MockMvc
MockMvc sends mock Servlet requests through the DispatcherServlet without opening a
network socket.
It can test request mappings, argument resolution, data binding, validation, message conversion, controller advice, filters, interceptors, and response serialization. It does not prove servlet container configuration, TLS, compression, real network timeouts, or client-server thread behavior.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockitoBean OrderService service;
@Test
void rejectsInvalidQuantityAsProblemDetail() throws Exception {
mvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"sku":"SKU-42","quantity":0}
"""))
.andExpect(status().isBadRequest())
.andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.title").value("Validation failed"))
.andExpect(jsonPath("$.errors.quantity").exists());
then(service).shouldHaveNoInteractions();
}
}
Prefer assertions on public HTTP behavior: status, media type, headers, JSON shape, validation, and security. Avoid asserting a private controller method, an exact internal exception type, or the entire JSON document when only two fields are contractual. Add negative cases for missing content type, malformed JSON, unknown IDs, invalid enum values, oversized input, and unsupported methods.
Context setup versus standalone setup
webAppContextSetup uses the Spring MVC configuration in a
WebApplicationContext. standaloneSetup registers selected controllers
and supporting objects programmatically. Standalone setup is focused but can drift from actual
conversion, advice, security, and configuration. A Boot @WebMvcTest usually provides
a useful middle ground.
10. WebTestClient and reactive web tests
WebTestClient offers a fluent non-blocking client API that can bind to mock handlers or a live server.
For WebFlux it can bind to a controller, router function, application context, or server. It can also test Spring MVC through MockMvc adapters. The same assertion style can therefore begin with mock handling and later connect over HTTP. It is particularly useful for streaming responses, asynchronous behavior, and reactive backpressure scenarios.
@WebFluxTest(InventoryController.class)
class InventoryControllerTest {
@Autowired WebTestClient client;
@MockitoBean InventoryQuery query;
@Test
void streamsAvailableItems() {
given(query.available()).willReturn(
Flux.just(new ItemView("A"), new ItemView("B")));
client.get().uri("/inventory/stream")
.exchange()
.expectStatus().isOk()
.returnResult(ItemView.class)
.getResponseBody()
.as(StepVerifier::create)
.expectNextMatches(item -> item.sku().equals("A"))
.expectNextMatches(item -> item.sku().equals("B"))
.verifyComplete();
}
}
Avoid blocking a reactive pipeline in production code merely to simplify a test. Use
StepVerifier for publisher signals, errors, cancellation, and virtual-time scenarios.
A mock binding still does not prove the behavior of the actual network server.
11. Repository and persistence tests
A repository test should prove mappings, generated SQL behavior, queries, constraints, and database-specific semantics.
@DataJpaTest scans entities and Spring Data JPA repositories, supplies focused JPA
auto-configuration, and normally runs each test in a rollback-only transaction. It may replace the
configured database with an embedded one when available. Use
@AutoConfigureTestDatabase(replace = Replace.NONE) when the real configured
PostgreSQL data source must remain.
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
class OrderRepositoryTest {
@Autowired OrderRepository orders;
@Autowired TestEntityManager entities;
@Test
void findsPendingOrdersOldestFirst() {
entities.persist(order(PENDING, instant("2026-07-29T10:00:00Z")));
entities.persist(order(PAID, instant("2026-07-29T09:00:00Z")));
entities.persist(order(PENDING, instant("2026-07-29T08:00:00Z")));
entities.flush();
entities.clear();
List<Order> result = orders.findTop100ByStatusOrderByCreatedAt(PENDING);
assertAll(
() -> assertEquals(2, result.size()),
() -> assertEquals(
instant("2026-07-29T08:00:00Z"),
result.getFirst().createdAt()));
}
}
- Flush before expecting database constraints or SQL errors.
- Clear the persistence context to prove data is read from the database.
- Test owning sides, cascades, orphan removal, converters, and optimistic versions.
- Assert ordering explicitly when the query contract promises ordering.
- Test empty, null, boundary, duplicate, and large-page cases.
- Inspect query counts for known N+1 risks, not every harmless implementation detail.
- Run migration scripts in integration tests rather than relying only on ORM schema creation.
12. Transaction behavior in tests
A test-managed transaction is started by the Spring TestContext framework around the test; it is related to, but distinct from, application-managed transaction boundaries.
A test annotated with Spring @Transactional normally starts a transaction before the
test and rolls it back afterward. Application methods using REQUIRED usually join it.
This is convenient isolation, but it changes what the test can observe. Lazy loading may succeed
because a persistence context remains open, and SQL deferred until flush or commit may never be
exercised.
@SpringBootTest
@Transactional
class OutboxCommitTest {
@Autowired CheckoutService checkout;
@Autowired JdbcTemplate jdbc;
@Test
void orderAndOutboxBecomeVisibleTogether() {
UUID orderId = checkout.place(command());
TestTransaction.flagForCommit();
TestTransaction.end();
assertEquals(1, count("orders", orderId));
assertEquals(1, count("outbox", orderId));
TestTransaction.start();
}
}
A real HTTP server handles requests on another thread. Work using
REQUIRES_NEW commits independently. Asynchronous work uses another execution
context. Preemptive timeout mechanisms may also run code on another thread. Rollback of the test
transaction cannot undo those writes.
Use @Commit or @Rollback(false) only when commit is the behavior under
test and provide deterministic cleanup. Use @BeforeTransaction and
@AfterTransaction for assertions outside the transaction. Never mistake automatic
rollback for proof that production cleanup or transaction boundaries are correct.
13. Testcontainers with PostgreSQL
Use the same database engine as production when SQL dialect, types, constraints, locking, migrations, or query plans matter.
H2 is useful for tests designed around H2. It is not PostgreSQL. Differences include data types, JSON operators, arrays, collations, generated identifiers, case behavior, SQL grammar, constraint timing, locking, transaction isolation, and extensions. Compatibility modes reduce some syntax differences but do not turn one engine into another.
@Testcontainers
@SpringBootTest
class CheckoutIntegrationTest {
@Container
static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:18-alpine")
.withDatabaseName("orders")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void databaseProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}
The JUnit extension starts a static @Container once for the test class and an
instance container for each test method. Per-method containers maximize environmental isolation
but are much slower. Static containers plus database cleanup are usually a better balance. Pin an
explicit compatible image tag so an upstream image change does not silently alter CI.
Service connections and dynamic properties
Supported Spring Boot versions can use @ServiceConnection so container connection
details override normal properties automatically. @DynamicPropertySource remains a
flexible option and registers lazy suppliers in the test Environment. A dynamic
property method is static and its values help determine the test context configuration.
@TestConfiguration(proxyBeanMethods = false)
class Containers {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgres() {
return new PostgreSQLContainer<>("postgres:18-alpine");
}
}
@SpringBootTest
@Import(Containers.class)
class OrderApplicationTest {
// The container connection details configure the DataSource.
}
When an application context must live as long as a container, align their lifecycles. A base-class singleton container that is stopped after one subclass while a cached context still points to it causes confusing connection failures. Boot-managed container beans or imported container declarations can make that ownership clearer.
Container production practices
- Run the real migration tool against the fresh container.
- Wait for readiness through supported container mechanisms, not fixed sleeps.
- Keep container logs on failure but avoid printing passwords.
- Do not enable experimental reusable containers in CI as an isolation strategy.
- Cache downloaded images in CI infrastructure where policy allows.
- Be deliberate with parallel execution because shared database state can collide.
- Use unique database names or schemas when workers genuinely run concurrently.
14. Fixture design and data builders
A fixture should make the important differences obvious and hide only irrelevant construction noise.
final class OrderBuilder {
private CustomerId customerId = new CustomerId("customer-1");
private List<OrderLine> lines = List.of(line("SKU-1", 1));
private Instant createdAt = Instant.parse("2026-07-29T10:00:00Z");
OrderBuilder withoutLines() {
this.lines = List.of();
return this;
}
OrderBuilder createdAt(Instant value) {
this.createdAt = value;
return this;
}
Order build() {
return Order.restore(new OrderId("order-1"), customerId, lines, createdAt);
}
}
Builders should return valid objects by default. Each test changes only fields relevant to its scenario. Avoid a universal fixture that creates dozens of unrelated rows. Hidden fixture graphs make failures mysterious and slow cleanup. Prefer small domain factories, builders, SQL scripts for precise relational state, or API setup when the setup behavior itself matters.
- Use fixed instants through an injected
Clock. - Generate unique values intentionally when a unique constraint requires them.
- Do not depend on auto-generated IDs having a particular sequence value.
- Keep expected values independent from the production algorithm.
- Store large JSON examples as readable test resources when inline text becomes noisy.
- Use factories per bounded context rather than one global test utility.
15. Test isolation and deterministic cleanup
Every test must produce the same result alone, after another test, in a different order, and on another machine.
Isolation means controlling database rows, singleton state, caches, message queues, files, ports, locale, time zone, environment variables, and background work. Rollback is one technique, not a universal solution.
| Technique | Strength | Risk |
|---|---|---|
| Rollback per test | Fast database reset in one thread and transaction. | Hides commit behavior and misses writes on other threads. |
| Targeted delete or truncate | Works after real commits. | Foreign keys, sequence state, and parallel workers need care. |
| Fresh schema or database | Strong database isolation. | Migration and creation cost. |
| Unique tenant or correlation key | Allows concurrent data in one service. | Queries and cleanup must always preserve the boundary. |
| Fresh container | Strong process and service isolation. | Highest startup and resource cost. |
Parallel tests are safe only after shared resources are identified. JUnit resource locks can serialize known global state. Separate schemas, ports, object keys, and broker destinations permit real parallelism. Never fix a race by adding arbitrary sleeps. Await an observable condition with a bounded timeout and useful failure diagnostics.
16. Spring Security tests
Security tests must cover both allowed and denied paths through the actual security boundary.
Spring Security test support can populate the SecurityContext, apply users to MockMvc
requests, create JWT authentication, and add CSRF tokens. With MockMvc, ensure the Spring Security
filter chain is applied. A direct controller unit test cannot prove URL authorization, CSRF
behavior, filter ordering, authentication entry points, or access-denied handling.
@WebMvcTest(AdminOrderController.class)
class AdminOrderControllerSecurityTest {
@Autowired MockMvc mvc;
@MockitoBean OrderAdminService service;
@Test
void anonymousUserIsRejected() throws Exception {
mvc.perform(delete("/admin/orders/{id}", "order-1")
.with(csrf()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "order:delete")
void authorizedUserCanDelete() throws Exception {
mvc.perform(delete("/admin/orders/{id}", "order-1")
.with(csrf()))
.andExpect(status().isNoContent());
then(service).should().delete(new OrderId("order-1"));
}
@Test
@WithMockUser(authorities = "order:delete")
void stateChangingRequestWithoutCsrfIsRejected() throws Exception {
mvc.perform(delete("/admin/orders/{id}", "order-1"))
.andExpect(status().isForbidden());
}
}
-
Distinguish unauthenticated
401from authenticated-but-forbidden403. - Test exact authorities, not only broad administrator roles.
- Test object ownership and method security separately from URL rules.
- Use
@WithUserDetailswhen code expects a custom principal. - For resource servers, test valid, missing, expired, malformed, and insufficient-scope tokens.
- Test CORS and CSRF according to the actual browser or API architecture.
- Never weaken the production filter chain merely to make a test convenient.
17. External clients and failure simulation
Test client code against a protocol boundary that can reproduce success, error, delay, and malformed-response behavior.
A mocked Java interface proves service decisions but not URL construction, headers, serialization,
connection behavior, or status mapping. A mock HTTP server such as WireMock or OkHttp
MockWebServer exercises the configured production client over HTTP. Spring also provides focused
support for clients, including MockRestServiceServer for selected synchronous clients
and Boot client test slices.
- Verify method, path, query encoding, headers, body, and content type.
- Simulate
4xx,5xx, timeout, disconnect, and malformed JSON. - Prove which failures retry and which fail immediately.
- Use a stable idempotency key across retries.
- Do not assert an exact retry delay in wall-clock milliseconds.
- Use virtual time where the retry library supports it.
- Keep a few sandbox-provider tests when a third-party contract is especially risky.
18. Contract tests
A contract test verifies the shared assumptions at a service boundary without requiring every service to be deployed together.
A producer contract states which requests the provider accepts and which responses or messages it produces. A consumer test uses a verified stub to prove its client expectations. The provider verifies the same contract against its implementation. Spring Cloud Contract supports consumer-driven and producer-driven HTTP and messaging contracts, generated tests, and published stubs.
A schema alone may say that a field is a string but not that a missing order returns
404. A provider test alone may cover responses no consumer uses. A contract records
meaningful examples and matching rules at the shared boundary.
- Keep business algorithms out of contracts; capture boundary examples and rules.
- Version contracts with provider and consumer ownership clearly defined.
- Use flexible matchers for IDs and timestamps, with concrete generated examples.
- Cover backward-compatible optional additions and breaking removals intentionally.
- Publish verified stubs only after provider verification succeeds.
- Do not call a mocked collaborator inside a provider test and claim the full service works.
- Retain a small number of end-to-end tests for deployment, routing, and infrastructure risks.
19. Avoiding brittle tests
A brittle test fails after a harmless refactor or passes while an important public behavior is broken.
| Brittle pattern | Better focus |
|---|---|
| Verify every internal method call in order. | Assert the observable state or essential boundary interaction. |
| Snapshot a huge response for one field rule. | Assert contractual fields and maintain a separate schema or contract. |
| Use production code to calculate the expected answer. | Write a simple independent expected value. |
| Sleep until asynchronous work "should" finish. | Await an observable condition with a bounded deadline. |
| Depend on current time, locale, or random values. | Inject controlled inputs and set explicit locale or zone. |
| Share mutable fixtures between test methods. | Create or restore state for each test. |
| Use a full context for a pure calculation. | Construct the object directly. |
Do not expose private methods only for tests. Test them through the public behavior, or extract a cohesive rule into its own type if it deserves direct tests. A failing test should name the violated behavior and show relevant expected and actual values. Diagnostic quality is part of suite design.
20. Test suite performance
A fast suite comes from deliberate boundaries, context reuse, and controlled infrastructure, not from deleting valuable assertions.
- Measure test duration, context cache hits, and slow classes in CI.
- Move pure rules out of Spring contexts.
- Replace repeated full contexts with appropriate slices.
- Unify equivalent profiles, properties, and bean overrides to reuse cache keys.
- Share expensive containers at a safe lifecycle and clean their data.
- Parallelize only tests whose resources are isolated.
- Avoid fixed sleeps, repeated migrations per method, and unnecessary context dirtiness.
- Separate very slow environmental checks while keeping them mandatory at the right CI stage.
Watch tail latency, not only average duration. One intermittent 90-second timeout damages feedback more than many stable two-second tests. Record retry counts and quarantine only as a short, owned remediation step. Automatically rerunning failures without investigation hides nondeterminism.
Parallel execution
JUnit can run tests concurrently, but Spring singleton beans, static variables, database tables, ports, and container lifecycles may be shared. Mark unsafe resources or isolate them before enabling parallelism. The Testcontainers JUnit extension documents its lifecycle support around sequential execution, so validate any concurrent container strategy instead of assuming it is safe.
21. Production-quality test strategy
Treat the test suite as production code with owners, architecture, observability, and a failure budget.
- Define which risks each test layer owns and avoid accidental gaps.
- Require all tests to run from a clean checkout with documented prerequisites.
- Pin Java, database images, locale, and time zone where reproducibility matters.
- Run migrations against supported PostgreSQL versions before release.
- Test rollback, timeout, retry, duplicate delivery, and partial failure paths.
- Keep secrets out of fixtures, logs, snapshots, and container configuration.
- Collect useful diagnostics on failure: request, response, SQL state, and container logs.
- Fail clearly when Docker or required infrastructure is unavailable.
- Review slow and flaky tests as defects, not normal background noise.
- Delete duplicated tests only after confirming the same risk is covered elsewhere.
A practical CI sequence
- Compile and run static checks.
- Run plain unit tests and focused slices.
- Run PostgreSQL and external-protocol integration tests.
- Verify producer and consumer contracts.
- Build the deployable artifact and start it in a production-like configuration.
- Run a small set of critical end-to-end and operational smoke tests.
Ordering gives early, precise feedback without making later integration evidence optional. Developers should be able to run the important layers locally. CI-specific tests should be rare and justified by infrastructure that cannot safely exist on a workstation.
22. Common mistakes
- Full context everywhere: slow tests hide whether the design is unit-testable.
- Mocks everywhere: tests repeat implementation calls but prove no integration.
- H2 as PostgreSQL: database-specific failures reach production.
- No flush in JPA tests: deferred SQL and constraints remain untested.
- Rollback assumption: real-server and asynchronous writes leak between tests.
- DirtiesContext cleanup: the suite repeatedly pays full startup cost.
- Random sleeps: tests are both slow and nondeterministic.
- Happy path only: validation, authorization, conflict, and timeout behavior drift.
- Disabled security filters: controller tests prove an impossible production path.
- Shared mutable fixture: ordering changes the result.
- Over-asserted JSON: harmless response additions break unrelated tests.
- Test-only production branches: production code behaves differently under test.
- Mocking framework internals: a simulated mapping or transaction proves nothing.
- Ignored flakiness: genuine regressions become indistinguishable from noise.
23. Worked test strategy for an order API
Consider an endpoint that validates an order, reserves PostgreSQL stock, writes an outbox event, and requires a scoped JWT.
- Unit-test quantity, price, and order state-transition rules with plain JUnit.
- Unit-test the application service with repository and event-store ports replaced by fakes.
-
Use
@WebMvcTestfor JSON binding, validationProblemDetail, status, location header, authentication, authority, and malformed requests. -
Use
@DataJpaTestwith PostgreSQL Testcontainers for repository queries, mappings, uniqueness, optimistic locking, and generated SQL behavior. - Use a full-context test against PostgreSQL to prove transactional order, stock, and outbox behavior. Force a flush and verify rollback on failure.
-
Use
RANDOM_PORTfor a few real HTTP journeys and clean committed data explicitly. - Publish an HTTP contract for consumers and an event contract for the outbox payload.
- Test duplicate request IDs, concurrent stock reservation, timeout, and retry safety.
- Start the packaged artifact in CI and verify health plus one authenticated smoke request.
This portfolio gives rapid feedback for domain rules, precise boundary failures for web and data problems, and a small amount of full-stack confidence. It avoids trying to prove every permutation through a running server.
24. Practice exercises
- Classify twenty existing tests by the risk they prove and remove unnecessary contexts.
- Write a parameterized JUnit test for validation boundaries without loading Spring.
- Replace an over-mocked unit test with real value objects and one boundary mock.
-
Build a
@WebMvcTestcovering valid JSON, malformed JSON, and validation errors. - Add anonymous, wrong-authority, correct-authority, and missing-CSRF security cases.
- Write a
@DataJpaTestthat flushes and proves a PostgreSQL unique constraint. - Compare H2 and PostgreSQL behavior for one JSONB or case-sensitive query.
- Use
TestTransactionto prove a commit-time database constraint. - Measure Spring context cache hits, then consolidate unnecessary unique configurations.
- Replace a fixed sleep in an asynchronous test with condition-based waiting.
- Design cleanup for real-server tests that commit on another thread.
- Define and verify one consumer-provider contract for an error response.
- Run two inventory reservations concurrently and assert the durable invariant.
- Create a CI test matrix and state which risk each stage controls.
25. Interview questions and answers
What is the testing pyramid?
It is a portfolio model with many fast focused tests, fewer integration tests, and a small number of broad end-to-end tests. Its purpose is fast diagnosis plus enough boundary confidence, not a fixed numerical ratio.
What is the difference between a unit test and a Spring integration test?
A unit test constructs the subject directly and tests a small behavioral unit without the Spring container. A Spring integration test loads an application context to verify container-managed behavior such as configuration, injection, proxies, or framework adapters.
Why should you choose the smallest useful Spring context?
Smaller contexts start faster, fail for fewer unrelated reasons, and communicate the boundary under test. The context must still include every infrastructure behavior required for the assertion to be valid.
How does Spring context caching work?
The TestContext framework creates a key from configuration classes and locations, profiles, properties, initializers, loader, parent, resource path, and customizers such as dynamic properties and bean overrides. Compatible tests in one JVM reuse the cached context.
Why can @DirtiesContext make a suite slow?
It removes a context from the cache, so a later compatible test must rebuild and refresh it. Use it only for irreversible context mutation, not as ordinary data cleanup.
What does @SpringBootTest add?
It creates the application context through SpringApplication, including Boot startup
and auto-configuration behavior. It can use a mock web environment, a real server, or no web
environment.
What is a Spring Boot test slice?
It is a focused context that includes selected component types and auto-configuration for one technical layer. Examples are MVC, JPA, JDBC, JSON, and REST client slices.
What does MockMvc test?
It sends mock Servlet requests through Spring MVC request handling without a real server. It can prove mapping, binding, conversion, validation, advice, filters, security, and serialization, but not real network or servlet-container behavior.
When would you prefer WebTestClient?
It is natural for WebFlux, streaming, and reactive assertions. It can bind to mock handlers or a live server and can also use a MockMvc adapter for Spring MVC.
Why should repository tests use PostgreSQL when production uses PostgreSQL?
Queries, types, operators, constraints, transaction behavior, locking, and extensions differ between engines. An embedded substitute cannot prove PostgreSQL-specific behavior.
Why do JPA tests call flush and clear?
Flush forces pending SQL so database errors become observable. Clear removes managed entities so a subsequent read proves mapping and query behavior rather than returning the same in-memory object.
How are test-managed and application transactions different?
The Spring TestContext framework owns the transaction around the test method. Application proxies own service transactions. They may participate in the same physical transaction, but propagation, threads, and real server requests can make them separate.
Why does @Transactional not clean up a RANDOM_PORT test?
The test client and real server execute on different threads and therefore in different transactions. Rolling back the test thread does not roll back a transaction already committed by the server.
How does @DynamicPropertySource work with Testcontainers?
A static method registers property suppliers in the test environment. It is useful for dynamic JDBC URLs, usernames, passwords, ports, and other values known after container startup.
Should a Testcontainers database be started per method?
Usually not. It gives strong isolation but high cost. A static per-class or suite-aligned container with reliable database cleanup is commonly faster. Choose based on actual isolation risk.
What should be mocked?
Mock an owned boundary when the test needs controlled answers or essential interaction verification. Use real simple objects. Do not mock framework internals to claim that integration behavior works.
How do you test Spring Security?
Apply the real security configuration at the relevant boundary, supply test authentication with Spring Security test support, and cover anonymous, forbidden, allowed, CSRF, token, and object-ownership cases.
What is contract testing?
It verifies shared producer-consumer expectations for requests, responses, or messages. Providers verify contracts against implementations, and consumers use verified stubs without deploying the complete distributed system.
What makes a test brittle?
It depends on incidental implementation details, timing, ordering, global state, or oversized snapshots rather than stable public behavior. It fails on harmless refactoring or misses real regressions.
How do you speed up a Spring test suite safely?
Measure first, move pure logic to unit tests, choose slices, standardize context configurations, reuse containers safely, avoid context dirtiness and sleeps, and parallelize only isolated resources.
26. Concise cheat sheet
- Name the risk before choosing a test annotation.
- Use plain JUnit for pure Java behavior.
- Mock boundaries, not simple values or framework internals.
- Use slices for focused framework adapters.
- Use
@SpringBootTestfor full Boot startup and bean-graph behavior. - MockMvc exercises Spring MVC without a network server.
- WebTestClient works with WebFlux, mock binding, and real servers.
- Context reuse requires the same effective configuration key in one JVM.
- Dynamic properties and bean overrides can create distinct context keys.
- Use
@DirtiesContextonly for real context corruption. - Flush and clear when testing JPA mappings and constraints.
- Automatic rollback can hide commit and cross-thread behavior.
- Real-server writes need explicit cleanup.
- Use PostgreSQL to test PostgreSQL semantics.
- Align container and cached-context lifecycles.
- Security tests need denied and allowed paths through real security rules.
- Contracts verify shared protocol assumptions, not complete user journeys.
- Control time, randomness, locale, ports, files, and shared state.
- Replace sleeps with bounded condition-based waiting.
- Measure slow and flaky tests and treat both as engineering defects.
27. Official references
- Spring Framework testing reference
- Spring TestContext Framework
- Spring test context caching
- Spring test-managed transactions
- Spring MockMvc
- Spring WebTestClient
- Testing Spring Boot applications
- Spring Boot Testcontainers support
- JUnit user guide
- Mockito API documentation
- Testcontainers JUnit Jupiter integration
- Testcontainers PostgreSQL module
- Spring Security MockMvc integration
- Testing Spring method security
- Spring Cloud Contract reference
- PostgreSQL concurrency control