Title: How to Write Better Unit Tests in Java and Spring Boot
Table of contents
- Why unit testing matters
- Unit test vs integration test vs end-to-end
- The testing pyramid and testing strategy
- Fundamental principles of good unit tests
- Testing design for testability (code-level)
- Tools and frameworks (JUnit 5, Mockito, AssertJ, Spring Test, Testcontainers)
- Patterns and idioms (AAA, Given-When-Then, test doubles)
- Common Spring Boot testing slices and examples
- Service unit test with Mockito
- Controller slice test with MockMvc (@WebMvcTest)
- Repository JPA tests (@DataJpaTest)
- Full integration with @SpringBootTest + Testcontainers
- Reactive WebFlux example with WebTestClient and StepVerifier
- Advanced topics
- Testing asynchronous and concurrent code
- Time-dependent code
- Avoiding flaky tests and test isolation
- Mutation testing and measuring test effectiveness
- Characterization testing for legacy code
- CI, performance, and parallelization
- Future directions and best investment areas
- Checklist: Quick rules to write better unit tests
Why unit testing matters Unit tests provide fast, focused feedback on small parts of your application. They:
- Reduce regressions by verifying behavior at a class or method level.
- Serve as living documentation of expected behavior.
- Enable safer refactors when coupled with good design.
- Are quick to run and easy to debug compared to slower integration tests.
But: "unit test" is a loosely used term — clarity on scope and intent is crucial.
Unit test vs integration test vs end-to-end
- Unit tests: test a single class/component in isolation; external dependencies are replaced with test doubles (mocks, fakes).
- Integration tests: exercise multiple components together (e.g., service + repository + DB).
- End-to-end tests: test the entire system, typically through HTTP UI/API. Follow the testing pyramid: lots of unit tests, fewer integration tests, even fewer end-to-end tests.
The testing pyramid and strategy
- Focus on fast, deterministic unit tests for business logic.
- Use slice or integration tests for framework or interaction features (DB, messaging).
- Reserve full end-to-end tests for critical user flows.
- Use Testcontainers or real dependencies when framework-specific integration behavior matters.
Fundamental principles of good unit tests
- Fast: sub-10ms per test ideally; keep suite fast for frequent runs.
- Deterministic: avoid randomness and network/time dependencies.
- Isolated: no shared global state between tests.
- Readable: tests are documentation; name and structure matter.
- Maintainable: avoid brittle internals; test behavior, not implementation.
- Trustworthy: tests should fail only when real regressions happen.
Testing design for testability (code-level) Design your production code to be easy to test:
- Use dependency injection; prefer constructor injection.
- Single Responsibility Principle (small classes).
- Avoid static state and static methods; wrap legacy or static calls in adapters.
- Use interfaces for external resources to enable easy mocking.
- Keep side-effects contained; prefer pure functions for logic where possible.
- Avoid heavy use of final (or use Mockito's inline mocks if needed) — but prefer refactoring rather than force-mocking.
Tools and frameworks
- JUnit 5 (Jupiter): modern testing framework for assertions, lifecycle, extensions.
- Mockito: mocking/stubbing framework.
- AssertJ: fluent, expressive assertions.
- Spring Boot Test: @SpringBootTest, @WebMvcTest, @DataJpaTest, @MockBean.
- MockMvc and WebTestClient: test MVC and WebFlux controllers.
- Testcontainers: real Dockerized services (Postgres, Kafka) for integration tests.
- PIT (PIT Mutation Testing): assess test suite quality.
- Optional: Hamcrest, PowerMock (discouraged), WireMock (HTTP mocking), jqwik for property-based testing.
Patterns and idioms
- AAA (Arrange-Act-Assert) / Given-When-Then: structure tests clearly.
- One logical assertion per test: assert one behavior, but multiple simple asserts are okay to verify state invariants.
- Use descriptive method names, or display names with @DisplayName.
- Use test doubles appropriately:
- Mocks: replace collaborator and verify interactions.
- Stubs: replace collaborator and provide canned responses (no verification).
- Spies: partial mocking of real object (caution: can lead to brittle tests).
- Fakes: simple in-memory implementation (useful for repositories, caches).
- Avoid over-mocking: mock behavior you don't own; prefer real value objects.
Common Spring Boot testing slices and examples
- Service unit test with Mockito and JUnit 5
- Fast pure unit test, mocking repository or external clients.
Example: Service that processes orders
Production code
1// OrderService.java
2public class OrderService {
3 private final OrderRepository repository;
4 private final PaymentClient paymentClient;
5
6 public OrderService(OrderRepository repository, PaymentClient paymentClient) {
7 this.repository = repository;
8 this.paymentClient = paymentClient;
9 }
10
11 public OrderResult placeOrder(OrderRequest request) {
12 if (request.getAmount() <= 0) throw new IllegalArgumentException("Amount must be > 0");
13
14 var payment = paymentClient.charge(request.getPaymentInfo(), request.getAmount());
15 if (!payment.isSuccessful()) return OrderResult.failure("Payment failed");
16
17 var order = new Order(request.getItems(), request.getAmount());
18 var saved = repository.save(order);
19 return OrderResult.success(saved.getId());
20 }
21}Unit test
1// OrderServiceTest.java
2import org.junit.jupiter.api.Test;
3import org.junit.jupiter.api.extension.ExtendWith;
4import org.mockito.*;
5import static org.assertj.core.api.Assertions.*;
6import static org.mockito.Mockito.*;
7
8@ExtendWith(MockitoExtension.class)
9class OrderServiceTest {
10 @Mock OrderRepository repository;
11 @Mock PaymentClient paymentClient;
12 @InjectMocks OrderService service;
13
14 @Test
15 void placeOrder_successfulPayment_savesOrder() {
16 // Arrange
17 var req = new OrderRequest(List.of("item1"), 10.0, new PaymentInfo(...));
18 when(paymentClient.charge(any(), eq(10.0))).thenReturn(PaymentResult.ok());
19 when(repository.save(any(Order.class))).thenAnswer(invocation -> {
20 var o = invocation.getArgument(0, Order.class);
21 o.setId(42L);
22 return o;
23 });
24
25 // Act
26 var result = service.placeOrder(req);
27
28 // Assert
29 assertThat(result.isSuccess()).isTrue();
30 verify(paymentClient).charge(any(), eq(10.0));
31 verify(repository).save(any(Order.class));
32 }
33
34 @Test
35 void placeOrder_negativeAmount_throws() {
36 var req = new OrderRequest(List.of(), -1.0, null);
37 assertThatThrownBy(() -> service.placeOrder(req))
38 .isInstanceOf(IllegalArgumentException.class)
39 .hasMessageContaining("Amount must be");
40 verifyNoInteractions(paymentClient, repository);
41 }
42}Key points:
- MockitoExtension simplifies setup.
- Use ArgumentMatchers and Answer to stub save behavior.
- Verify interactions where behavior is contract.
- Controller slice test with MockMvc (@WebMvcTest)
- Tests controller logic and request/response mapping without starting full app.
Example: REST controller
1// OrderController.java (Spring MVC)
2@RestController
3@RequestMapping("/orders")
4public class OrderController {
5 private final OrderService service;
6 public OrderController(OrderService service) { this.service = service; }
7
8 @PostMapping
9 public ResponseEntity<?> create(@RequestBody OrderRequest request) {
10 var res = service.placeOrder(request);
11 if (res.isSuccess()) return ResponseEntity.created(URI.create("/orders/" + res.getOrderId())).build();
12 return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", res.getMessage()));
13 }
14}Test
1// OrderControllerTest.java
2@WebMvcTest(OrderController.class)
3class OrderControllerTest {
4 @Autowired MockMvc mvc;
5 @MockBean OrderService service;
6
7 @Test
8 void postOrder_successCreated() throws Exception {
9 var reqJson = "{\"items\": [\"item1\"], \"amount\": 10.0 }";
10 when(service.placeOrder(any())).thenReturn(OrderResult.success(100L));
11
12 mvc.perform(post("/orders")
13 .contentType(MediaType.APPLICATION_JSON)
14 .content(reqJson))
15 .andExpect(status().isCreated())
16 .andExpect(header().string("Location", "/orders/100"));
17 }
18}Notes:
- @WebMvcTest loads only MVC layer beans; @MockBean injects mock into Spring context.
- Use MockMvc for fluent checks.
- Repository JPA tests (@DataJpaTest)
- Tests mapping and repository queries against an embedded DB or Testcontainers.
Example: simple repository test
1// OrderRepositoryTest.java
2@DataJpaTest
3@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // with Testcontainers you'd configure separately
4class OrderRepositoryTest {
5 @Autowired TestEntityManager em;
6 @Autowired OrderRepository repo;
7
8 @Test
9 void findByCustomer_returnsOrders() {
10 var order = new Order(...);
11 order.setCustomerId("cust-1");
12 em.persistAndFlush(order);
13
14 var result = repo.findByCustomerId("cust-1");
15 assertThat(result).isNotEmpty().extracting(Order::getCustomerId).contains("cust-1");
16 }
17}Notes:
- @DataJpaTest rolls back transactions by default, isolating tests.
- Use TestEntityManager to control persisted state in tests.
- Full integration with @SpringBootTest + Testcontainers
- Use Testcontainers for realistic DB or messaging backends.
Example: Postgres + Testcontainers
1// build.gradle dependencies (snippet)
2// testImplementation 'org.testcontainers:junit-jupiter:1.17.6'
3// testImplementation 'org.testcontainers:postgresql:1.17.6'
4
5import org.testcontainers.containers.PostgreSQLContainer;
6import org.testcontainers.junit.jupiter.Container;
7import org.testcontainers.junit.jupiter.Testcontainers;
8
9@Testcontainers
10@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
11class IntegrationTest {
12 @Container
13 static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
14 .withDatabaseName("testdb")
15 .withUsername("test")
16 .withPassword("test");
17
18 @DynamicPropertySource
19 static void setProps(DynamicPropertyRegistry registry) {
20 registry.add("spring.datasource.url", postgres::getJdbcUrl);
21 registry.add("spring.datasource.username", postgres::getUsername);
22 registry.add("spring.datasource.password", postgres::getPassword);
23 }
24
25 @Autowired TestRestTemplate rest;
26 @Test
27 void fullFlow() {
28 // Perform real HTTP calls, DB backed by a real Postgres in Docker
29 }
30}Notes:
- DynamicPropertyRegistry injects container properties into Spring.
- Testcontainers are slower; keep number of such tests limited.
- Reactive WebFlux example with WebTestClient and StepVerifier
- For WebFlux controllers and reactive repositories.
Controller test using WebTestClient
1@WebFluxTest(MyReactiveController.class)
2class ReactiveControllerTest {
3 @Autowired WebTestClient webClient;
4 @MockBean ReactiveService service;
5
6 @Test
7 void getReactiveItem() {
8 when(service.findById("1")).thenReturn(Mono.just(new Item("1", "name")));
9 webClient.get().uri("/items/1")
10 .exchange()
11 .expectStatus().isOk()
12 .expectBody()
13 .jsonPath("$.id").isEqualTo("1")
14 .jsonPath("$.name").isEqualTo("name");
15 }
16}Testing reactive pipelines with StepVerifier
1@Test
2void reactivePipelineTest() {
3 Flux<Integer> pipeline = Flux.range(1, 3).map(i -> i * 2);
4 StepVerifier.create(pipeline)
5 .expectNext(2, 4, 6)
6 .verifyComplete();
7}Advanced topics
Testing asynchronous and concurrent code
- Prefer deterministic testing by controlling schedulers or injecting Executor/Clock.
- For CompletableFuture, use thenCompose and join with timeouts:
assertThat(future.get(1, TimeUnit.SECONDS)).isEqualTo(expected);- Use Awaitility for async assertions:
await().atMost(Duration.ofSeconds(2)).untilAsserted(() ->
assertThat(someState).isTrue()
);- For Reactor, use StepVerifier with virtual time (StepVerifier.withVirtualTime) to simulate timed sequences.
Time-dependent code
- Avoid System.currentTimeMillis() or new Date() directly. Inject java.time.Clock and use Clock.fixed in tests:
var clock = Clock.fixed(Instant.parse("2023-01-01T00:00:00Z"), ZoneOffset.UTC);
MyService s = new MyService(clock);Avoiding flaky tests and test isolation
- Never rely on shared external resources or system state. Use @TempDir for temp files:
1@Test
2void writeFile(@TempDir Path tmpDir) {
3 Path file = tmpDir.resolve("out.txt");
4 // ...
5}- Avoid thread sleeps; prefer wait-until conditions (Awaitility).
- Use unique test data (randomized IDs) but deterministic behavior.
- Reset static state between tests or avoid static mutable state entirely.
Mutation testing and measuring test effectiveness
- Use PIT (PIT mutation testing) to see how well tests detect injected faults. High coverage doesn't guarantee good tests; mutation testing helps find gaps.
- Keep mutation runs in CI/periodically given they are slower.
Characterization testing for legacy code
- For hard-to-change legacy classes, write tests that capture current behavior (characterization tests) before refactoring.
- Use seam techniques: introduce interfaces or wrappers to isolate behavior for mocking.
Testing security, messaging, caches, and more
- Security: @WithMockUser for Spring Security tests. For method security, test security expressions separately.
- Messaging: use embedded brokers or Testcontainers. For Kafka, Testcontainers' Kafka is useful.
- Cache: @Cacheable can be tested by asserting interaction counts (method invoked once) or by using CacheManager.
Common pitfalls and how to avoid them
- Overuse of @SpringBootTest: slows tests; use slice tests when possible.
- Over-mocking implementation details: test behavior and observable outcomes.
- Not testing failure paths: ensure you test exceptions, boundary conditions.
- Fragile assertions on internal state: prefer assertions on public API/contract.
Practical tips & conventions
- Test naming: methodName_scenario_expected behavior, or human-readable with @DisplayName.
- Arrange-Act-Assert pattern in tests and clear separation of setup, trigger, and assertions.
- Use AssertJ for expressive assertions:
1assertThat(person)
2 .hasFieldOrPropertyWithValue("name", "Alice")
3 .extracting(Person::getAge)
4 .isGreaterThan(18);- Use @TestInstance(Lifecycle.PER_CLASS) to avoid static @BeforeAll, but be careful with state sharing.
- Use Mockito's @Captor for argument capture:
@Captor ArgumentCaptor<Order> orderCaptor;
verify(repo).save(orderCaptor.capture());
assertThat(orderCaptor.getValue().getAmount()).isEqualTo(10.0);- Prefer verifyNoMoreInteractions to catch unexpected calls, but avoid strict mixing with many mocks (fragile).
Legacy static code: strategies
- Adapter pattern: wrap static calls in an instance interface that you can mock.
- Partial refactor: create seams to inject test doubles.
- Avoid PowerMock unless absolute last resort — it complicates test maintenance.
CI, performance, and parallelization
- Keep unit tests lightweight and parallelizable. JUnit 5 supports parallel execution; ensure thread-safety of shared resources.
- Tag slow tests (e.g., @Tag("integration")) and exclude them from the fast suite.
- Monitor test run times and flakiness in CI. Slow or flaky tests reduce developer trust.
Future directions and best investment areas
- Investment in faster, high-value unit tests, and selective integration tests using Testcontainers.
- Mutation testing to raise test quality.
- Property-based testing for complex invariants (jqwik).
- AI-assisted test generation and maintenance: helpful but needs review and alignment with design intent.
- Contract testing (Pact) in microservices to reduce brittle cross-service integration tests.
Checklist: Quick rules to write better unit tests
- Tests are fast and deterministic.
- Test one logical behavior per test.
- Use DI and small classes to facilitate testing.
- Use mocks for external collaborators; prefer fakes for deterministic components.
- Prefer slice tests over full application context where possible.
- Use Testcontainers for realistic integration tests sparingly.
- Avoid static/shared mutable state.
- Test both happy paths and failure/edge cases.
- Use expressive assertions (AssertJ).
- Track test coverage and mutation score to measure quality.
Practical example repository layout
- src/main/java/...
- controller/
- service/
- repository/
- dto/
- src/test/java/...
- controller/ (WebMvc tests)
- service/ (unit tests with Mockito)
- repository/ (DataJpaTest)
- integration/ (Testcontainers + SpringBootTest)
Conclusion Writing better unit tests in Java and Spring Boot is a mix of good design, the right tooling, and discipline. Favor small, isolated, fast tests for business logic and use slice tests for framework-specific features. For full confidence in integrations, use containerized dependencies sparingly. Measure and improve the quality of your tests with mutation testing and emphasize readability and maintainability. Testability starts in your code design: constructor injection, small classes, and separation of concerns pay off in easier and more valuable tests.
Further reading and tools to explore
- JUnit 5 User Guide
- Mockito documentation and Tips
- Spring Boot Testing Reference
- Testcontainers documentation
- PIT mutation testing
- AssertJ assertion library
Appendix: Frequently used snippets
Mockito with JUnit 5
1@ExtendWith(MockitoExtension.class)
2class XTest {
3 @Mock Dependency dep;
4 @InjectMocks ServiceUnderTest sut;
5 @Captor ArgumentCaptor<Foo> captor;
6}JUnit 5 parameterized test
1@ParameterizedTest
2@ValueSource(strings = {"", " "})
3void invalidInput_throws(String input) {
4 assertThatThrownBy(() -> sut.process(input)).isInstanceOf(IllegalArgumentException.class);
5}Using DynamicPropertySource with Testcontainers
1@Container static GenericContainer<?> kafka = new KafkaContainer("confluentinc/cp-kafka:latest");
2@DynamicPropertySource
3static void props(DynamicPropertyRegistry registry) {
4 registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
5}If you want, I can:
- Provide a runnable small Spring Boot project skeleton with tests demonstrating these patterns.
- Show a before-and-after refactor to make a legacy static-heavy class testable.
- Show configurations for JUnit 5 parallel execution and Testcontainers reuse.