9.1 JUnit 5 and Mockito Best Practices
JUnit 5 Architecture
JUnit 5 splits into three components: JUnit Platform (launcher, discovery), JUnit Jupiter (the new programming model and extension API), and JUnit Vintage (runs JUnit 3/4 tests). This modularity is why Jupiter extensions are more powerful than JUnit 4 Runners/Rules — they compose instead of replacing each other.
Core Annotations and Lifecycle
@DisplayName("Order Service Tests")
class OrderServiceTest {
@BeforeAll
static void initAll() { /* runs once, must be static unless using PER_CLASS lifecycle */ }
@BeforeEach
void init() { /* runs before every test */ }
@Test
@DisplayName("should reject order when inventory is insufficient")
void rejectsOrderOnInsufficientInventory() { }
@AfterEach
void tearDown() { }
@AfterAll
static void tearDownAll() { }
}
Best practice: Use @TestInstance(Lifecycle.PER_CLASS) when you need non-static @BeforeAll, or when sharing expensive setup (e.g., a TestContainers instance) across tests in a class without making it static — it also enables instance-level @BeforeAll for cleaner DI integration with Spring.
Parameterized Tests — Stop Writing 5 Copies of the Same Test
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t"})
void blankCustomerIdIsRejected(String input) {
assertThrows(IllegalArgumentException.class, () -> service.validate(input));
}
@ParameterizedTest
@MethodSource("discountScenarios")
void appliesCorrectDiscount(BigDecimal orderTotal, CustomerTier tier, BigDecimal expected) {
assertEquals(expected, pricingEngine.discountFor(orderTotal, tier));
}
static Stream<Arguments> discountScenarios() {
return Stream.of(
Arguments.of(new BigDecimal("100"), CustomerTier.GOLD, new BigDecimal("10")),
Arguments.of(new BigDecimal("100"), CustomerTier.SILVER, new BigDecimal("5"))
);
}
Prefer @MethodSource or @CsvFileSource over inline @CsvSource once you exceed ~3 parameters or need non-primitive types — readability degrades fast otherwise.
Nested Tests for Behavioral Grouping
@Nested
@DisplayName("when account is locked")
class WhenAccountLocked {
@BeforeEach
void lockAccount() { account.lock(); }
@Test
void withdrawalIsRejected() { ... }
@Test
void balanceInquiryStillWorks() { ... }
}
This produces self-documenting test reports and avoids the classic “one giant flat test class with 40 tests and no narrative” problem — important for architect-level review and onboarding.
Assertions: AssertJ Over Hamcrest/JUnit Assertions
JUnit’s built-in assertions are fine for simple checks, but for anything composite, use AssertJ for fluent, readable, and far more diagnostic assertions:
assertThat(order.getLineItems())
.hasSize(3)
.extracting(LineItem::getSku)
.containsExactlyInAnyOrder("SKU-1", "SKU-2", "SKU-3");
assertThatThrownBy(() -> service.process(invalidOrder))
.isInstanceOf(OrderValidationException.class)
.hasMessageContaining("inventory");
Use assertAll() from JUnit when you deliberately want all assertions to run and report together rather than short-circuiting on the first failure — valuable for verifying multiple fields of a returned DTO in one shot.
Mockito Best Practices
1. Prefer constructor injection + @ExtendWith(MockitoExtension.class) over manual mock creation:
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock private InventoryClient inventoryClient;
@Mock private PricingEngine pricingEngine;
@InjectMocks private OrderService orderService;
@Test
void rejectsOrderOnInsufficientInventory() {
when(inventoryClient.checkAvailability("SKU-1")).thenReturn(0);
assertThatThrownBy(() -> orderService.placeOrder(orderFor("SKU-1")))
.isInstanceOf(InsufficientInventoryException.class);
verify(inventoryClient).checkAvailability("SKU-1");
verifyNoInteractions(pricingEngine);
}
}
2. Use strictness = Strictness.STRICT_STUBS (default with MockitoExtension) — it fails tests that stub something never used, catching dead/leftover stubs that silently rot over time.
3. Avoid over-mocking — mock only what you don’t own. A frequent architect-level anti-pattern: mocking value objects, DTOs, or your own domain entities. Mock collaborators at architectural boundaries (repositories, external clients, gateways) — not internal logic. If you find yourself mocking 6+ dependencies for one test, that’s a signal the class under test has too many responsibilities.
4. ArgumentCaptor for verifying what was actually passed, not just that something was called:
@Captor private ArgumentCaptor<Order> orderCaptor;
verify(orderRepository).save(orderCaptor.capture());
assertThat(orderCaptor.getValue().getStatus()).isEqualTo(OrderStatus.CONFIRMED);
5. @Spy sparingly, and never spy on the class under test. Spies are for wrapping real objects when you need real behavior plus selective stubbing — common with legacy code you’re characterizing, not for new clean code. Heavy spy usage usually means the design needs refactoring, not more mocking cleverness.
6. Use BDDMockito (given/willReturn/then) for readability in BDD-style teams:
given(inventoryClient.checkAvailability("SKU-1")).willReturn(0);
then(pricingEngine).shouldHaveNoInteractions();
7. Never use Mockito.mock() statically scattered through test bodies in large suites — centralize via @Mock/@InjectMocks so failures point clearly at missing stubs versus wiring issues.
8. Argument matchers — don’t mix raw values and matchers in the same call:
// WRONG — throws InvalidUseOfMatchersException
when(client.send("order-1", any())).thenReturn(true);
// RIGHT
when(client.send(eq("order-1"), any())).thenReturn(true);
9. Reset is a smell. If you’re calling reset(mock) mid-test, it usually means the test is doing too much — split it into two tests instead.
9.2 Test Pyramid, Contract Testing, and TestContainers
The Test Pyramid — and Why It’s Still Relevant in Microservices
/\
/ \ E2E (few, slow, brittle, high confidence)
/----\
/ \ Integration / Contract (moderate)
/--------\
/ \ Unit Tests (many, fast, isolated)
/____________\
In a microservices/distributed architecture, the classic pyramid often morphs into a “testing trophy” (Kent C. Dodds’ framing) — heavier on integration tests, since unit tests alone don’t catch the failures that actually happen at service boundaries (serialization mismatches, contract drift, network behavior). For enterprise Java/Spring systems, a practical ratio architects should target:
| Layer | Proportion | Tooling |
|---|---|---|
| Unit | ~60-70% | JUnit 5, Mockito, AssertJ |
| Integration (DB, messaging, HTTP) | ~20-30% | TestContainers, @SpringBootTest slices |
| Contract | targeted, per integration point | Pact, Spring Cloud Contract |
| E2E | minimal, critical paths only | RestAssured, Cucumber, Selenium/Playwright |
Architect-level guidance: the pyramid is a cost/confidence trade-off, not dogma. The real KPI is: does a failing test tell you exactly what broke, fast? A bloated E2E suite that takes 40 minutes and gives vague failures is worse than a smaller, well-targeted integration suite.
Contract Testing — Solving the “Works in Isolation, Breaks in Production” Problem
In a microservices architecture, two services can each pass their own unit/integration tests yet break when integrated, because their assumptions about the API shape silently diverged. Contract testing closes this gap without spinning up the full dependency chain.
Consumer-Driven Contracts (CDC) with Pact:
- The consumer writes a test expressing what it expects from the provider:
@ExtendWith(PactConsumerTestExt.class)
class OrderClientPactTest {
@Pact(consumer = "order-service", provider = "inventory-service")
public RequestResponsePact inventoryPact(PactDslWithProvider builder) {
return builder
.given("SKU-1 has stock")
.uponReceiving("a request for inventory")
.path("/inventory/SKU-1")
.method("GET")
.willRespondWith()
.status(200)
.body(newJsonBody(o -> o.numberType("quantity", 42)).build())
.toPact();
}
@Test
void verifiesInventoryContract(MockServer mockServer) {
InventoryClient client = new InventoryClient(mockServer.getUrl());
assertThat(client.checkAvailability("SKU-1")).isEqualTo(42);
}
}
- This generates a pact file (JSON contract), published to a Pact Broker.
- The provider (inventory-service) runs a verification step in its own CI pipeline against that pact file — proving it satisfies what the consumer expects, before either side deploys.
Why CDC matters at the architecture level: it shifts integration-breakage detection from “discovered in staging/production” to “caught in CI, on the PR.” It also documents inter-service expectations explicitly, which is invaluable when you have 15+ microservices and no one remembers every downstream consumer of an API.
Spring Cloud Contract is the alternative if you’re provider-driven (provider defines the contract, generates stubs consumers use) — better fit when the provider team owns the API design and consumers are internal/trusted, versus Pact’s consumer-driven model which suits more decentralized, cross-team ownership.
TestContainers — Real Dependencies, Disposable Infrastructure
TestContainers solves the “mocking the database lies to you” problem — H2-in-memory tests pass, then production Postgres-specific behavior (JSONB, sequences, constraints) breaks at runtime. TestContainers spins up real Docker containers for the test lifecycle.
@SpringBootTest
@Testcontainers
class OrderRepositoryIT {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("orders_test")
.withReuse(true);
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired private OrderRepository orderRepository;
@Test
void persistsOrderWithLineItems() {
Order saved = orderRepository.save(sampleOrder());
assertThat(orderRepository.findById(saved.getId())).isPresent();
}
}
Multi-container scenarios (common in real architectures — Kafka + Postgres + Redis):
@Container
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
Best practices for TestContainers at scale:
withReuse(true)+ a shared singleton container pattern across test classes to avoid the cost of spinning up a new container per test class — critical once you have 50+ integration test classes, or CI time balloons.- Use
@Testcontainerswith static containers shared across@Nestedclasses when testing multiple repository flows against the same schema. - Pin exact image tags (
postgres:16-alpine, notpostgres:latest) — reproducibility across CI runs and team machines matters more than convenience. - For Kafka/messaging integration tests, pair TestContainers with Awaitility instead of
Thread.sleep()to assert eventual consistency:
await().atMost(5, SECONDS).untilAsserted(() ->
verify(orderEventListener).onOrderConfirmed(any()));
- In CI (Jenkins/GitHub Actions), ensure the runner has Docker-in-Docker or a Docker socket mounted — this is the most common TestContainers CI failure architects hit when standardizing the pattern org-wide.
How These Three Fit Together Architecturally
- Unit tests (JUnit 5 + Mockito) validate business logic in isolation — fast feedback, run on every save.
- TestContainers-backed integration tests validate that your code correctly talks to real infrastructure (DB, broker, cache) — catches ORM/driver/schema issues unit tests can’t see.
- Contract tests validate that service boundaries agree on the API shape without needing the other service running — catches integration drift before deployment, at a fraction of the cost of full E2E.
- E2E tests remain minimal, reserved for the handful of business-critical user journeys that justify the cost of full-stack execution.
This layering is what lets a 15-20 microservice estate stay deployable independently with confidence — which is usually the actual architectural goal driving the test strategy conversation.