Testing with JUnit 5 & Mockito

Testing Best Practices

15 min Lesson 9 of 13

Testing Best Practices

Writing tests that pass is easy. Writing tests that communicate intent, survive refactoring, and catch real bugs is a craft. This lesson distils the most impactful best practices around three themes: naming, isolation, and scope — what to test and what to deliberately leave out.

1. Name Tests Like Specifications

A test name is the first thing a failing build shows you. It should answer three questions without reading the body: what is under test, under what condition, and what is expected.

A widely adopted pattern is Given / When / Then compressed into a method name:

// Bad — tells you nothing useful when it fails @Test void test1() { ... } // Bad — tells you the action but not the expectation @Test void withdraw() { ... } // Good — method name IS the spec @Test void withdraw_reducesBalance_whenFundsAreSufficient() { ... } @Test void withdraw_throwsInsufficientFundsException_whenBalanceIsLow() { ... }

Underscores inside the method name are acceptable (many teams prefer them) because they make the three-part structure scannable in IDE test runners and CI reports. Alternatively, use @DisplayName to attach a sentence-style label without changing the method identifier:

@Test @DisplayName("withdraw() reduces balance when funds are sufficient") void withdrawReducesBalanceWhenFundsSufficient() { ... }
Treat failing test names as error messages. If a colleague reads "withdraw_reducesBalance_whenFundsAreSufficient FAILED" in a CI log, they instantly know where the defect lives. A name like "test1 FAILED" offers no signal at all.

2. One Logical Assertion Per Test

Each test should verify exactly one behaviour. This does not always mean exactly one assertX() call — sometimes verifying a single behaviour requires checking two related values. But a test that verifies ten unrelated things hides the root cause when it fails.

// Bad — multiple unrelated assertions in one test @Test void processOrder() { Order order = service.process(cart); assertEquals(OrderStatus.CONFIRMED, order.getStatus()); assertEquals(2, emailSender.getSentCount()); // side-effect of another concern assertTrue(inventory.isReduced()); // yet another concern } // Good — each concern in its own test @Test void processOrder_setsStatusToConfirmed() { Order order = service.process(cart); assertEquals(OrderStatus.CONFIRMED, order.getStatus()); } @Test void processOrder_sendsConfirmationEmail() { service.process(cart); assertEquals(1, emailSender.getSentCount()); } @Test void processOrder_reducesInventory() { service.process(cart); assertTrue(inventory.isReduced()); }

3. Isolate the System Under Test

Every test should create its own fresh state. Shared mutable state across tests causes order-dependent failures — tests that pass alone but fail when run in a different sequence. JUnit 5 creates a new test instance per method by default, which helps, but you must also:

  • Initialise fixtures in @BeforeEach, not as static fields.
  • Replace real collaborators with mocks or fakes so the test only exercises the class it claims to test.
  • Never rely on the filesystem, a running database, or an external HTTP endpoint in a unit test.
// Bad — static shared state causes test interference class OrderServiceTest { static OrderService service = new OrderService(new RealDatabase()); // shared! @Test void test1() { service.place(order1); } @Test void test2() { service.place(order2); } // may fail if test1 mutated state } // Good — fresh instance per test, collaborators mocked class OrderServiceTest { private OrderService service; private OrderRepository repoMock; @BeforeEach void setUp() { repoMock = mock(OrderRepository.class); service = new OrderService(repoMock); } @Test void place_savesOrder_whenCartIsValid() { service.place(validCart); verify(repoMock).save(any(Order.class)); } }
Static mutable state is a test-isolation killer. Even if you reset it in @AfterEach, a test that throws unexpectedly before the teardown runs leaves state behind for the next test. Prefer instance fields reset in @BeforeEach.

4. Follow the Arrange–Act–Assert (AAA) Structure

Every test body should have three clearly separated phases: set up data and collaborators (Arrange), invoke the code under test (Act), then verify the outcome (Assert). A blank line between phases makes the structure instantly readable.

@Test void transfer_movesMoneyBetweenAccounts() { // Arrange Account source = new Account(1000); Account target = new Account(200); // Act source.transfer(300, target); // Assert assertEquals(700, source.getBalance()); assertEquals(500, target.getBalance()); }

5. What to Test

Focus tests on observable behaviour from the perspective of the caller, not on internal implementation details.

  • Public API contracts — return values, thrown exceptions, state changes visible through getters.
  • All branches of conditional logic — the happy path, every meaningful error path, and edge cases (null, empty, zero, max value).
  • Business rules — validations, calculations, invariants your domain model must uphold.
  • Integration points via integration tests — that a JpaRepository query returns the right rows, that an HTTP endpoint responds with the correct status code.
// Test the contract, not the implementation detail @Test void discount_appliesPercentage_forPremiumCustomer() { Customer premium = new Customer(CustomerTier.PREMIUM); PricingService pricing = new PricingService(); BigDecimal discounted = pricing.calculate(new BigDecimal("100.00"), premium); // Assert observable output — do NOT assert which private method was called assertEquals(new BigDecimal("80.00"), discounted); }

6. What NOT to Test

Untested code is a risk, but over-testing is also a problem: tests that are tightly coupled to implementation details break on every refactor, even when the behaviour is correct, making developers distrust the suite.

  • Private methods — test them through the public API that exercises them. If a private method is so complex it demands its own test, extract it into a collaborator class.
  • Framework internals — do not test that Spring wires a bean or that JPA generates a SQL statement correctly. Trust the framework; test your logic.
  • Trivial getters/setters — a getter that returns a field has no logic. Testing it adds noise without safety.
  • Configuration data — a constant, an enum value, or a property file entry does not need a unit test.
The Test Pyramid principle: Many fast unit tests form the base; fewer integration tests in the middle; even fewer end-to-end tests at the top. Inverting the pyramid by writing mostly E2E tests results in a slow, fragile suite that developers stop running locally.

7. Keep Tests Fast and Deterministic

A test suite that takes ten minutes to run will not be run before every commit. Guard speed relentlessly:

  • Replace slow dependencies (databases, queues, HTTP) with in-memory fakes or Mockito mocks in unit tests.
  • Use @Tag("integration") to separate integration tests so they can run in CI but not on every local build if speed is critical.
  • Never use Thread.sleep() in a test to wait for an async result — use Awaitility or CompletableFuture.get(timeout).
  • Avoid new Date() or Instant.now() directly; inject a Clock so tests can pass a fixed time.
// Inject Clock so the test controls "now" public class SubscriptionService { private final Clock clock; public SubscriptionService(Clock clock) { this.clock = clock; } public boolean isExpired(Subscription sub) { return sub.getExpiryDate().isBefore(LocalDate.now(clock)); } } // In the test @Test void isExpired_returnsTrue_whenExpiryIsInThePast() { Clock fixed = Clock.fixed( Instant.parse("2025-06-01T00:00:00Z"), ZoneOffset.UTC); SubscriptionService service = new SubscriptionService(fixed); Subscription sub = new Subscription(LocalDate.of(2025, 5, 31)); assertTrue(service.isExpired(sub)); }

Summary

The hallmarks of a high-quality test suite: test names that read like specifications, one logical assertion per test, full isolation of each test, the AAA structure, tests aimed at public observable behaviour rather than private implementation details, and a fast deterministic run time. Internalise these practices and your suite becomes a safety net you trust — one that accelerates development rather than slowing it down.