Unit Testing Best Practices
Definition

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

Tips & Tricks
