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
1) Service unit test with Mockito and JUnit 5
- Fast pure unit test, mocking repository or external clients.
Example: Service that processes orders
Production code ```java // OrderService.java public class OrderService { private final OrderRepository repository; private final PaymentClient paymentClient;
public OrderService(OrderRepository repository, PaymentClient paymentClient) { this.repository = repository; this.paymentClient = paymentClient; }
public OrderResult placeOrder(OrderRequest request) { if (request.getAmount() 0");
var payment = paymentClient.charge(request.getPaymentInfo(), request.getAmount()); if (!payment.isSuccessful()) return OrderResult.failure("Payment failed");
var order = new Order(request.getItems(), request.getAmount()); var saved = repository.save(order); return OrderResult.success(saved.getId()); } } ```
Unit test ```java // OrderServiceTest.java import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.; import static org.assertj.core.api.Assertions.; import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock OrderRepository repository; @Mock PaymentClient paymentClient; @InjectMocks OrderService service;
@Test void placeOrdersuccessfulPaymentsavesOrder() { // Arrange var req = new OrderRequest(List.of("item1"), 10.0, new PaymentInfo(...)); when(paymentClient.charge(any(), eq(10.0))).thenReturn(PaymentResult.ok()); when(repository.save(any(Order.class))).thenAnswer(invocation -> { var o = invocation.getArgument(0, Order.class); o.setId(42L); return o; });
// Act var result = service.placeOrder(req);
// Assert assertThat(result.isSuccess()).isTrue(); verify(paymentClient).charge(any(), eq(10.0)); verify(repository).save(any(Order.class)); }
@Test void placeOrdernegativeAmountthrows() { var req = new OrderRequest(List.of(), -1.0, null); assertThatThrownBy(() -> service.placeOrder(req)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Amount must be"); verifyNoInteractions(paymentClient, repository); } } ```
Key points:
- MockitoExtension simplifies setup.
- Use ArgumentMatchers and Answer to stub save behavior.
- Verify interactions where behavior is contract.
2) Controller slice test with MockMvc (@WebMvcTest)
- Tests controller logic and request/response mapping without starting full app.
Example: REST controller ```java // OrderController.java (Spring MVC) @RestController @RequestMapping("/orders") public class OrderController { private final OrderService service; public OrderController(OrderService service) { this.service = service; }
@PostMapping public ResponseEntity create(@RequestBody OrderRequest request) { var res = service.placeOrder(request); if (res.isSuccess()) return ResponseEntity.created(URI.create("/orders/" + res.getOrderId())).build(); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", res.getMessage())); } } ```
Test ```java // OrderControllerTest.java @WebMvcTest(OrderController.class) class OrderControllerTest { @Autowired MockMvc mvc; @MockBean OrderService service;
@Test void postOrder_successCreated() throws Exception { var reqJson = "{\"items\": [\"item1\"], \"amount\": 10.0 }"; when(service.placeOrder(any())).thenReturn(OrderResult.success(100L));
mvc.perform(post("/orders") .contentType(MediaType.APPLICATION_JSON) .content(reqJson)) .andExpect(status().isCreated()) .andExpect(header().string("Location", "/orders/100")); } } ```
Notes:
- @WebMvcTest loads only MVC layer beans; @MockBean injects mock into Spring context.
- Use MockMvc for fluent checks.
3) Repository JPA tests (@DataJpaTest)
- Tests mapping and repository queries against an embedded DB or Testcontainers.
Example: simple repository test ```java // OrderRepositoryTest.java @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // with Testcontainers you'd configure separately class OrderRepositoryTest { @Autowired TestEntityManager em; @Autowired OrderRepository repo;
@Test void findByCustomer_returnsOrders() { var order = new Order(...); order.setCustomerId("cust-1"); em.persistAndFlush(order);
var result = repo.findByCustomerId("cust-1"); assertThat(result).isNotEmpty().extracting(Order::getCustomerId).contains("cust-1"); } } ```
Notes:
- @DataJpaTest rolls back transactions by default, isolating tests.
- Use TestEntityManager to control persisted state in tests.
4) Full integration with @SpringBootTest + Testcontainers
- Use Testcontainers for realistic DB or messaging backends....