9.1 JUnit 5 and Mockito Best Practices

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:

  1. 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);
    }
}
  1. This generates a pact file (JSON contract), published to a Pact Broker.
  2. 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 @Testcontainers with static containers shared across @Nested classes when testing multiple repository flows against the same schema.
  • Pin exact image tags (postgres:16-alpine, not postgres: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.