Unit Testing Best Practices

Unit tests verify the smallest testable parts of an application in isolation. High-quality unit tests act as executable documentation and provide a safety net for refactoring.

The FIRST Principles

Anatomy of a Good Test

Use the Arrange-Act-Assert (AAA) pattern to keep tests readable.

@Test
void shouldCalculateDiscountForPremiumUser() {
    // Arrange
    User user = new User("Alice", UserTier.PREMIUM);
    PricingEngine engine = new PricingEngine();

    // Act
    double price = engine.calculatePrice(100.0, user);

    // Assert
    assertEquals(80.0, price, "Premium users should get a 20% discount");
}

Table-Driven Tests (Parameterized)

Instead of writing five tests for five different inputs, use parameterized tests to map inputs to expected outputs. This is the standard for complex logic.

@ParameterizedTest
@CsvSource({
    "10, 2.0",
    "50, 10.0",
    "100, 20.0",
    "0, 0.0"
})
void shouldCalculateCorrectTax(double amount, double expectedTax) {
    TaxCalculator calc = new TaxCalculator(0.20);
    assertEquals(expectedTax, calc.calculate(amount));
}

Mocking and Boundaries

Use mocks (e.g., Mockito) only for external boundaries or complex dependencies you don't control. Do not mock internal logic or data objects (POJOs/Records).

Common Pitfalls

  1. Testing Implementation, Not Behavior: If you rename a private method and the test breaks, your test is too brittle. Assert on the output, not the internal calls.
  2. The "Slow Unit Test" Oxymoron: If a test hits a database or starts a Spring context, it is an Integration Test, not a Unit Test. Move it to the appropriate suite.
  3. Over-Mocking: If your test setup is 50 lines of when(...).thenReturn(...) for a 5-line method, your class probably has too many responsibilities (SRP violation).
  4. Assertion Roulette: Multiple assertions in one test without clear messages. If it fails, you won't know which one failed without a debugger.

Verification Checklist