Unit Testing Patterns
Unit Testing Patterns
Overview
Unit Testing Patterns provide structured approaches to writing clear, maintainable, and effective tests. The most fundamental is the AAA (Arrange-Act-Assert) pattern, which organizes test code into three distinct phases. Combined with proper naming conventions, test organization strategies, parameterized tests, and well-designed fixtures, these patterns ensure tests serve as executable documentation while remaining fast and reliable.
Mastering unit testing patterns transforms tests from afterthoughts into first-class design tools that guide development, catch regressions, and enable fearless refactoring.
Key Concepts
The AAA Pattern (Arrange-Act-Assert)
┌─────────────────────────────────────────────────────┐
│ AAA Pattern Structure │
├─────────────────────────────────────────────────────┤
│ │
│ 📦 ARRANGE │
│ • Set up test data │
│ • Configure mocks/stubs │
│ • Initialize system under test │
│ │
│ ⚡ ACT │
│ • Execute the behavior being tested │
│ • Should be ONE line (or very few) │
│ • The "when" of the test │
│ │
│ ✅ ASSERT │
│ • Verify expected outcomes │
│ • Check state changes │
│ • Verify interactions │
│ │
└─────────────────────────────────────────────────────┘
Test Naming Conventions
| Convention | Example | Use Case |
|---|---|---|
| should_when | shouldReturnDiscount_whenCustomerIsVIP | Clear behavior description |
| given_when_then | givenVIPCustomer_whenCalculatingPrice_thenAppliesDiscount | BDD style, very explicit |
| methodName_condition_expectedBehavior | calculatePrice_vipCustomer_appliesDiscount | Roy Osherove convention |
Test Organization Strategies
- Test Class per Production Class: One-to-one mapping for simple classes
- Test Class per Feature: Group related behaviors for complex classes
- Nested Test Classes: Use
@Nestedfor logical grouping and context sharing - Test Packages: Mirror production package structure
Types of Test Fixtures
┌───────────────────────────────────────────────────┐
│ Test Fixture Types │
├───────────────────────────────────────────────────┤
│ │
│ Fresh Fixture (@BeforeEach) │
│ • New instance for each test │
│ • Prevents test interdependencies │
│ • Most common pattern │
│ │
│ Shared Fixture (@BeforeAll) │
│ • Single instance for all tests │
│ • Use for expensive setup │
│ • Must be immutable/stateless │
│ │
│ Inline Fixture │
│ • Created directly in test method │
│ • Best for simple, test-specific data │
│ • No shared state │
│ │
└───────────────────────────────────────────────────┘
Best Practices
1. Always Use AAA Pattern
Structure every test with clear Arrange-Act-Assert sections, separated by blank lines for readability.
2. Name Tests Descriptively
Test names should read like specifications. Anyone should understand what’s being tested without reading the code.
3. One Logical Assertion Per Test
Focus each test on a single behavior or outcome. Multiple assertions are OK if verifying the same behavior.
4. Keep Tests Independent
Tests should run in any order and not depend on each other. Use fresh fixtures or proper cleanup.
5. Use Parameterized Tests for Similar Cases
When testing the same behavior with different inputs, use @ParameterizedTest instead of duplicating test code.
Code Examples
Example 1: AAA Pattern in Action
// ✅ CORRECT - Clear AAA structure
@Test
void shouldCalculateDiscountForVIPCustomer() {
// Arrange
Customer customer = new Customer("John", CustomerType.VIP);
Order order = new Order(Money.usd(100));
DiscountCalculator calculator = new DiscountCalculator();
// Act
Money discount = calculator.calculate(customer, order);
// Assert
assertThat(discount).isEqualTo(Money.usd(10));
}
// ❌ WRONG - No clear structure
@Test
void test1() {
Customer customer = new Customer("John", CustomerType.VIP);
DiscountCalculator calculator = new DiscountCalculator();
Order order = new Order(Money.usd(100));
Money discount = calculator.calculate(customer, order);
assertThat(discount).isEqualTo(Money.usd(10));
}
Example 2: Test Naming Conventions
// ✅ CORRECT - Multiple good naming styles
// Style 1: should_when (simple and readable)
@Test
void shouldReturnEmptyList_whenNoOrdersExist() { }
@Test
void shouldThrowException_whenEmailIsInvalid() { }
// Style 2: given_when_then (BDD style)
@Test
void givenValidOrder_whenProcessing_thenMarksAsComplete() { }
@Test
void givenInsufficientStock_whenOrdering_thenThrowsException() { }
// Style 3: methodName_condition_expectedBehavior
@Test
void processPayment_insufficientFunds_throwsPaymentException() { }
@Test
void calculateShipping_internationalOrder_includesTaxes() { }
// ❌ WRONG - Poor test names
@Test
void test1() { } // No information
@Test
void testCalculate() { } // Too vague
@Test
void whenCalculatingDiscountForVIPCustomerShouldReturn10PercentOff() { } // Too long
Example 3: Nested Test Classes for Organization
// ✅ CORRECT - Organized with @Nested classes
@DisplayName("UserService")
class UserServiceTest {
private UserService userService;
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository = mock(UserRepository.class);
userService = new UserService(userRepository);
}
@Nested
@DisplayName("Registration")
class Registration {
@Test
void shouldCreateNewUser_whenEmailIsValid() {
String email = "user@example.com";
User user = userService.register(email, "password");
assertThat(user).isNotNull();
assertThat(user.getEmail()).isEqualTo(email);
}
@Test
void shouldThrowException_whenEmailIsInvalid() {
assertThatThrownBy(() -> userService.register("invalid", "password"))
.isInstanceOf(InvalidEmailException.class);
}
@Test
void shouldHashPassword_whenRegisteringUser() {
User user = userService.register("user@example.com", "plaintext");
assertThat(user.getPassword()).isNotEqualTo("plaintext");
assertThat(user.getPassword()).startsWith("$2a$"); // BCrypt hash
}
}
@Nested
@DisplayName("Authentication")
class Authentication {
@Test
void shouldAuthenticateUser_whenCredentialsAreValid() {
User existingUser = new User("user@example.com", hashedPassword("secret"));
when(userRepository.findByEmail("user@example.com"))
.thenReturn(Optional.of(existingUser));
boolean authenticated = userService.authenticate("user@example.com", "secret");
assertThat(authenticated).isTrue();
}
@Test
void shouldRejectAuthentication_whenPasswordIsWrong() {
User existingUser = new User("user@example.com", hashedPassword("secret"));
when(userRepository.findByEmail("user@example.com"))
.thenReturn(Optional.of(existingUser));
boolean authenticated = userService.authenticate("user@example.com", "wrong");
assertThat(authenticated).isFalse();
}
}
@Nested
@DisplayName("Profile Updates")
class ProfileUpdates {
@Test
void shouldUpdateDisplayName_whenUserExists() {
User user = new User("user@example.com");
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
userService.updateDisplayName(1L, "John Doe");
assertThat(user.getDisplayName()).isEqualTo("John Doe");
verify(userRepository).save(user);
}
}
}
// ❌ WRONG - Flat structure, hard to navigate
class UserServiceTest {
@Test void testRegister1() { }
@Test void testRegister2() { }
@Test void testAuth1() { }
@Test void testAuth2() { }
@Test void testUpdate1() { }
// ... 50 more tests in flat structure
}
Example 4: Parameterized Tests
// ✅ CORRECT - Parameterized test for multiple inputs
@ParameterizedTest
@CsvSource({
"0, 0",
"1, 1",
"2, 2",
"3, 6",
"4, 24",
"5, 120",
"10, 3628800"
})
void shouldCalculateFactorial_forVariousInputs(int input, long expected) {
MathUtils math = new MathUtils();
long result = math.factorial(input);
assertThat(result).isEqualTo(expected);
}
// Using @ValueSource for single parameter
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t", "\n"})
void shouldRejectBlankPasswords(String password) {
PasswordValidator validator = new PasswordValidator();
boolean valid = validator.isValid(password);
assertThat(valid).isFalse();
}
// Using @EnumSource for enum testing
@ParameterizedTest
@EnumSource(CustomerType.class)
void shouldHandleAllCustomerTypes(CustomerType type) {
DiscountCalculator calculator = new DiscountCalculator();
BigDecimal discount = calculator.getDiscountRate(type);
assertThat(discount).isNotNull();
assertThat(discount).isGreaterThanOrEqualTo(BigDecimal.ZERO);
}
// Using @MethodSource for complex objects
@ParameterizedTest
@MethodSource("invalidEmailProvider")
void shouldRejectInvalidEmails(String email, String expectedMessage) {
EmailValidator validator = new EmailValidator();
ValidationResult result = validator.validate(email);
assertThat(result.isValid()).isFalse();
assertThat(result.getMessage()).contains(expectedMessage);
}
private static Stream<Arguments> invalidEmailProvider() {
return Stream.of(
Arguments.of("no-at-sign", "missing @ symbol"),
Arguments.of("@no-local-part.com", "missing local part"),
Arguments.of("no-domain@", "missing domain"),
Arguments.of("spaces in@email.com", "contains invalid characters")
);
}
// ❌ WRONG - Duplicated test code
@Test void testFactorial0() { assertThat(math.factorial(0)).isEqualTo(0); }
@Test void testFactorial1() { assertThat(math.factorial(1)).isEqualTo(1); }
@Test void testFactorial2() { assertThat(math.factorial(2)).isEqualTo(2); }
@Test void testFactorial3() { assertThat(math.factorial(3)).isEqualTo(6); }
// ... repetitive tests
Example 5: Test Fixtures and Setup Patterns
// ✅ CORRECT - Fresh fixture pattern
class ShoppingCartTest {
private ShoppingCart cart;
private Product product1;
private Product product2;
@BeforeEach
void setUp() {
// Fresh fixture for each test - prevents test interdependency
cart = new ShoppingCart();
product1 = new Product("P1", "Laptop", Money.usd(1000));
product2 = new Product("P2", "Mouse", Money.usd(25));
}
@Test
void shouldAddItemToCart() {
cart.add(product1);
assertThat(cart.getItems()).hasSize(1);
assertThat(cart.getItems()).contains(product1);
}
@Test
void shouldCalculateTotalPrice() {
cart.add(product1);
cart.add(product2);
Money total = cart.getTotal();
assertThat(total).isEqualTo(Money.usd(1025));
}
}
// ✅ CORRECT - Shared fixture for expensive setup
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DatabaseIntegrationTest {
private DataSource dataSource;
@BeforeAll
void setUpDatabase() {
// Expensive setup - done once for all tests
dataSource = createTestDatabase();
runMigrations(dataSource);
}
@BeforeEach
void cleanDatabase() {
// Clean state before each test
truncateTables(dataSource);
}
@AfterAll
void tearDownDatabase() {
closeDataSource(dataSource);
}
}
// ✅ CORRECT - Inline fixture for test-specific data
class OrderProcessingTest {
@Test
void shouldRejectOrderWithNegativeQuantity() {
// Inline fixture - specific to this test only
OrderItem invalidItem = new OrderItem("P1", -5);
Order order = new Order(List.of(invalidItem));
assertThatThrownBy(() -> orderProcessor.process(order))
.isInstanceOf(InvalidOrderException.class)
.hasMessageContaining("negative quantity");
}
}
// ✅ CORRECT - Builder pattern for complex fixtures
class OrderTestDataBuilder {
private String customerId = "C123";
private List<OrderItem> items = new ArrayList<>();
private OrderStatus status = OrderStatus.PENDING;
public static OrderTestDataBuilder anOrder() {
return new OrderTestDataBuilder();
}
public OrderTestDataBuilder withCustomerId(String customerId) {
this.customerId = customerId;
return this;
}
public OrderTestDataBuilder withItem(String productId, int quantity) {
this.items.add(new OrderItem(productId, quantity));
return this;
}
public OrderTestDataBuilder withStatus(OrderStatus status) {
this.status = status;
return this;
}
public Order build() {
return new Order(customerId, items, status);
}
}
// Usage in tests
@Test
void shouldProcessCompletedOrder() {
Order order = anOrder()
.withCustomerId("C999")
.withItem("P1", 2)
.withItem("P2", 1)
.withStatus(OrderStatus.COMPLETED)
.build();
processor.process(order);
verify(notificationService).sendConfirmation("C999");
}
// ❌ WRONG - Shared mutable state between tests
class BadFixtureTest {
private static ShoppingCart cart = new ShoppingCart(); // SHARED!
@Test
void test1() {
cart.add(product); // Modifies shared state
}
@Test
void test2() {
assertThat(cart.isEmpty()).isTrue(); // May fail if test1 ran first!
}
}
Anti-Patterns
❌ Missing AAA Structure
// WRONG - Everything jumbled together
@Test
void testOrderProcessing() {
Order order = new Order();
order.addItem(new Item("P1"));
OrderProcessor processor = new OrderProcessor();
ProcessResult result = processor.process(order);
assertThat(result.isSuccess()).isTrue();
assertThat(order.getStatus()).isEqualTo("COMPLETED");
}
// ✅ CORRECT - Clear AAA sections
@Test
void shouldMarkOrderAsCompleted_whenProcessingSucceeds() {
// Arrange
Order order = new Order();
order.addItem(new Item("P1"));
OrderProcessor processor = new OrderProcessor();
// Act
ProcessResult result = processor.process(order);
// Assert
assertThat(result.isSuccess()).isTrue();
assertThat(order.getStatus()).isEqualTo("COMPLETED");
}
❌ Testing Multiple Behaviors in One Test
// WRONG - Testing too much
@Test
void testUserService() {
User user = userService.register("john@example.com", "password");
assertThat(user).isNotNull();
boolean authenticated = userService.authenticate("john@example.com", "password");
assertThat(authenticated).isTrue();
user.setName("John Doe");
userService.update(user);
assertThat(userService.findById(user.getId()).getName()).isEqualTo("John Doe");
}
// ✅ CORRECT - Separate tests for separate behaviors
@Test
void shouldRegisterNewUser() {
User user = userService.register("john@example.com", "password");
assertThat(user).isNotNull();
}
@Test
void shouldAuthenticateWithValidCredentials() {
userService.register("john@example.com", "password");
boolean authenticated = userService.authenticate("john@example.com", "password");
assertThat(authenticated).isTrue();
}
@Test
void shouldUpdateUserDetails() {
User user = userService.register("john@example.com", "password");
user.setName("John Doe");
userService.update(user);
assertThat(userService.findById(user.getId()).getName()).isEqualTo("John Doe");
}
❌ Conditional Logic in Tests
// WRONG - Conditionals make tests unpredictable
@Test
void testCalculation() {
int result = calculator.calculate(input);
if (input > 0) {
assertThat(result).isPositive();
} else {
assertThat(result).isNegative();
}
}
// ✅ CORRECT - Separate tests for different conditions
@Test
void shouldReturnPositiveResult_whenInputIsPositive() {
int result = calculator.calculate(5);
assertThat(result).isPositive();
}
@Test
void shouldReturnNegativeResult_whenInputIsNegative() {
int result = calculator.calculate(-5);
assertThat(result).isNegative();
}
Testing Strategies
Test Organization Strategy
// Organize tests to mirror production structure
src/main/java/com/example/
├── order/
│ ├── Order.java
│ ├── OrderService.java
│ └── OrderRepository.java
src/test/java/com/example/
├── order/
│ ├── OrderTest.java
│ ├── OrderServiceTest.java
│ └── OrderRepositoryTest.java
Test Coverage Strategy
// Cover these scenarios for each method:
// 1. Happy path (expected input → expected output)
// 2. Edge cases (boundaries, limits)
// 3. Error cases (invalid input → exceptions)
// 4. State changes (verify side effects)
// 5. Interactions (verify collaborations)
@Nested
@DisplayName("divide()")
class DivideTests {
@Test void shouldDivideTwoPositiveNumbers() { } // Happy path
@Test void shouldHandleZeroDividend() { } // Edge case
@Test void shouldThrowException_whenDivisorIsZero() { } // Error case
}
Test Maintenance Strategy
// Keep tests maintainable:
// 1. Use descriptive names
// 2. Extract common setup to @BeforeEach or builders
// 3. Keep tests short (<20 lines typically)
// 4. Use helper methods for repeated logic
// 5. Review tests during code review
// Helper method example
private Order createOrderWithItems(String... productIds) {
Order order = new Order();
for (String productId : productIds) {
order.addItem(new OrderItem(productId, 1));
}
return order;
}
@Test
void shouldCalculateTotalForMultipleItems() {
Order order = createOrderWithItems("P1", "P2", "P3");
Money total = order.calculateTotal();
assertThat(total).isEqualTo(expectedTotalFor("P1", "P2", "P3"));
}
References
- JUnit 5 User Guide
- AssertJ Documentation
- Effective Unit Testing (Lasse Koskela)
- xUnit Test Patterns (Gerard Meszaros)