Skip to content

Unit Testing Best Practices


Definition

Unit Testing Overview


Test Structure

// AAA Pattern: Arrange, Act, Assert

@Test
void shouldCalculateDiscountForPremiumCustomer() {
    // ARRANGE: Set up test data and dependencies
    Customer customer = new Customer("premium");
    Order order = new Order(Money.of(100));
    PricingService service = new PricingService();

    // ACT: Execute the code under test
    Money discount = service.calculateDiscount(customer, order);

    // ASSERT: Verify the expected outcome
    assertThat(discount).isEqualTo(Money.of(15)); // 15% premium discount
}

// Given-When-Then (BDD style, same concept)

@Test
void shouldRejectOrderWhenInventoryInsufficient() {
    // Given
    Product product = new Product("widget", 5);  // 5 in stock
    Order order = new Order(product, 10);         // Ordering 10

    // When
    Result result = orderService.submit(order);

    // Then
    assertThat(result.isSuccess()).isFalse();
    assertThat(result.getError()).isEqualTo("Insufficient inventory");
}

// One assertion per test (conceptually)
// Multiple assertions OK if testing one behavior

@Test
void shouldCreateUserWithDefaults() {
    User user = userService.create("[email protected]");

    // Multiple assertions for one behavior
    assertThat(user.getEmail()).isEqualTo("[email protected]");
    assertThat(user.getStatus()).isEqualTo(Status.PENDING);
    assertThat(user.getCreatedAt()).isNotNull();
}

Mocking

// MOCKING: Replace dependencies with test doubles

// Mockito example
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;

    @Mock
    private PaymentGateway paymentGateway;

    @InjectMocks
    private OrderService orderService;

    @Test
    void shouldSaveOrderWhenPaymentSucceeds() {
        // Arrange
        Order order = new Order(Money.of(100));
        when(paymentGateway.charge(any())).thenReturn(PaymentResult.success());

        // Act
        orderService.submit(order);

        // Assert
        verify(orderRepository).save(order);
        verify(paymentGateway).charge(order);
    }

    @Test
    void shouldNotSaveOrderWhenPaymentFails() {
        // Arrange
        Order order = new Order(Money.of(100));
        when(paymentGateway.charge(any())).thenReturn(PaymentResult.failed());

        // Act & Assert
        assertThrows(PaymentFailedException.class,
            () -> orderService.submit(order));

        verify(orderRepository, never()).save(any());
    }
}

// TYPES OF TEST DOUBLES:
// Mock: Verify interactions (did method get called?)
// Stub: Return canned responses
// Spy: Real object with some methods stubbed
// Fake: Working implementation (e.g., in-memory DB)

// When to mock:
// ✓ External services (APIs, databases)
// ✓ Non-deterministic (time, random)
// ✓ Slow dependencies
// ✗ Value objects
// ✗ Simple collaborators

Test Naming

// GOOD: Describes behavior

// Pattern: should[ExpectedBehavior]When[Condition]
@Test
void shouldReturnEmptyListWhenNoOrdersExist() { }

@Test
void shouldThrowExceptionWhenAmountIsNegative() { }

@Test
void shouldApplyDiscountWhenCustomerIsPremium() { }

// Pattern: [method]_[scenario]_[expectedResult]
@Test
void calculateTotal_withEmptyCart_returnsZero() { }

@Test
void validateEmail_withInvalidFormat_returnsFalse() { }

// BAD: Describes implementation
@Test
void testCalculateTotal() { }  // What about it?

@Test
void test1() { }  // Meaningless

@Test
void orderServiceTest() { }  // What behavior?

// NESTED TESTS (JUnit 5)
@Nested
class WhenCartIsEmpty {
    @Test
    void shouldReturnZeroTotal() { }

    @Test
    void shouldReturnEmptyItemList() { }
}

@Nested
class WhenCartHasItems {
    @Test
    void shouldCalculateTotalCorrectly() { }

    @Test
    void shouldApplyDiscounts() { }
}

Edge Cases & Boundaries

// BOUNDARY VALUE ANALYSIS
// Test at boundaries: min, max, just inside, just outside

@Test
void shouldRejectOrderBelowMinimum() {
    Order order = new Order(Money.of(9.99));  // Just below $10 minimum
    assertThat(orderService.validate(order)).isFalse();
}

@Test
void shouldAcceptOrderAtMinimum() {
    Order order = new Order(Money.of(10.00));  // Exactly $10
    assertThat(orderService.validate(order)).isTrue();
}

@Test
void shouldAcceptOrderAboveMinimum() {
    Order order = new Order(Money.of(10.01));  // Just above $10
    assertThat(orderService.validate(order)).isTrue();
}

// COMMON EDGE CASES TO TEST:
// - Null inputs
// - Empty collections/strings
// - Zero, negative numbers
// - Maximum values
// - Unicode/special characters
// - Whitespace

@ParameterizedTest
@NullSource
@EmptySource
@ValueSource(strings = {"  ", "\t", "\n"})
void shouldRejectInvalidEmail(String email) {
    assertThrows(ValidationException.class,
        () -> userService.register(email));
}

@ParameterizedTest
@CsvSource({
    "0, 0",
    "1, 1",
    "100, 15",    // 15% discount over $100
    "99.99, 0"    // Just under threshold
})
void shouldCalculateDiscount(double orderTotal, double expectedDiscount) {
    Money discount = pricingService.calculateDiscount(Money.of(orderTotal));
    assertThat(discount).isEqualTo(Money.of(expectedDiscount));
}

Test Coverage

Test Coverage


Tips & Tricks

Unit Testing Tips