| name | java-unit-tests |
| description | Comprehensive guidance for writing high-quality unit tests in Java projects using JUnit 5 and AssertJ. Use when writing unit tests, creating test classes, or need guidance on mocking strategies, assertions, test builders, or JUnit 5 best practices. Requires JUnit 5, AssertJ, and Mockito dependencies. |
Java Unit Testing Skill
Overview
This skill provides comprehensive guidance for writing high-quality unit tests in Java projects using JUnit 5 and AssertJ. It enforces best practices for test structure, mocking strategy, and assertion patterns.
Core Principles
Test Structure
- Use
lower_snake_case for test method names
- Annotate test phases with
// given, // when, // then comments
- One logical assertion per test (multiple chained AssertJ assertions are acceptable)
- Tests should be self-contained and independent
- Use
@BeforeEach sparingly - prefer test-scoped setup in the given phase
- Inline simple construction as
private final fields when the class under test doesn't need setup
Mocking Strategy
Critical Rules:
- NEVER mock domain objects - Domain objects should be real instances
- Fake over mock - If a dependency is a functional interface or single-method interface, create a fake implementation instead of mocking
- Mock external dependencies - Services, repositories, and infrastructure components should be mocked
- Use static
mock() methods, not @Mock or @InjectMocks annotations
- Inline simple construction as
private final fields instead of using @BeforeEach
- Be explicit in
when() blocks - Use specific parameter values instead of any() matchers when the parameter matters to the test
Assertion Guidelines
- Always use AssertJ - Never use JUnit assertions
- Chain assertions fluently for readability
- Use soft assertions when validating multiple conditions on the same object
- Prefer specific assertions over generic ones (e.g.,
hasSize() over satisfies())
- Use
assertThatCode() for verifying no exception is thrown
- Leverage
usingRecursiveComparison() for complex object equality
- Use
extracting() with multiple fields for cleaner assertions
- Consider custom assertions for domain-specific validation
Test Data Management
- Check for existing test builders or fixtures before creating domain objects
- If no builder exists for commonly used domain objects, create one using the Builder pattern
- Use meaningful test data that clarifies the test's intent
- Avoid test data pollution - use minimal data needed for the test
Test Organization
- Use
@Nested classes to group related test scenarios and improve readability
- Nested classes provide better structure for testing different states or contexts
- Each nested class can have its own setup specific to that scenario
Implementation Guide
1. Basic Test Structure
class ServiceUnderTestTest {
private final ExternalService externalService = mock(ExternalService.class);
private final ServiceUnderTest serviceUnderTest = new ServiceUnderTest(externalService);
@Test
void should_return_processed_result_when_input_is_valid() {
var input = new Input("valid-data");
when(externalService.process(input)).thenReturn("processed");
var result = serviceUnderTest.execute(input);
assertThat(result)
.isNotNull()
.extracting(Result::getValue)
.isEqualTo("processed");
}
}
2. Faking Functional Interfaces
Instead of mocking:
@Mock
private Validator<String> validator;
@Test
void test_validation() {
when(validator.validate("test")).thenReturn(ValidationResult.valid());
}
Create a fake:
@Test
void should_accept_valid_input() {
Validator<String> validator = input ->
input.length() > 5 ? ValidationResult.valid() : ValidationResult.invalid("Too short");
var service = new ServiceUnderTest(validator);
var result = service.process("valid-input");
assertThat(result).isNotNull();
}
3. Domain Objects - Never Mock
@Mock
private User user;
@Test
void test_user_processing() {
when(user.getName()).thenReturn("John");
when(user.getAge()).thenReturn(30);
}
@Test
void should_process_adult_user() {
var user = User.builder()
.name("John")
.age(30)
.build();
var result = service.processUser(user);
assertThat(result.isAdult()).isTrue();
}
4. Test Builders
Create builders for complex domain objects:
public class OrderTestBuilder {
private String orderId = "default-id";
private List<OrderItem> items = new ArrayList<>();
private OrderStatus status = OrderStatus.PENDING;
private LocalDateTime createdAt = LocalDateTime.now();
public static OrderTestBuilder anOrder() {
return new OrderTestBuilder();
}
public OrderTestBuilder withId(String orderId) {
this.orderId = orderId;
return this;
}
public OrderTestBuilder withItems(OrderItem... items) {
this.items = Arrays.asList(items);
return this;
}
public OrderTestBuilder withStatus(OrderStatus status) {
this.status = status;
return this;
}
public OrderTestBuilder completed() {
this.status = OrderStatus.COMPLETED;
;
}
Order {
(orderId, items, status, createdAt);
}
}
{
anOrder()
.withItems(
(, Money.of()),
(, Money.of())
)
.completed()
.build();
service.calculateTotal(order);
assertThat(total).isEqualTo(Money.of());
}
5. AssertJ Chaining
@Test
void should_return_filtered_and_sorted_users() {
var users = List.of(
new User("Alice", 30),
new User("Bob", 25),
new User("Charlie", 35)
);
var result = service.getAdultUsersSortedByAge(users);
assertThat(result)
.hasSize(3)
.extracting(User::getName)
.containsExactly("Bob", "Alice", "Charlie");
}
@Test
void should_create_valid_response_with_all_fields() {
var request = new Request("data");
var response = service.handle(request);
assertThat(response)
.isNotNull()
.satisfies(r -> {
assertThat(r.getStatus()).isEqualTo(Status.SUCCESS);
assertThat(r.getMessage()).isNotEmpty();
assertThat(r.getTimestamp()).isBeforeOrEqualTo(LocalDateTime.now());
});
}
6. Testing Exceptions
@Test
void should_throw_exception_when_input_is_invalid() {
var invalidInput = new Input(null);
assertThatThrownBy(() -> service.process(invalidInput))
.isInstanceOf(InvalidInputException.class)
.hasMessage("Input cannot be null")
.hasNoCause();
}
@Test
void should_throw_exception_with_proper_context() {
var input = new Input("invalid");
assertThatExceptionOfType(ValidationException.class)
.isThrownBy(() -> service.validate(input))
.withMessageContaining("invalid")
.satisfies(ex -> {
assertThat(ex.getErrorCode()).isEqualTo("VALIDATION_FAILED");
assertThat(ex.getFields()).contains("input");
});
}
7. Parameterized Tests
@ParameterizedTest
@MethodSource("provideInvalidInputs")
void should_reject_invalid_inputs(String input, String expectedError) {
assertThatThrownBy(() -> service.process(input))
.isInstanceOf(ValidationException.class)
.hasMessageContaining(expectedError);
}
private static Stream<Arguments> provideInvalidInputs() {
return Stream.of(
Arguments.of(null, "cannot be null"),
Arguments.of("", "cannot be empty"),
Arguments.of(" ", "cannot be blank")
);
}
@ParameterizedTest
@CsvSource({
"10, 20, 30",
"5, 15, 20",
"100, 200, 300"
})
void should_sum_two_numbers(int a, int b, int expected) {
var result = calculator.add(a, b);
assertThat(result).isEqualTo(expected);
}
8. Verification Patterns
@Test
void should_call_external_service_with_correct_parameters() {
var request = new Request("data");
service.processRequest(request);
verify(externalService).process(argThat(arg ->
arg.getData().equals("data") &&
arg.getTimestamp() != null
));
}
@Test
void should_not_call_service_when_cache_hit() {
var key = "cached-key";
when(cache.get(key)).thenReturn(Optional.of("cached-value"));
service.getValue(key);
verify(externalService, never()).fetchValue(key);
}
Verification Best Practices:
- Prefer
verify() with argument matchers over capturing arguments
- Use
argThat() for complex argument validation instead of ArgumentCaptor
- Only capture arguments when you need to perform multiple assertions on them
Stubbing Strategy: Explicit vs Lenient
General Rule: Be explicit in when() blocks - Use specific parameter values instead of any() when the parameter value matters to your test.
@Test
void should_fetch_customer_by_specific_id() {
var customerId = "customer-123";
var customer = aCustomer().withId(customerId).build();
when(customerRepository.findById(customerId)).thenReturn(Optional.of(customer));
var result = service.getCustomer(customerId);
assertThat(result).isEqualTo(customer);
}
@Test
void should_fetch_customer_by_specific_id() {
var customerId = "customer-123";
var customer = aCustomer().withId(customerId).build();
when(customerRepository.findById(any())).thenReturn(Optional.of(customer));
var result = service.getCustomer(customerId);
assertThat(result).isEqualTo(customer);
verify(customerRepository).findById(customerId);
}
Benefits of being explicit:
- Catches bugs earlier - If your code passes wrong parameters, the test fails immediately with "unexpected method call"
- Self-documenting - The
given section clearly shows expected inputs
- Less noise - No redundant
verify() calls just to check parameters
- Clearer intent - Shows you care about what is passed, not just that something was called
When to use any():
Use any() only when the parameter truly doesn't matter for the test scenario:
@Test
void should_log_all_requests_regardless_of_content() {
when(logger.log(any())).thenReturn(true);
service.handleRequest(request1);
service.handleRequest(request2);
verify(logger, times(2)).log(any());
}
@Test
void should_sanitize_all_user_inputs() {
when(sanitizer.clean(any())).thenAnswer(inv -> inv.getArgument(0) + "-cleaned");
var result1 = service.process("input1");
var result2 = service.process("input2");
assertThat(result1).endsWith("-cleaned");
assertThat(result2).endsWith("-cleaned");
}
For complex parameters, use argThat() instead:
@Test
void should_save_order_with_correct_customer_id() {
var customerId = "customer-123";
var request = new OrderRequest(customerId, items);
service.createOrder(request);
verify(orderRepository).save(argThat(order ->
order.getCustomerId().equals(customerId) &&
order.getStatus() == OrderStatus.PENDING
));
}
9. Testing Collections
@Test
void should_return_users_with_expected_properties() {
var filter = new UserFilter(minAge = 18);
var users = service.findUsers(filter);
assertThat(users)
.isNotEmpty()
.allSatisfy(user -> assertThat(user.getAge()).isGreaterThanOrEqualTo(18))
.extracting(User::getName)
.containsExactlyInAnyOrder("Alice", "Bob", "Charlie");
}
@Test
void should_group_items_by_category() {
var items = List.of(
new Item("A", Category.FOOD),
new Item("B", Category.ELECTRONICS),
new Item("C", Category.FOOD)
);
var grouped = service.groupByCategory(items);
assertThat(grouped)
.containsOnlyKeys(Category.FOOD, Category.ELECTRONICS)
.hasEntrySatisfying(Category.FOOD,
foodItems -> assertThat(foodItems).hasSize(2))
.hasEntrySatisfying(Category.ELECTRONICS,
electronicItems -> assertThat(electronicItems).hasSize(1));
}
10. Nested Tests for Organization
@Nested
@DisplayName("When processing valid orders")
class ValidOrderProcessing {
@Test
void should_accept_order_with_items() {
var order = anOrder().withItems(someItems()).build();
var result = service.process(order);
assertThat(result.isSuccess()).isTrue();
}
@Test
void should_send_confirmation_email() {
var order = anOrder().build();
service.process(order);
verify(emailService).sendConfirmation(order.getCustomerEmail());
}
}
@Nested
@DisplayName("When processing invalid orders")
class InvalidOrderProcessing {
@Test
void should_reject_empty_order() {
var order = anOrder().withItems().build();
assertThatThrownBy(() -> service.process(order))
.isInstanceOf(EmptyOrderException.class);
}
}
Using Context7 MCP for Documentation
When writing tests, use the context7 MCP to fetch current documentation:
// Query JUnit 5 features
context7:search("JUnit 5 parameterized tests")
// Query AssertJ assertions
context7:search("AssertJ collection assertions")
// Query Mockito verification
context7:search("Mockito argument matchers")
Test Coverage and Quality Guidelines
Coverage Principles
- Test edge cases and boundary conditions explicitly - Don't just test the happy path
- Write tests for error paths, not just happy paths - Verify exception handling and error scenarios
- Cover null handling, empty collections, and invalid states - These are common sources of bugs
- Test boundary values - For numeric inputs, test min, max, zero, negative values
- Test state transitions - Verify objects behave correctly as they move through different states
What to Test
@Test
void should_handle_empty_list() {
var emptyList = List.of();
var result = service.process(emptyList);
assertThat(result).isEmpty();
}
@Test
void should_handle_single_item() {
var singleItem = List.of(item);
var result = service.process(singleItem);
assertThat(result).hasSize(1);
}
@Test
void should_throw_exception_when_required_field_is_null() {
var invalidRequest = new Request(null, "value");
assertThatThrownBy(() -> service.process(invalidRequest))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("required field cannot be null");
}
@Test
void should_handle_external_service_failure_gracefully() {
(externalService.call()).thenThrow( ());
service.processWithFallback();
assertThat(result.isSuccess()).isFalse();
assertThat(result.getErrorMessage()).contains();
}
{
anOrder().withStatus(OrderStatus.PENDING).build();
order.complete();
assertThat(order.getStatus()).isEqualTo(OrderStatus.COMPLETED);
assertThat(order.getCompletedAt()).isNotNull();
}
{
anOrder().withStatus(OrderStatus.CANCELLED).build();
assertThatThrownBy(() -> order.complete())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining();
}
Checklist Before Writing Tests
- ✅ Identify all dependencies - which should be mocked vs faked vs real?
- ✅ Check for existing test builders or fixtures
- ✅ Ensure domain objects are never mocked
- ✅ Use AssertJ for all assertions
- ✅ Follow given/when/then structure
- ✅ Use
lower_snake_case for test names
- ✅ Verify tests are independent and don't share state
- ✅ Ensure test names clearly describe the scenario and expected outcome
Common Pitfalls to Avoid
- ❌ Mocking domain objects
- ❌ Using JUnit assertions instead of AssertJ
- ❌ Mocking functional interfaces instead of faking them
- ❌ Over-mocking - only mock what crosses architectural boundaries
- ❌ Testing implementation details instead of behavior
- ❌ Sharing mutable state between tests
- ❌ Using
CamelCase or camelCase for test method names
- ❌ Missing or incorrect given/when/then annotations
- ❌ Creating complex domain objects when builders exist
- ❌ Using
any() in when() blocks when you should be explicit about expected parameters
- ❌ Adding
verify() calls just to check parameters that should have been explicit in when()
Examples of Complete Test Classes
Service with Repository and Domain Objects
class OrderServiceTest {
private final OrderRepository orderRepository = mock(OrderRepository.class);
private final PaymentGateway paymentGateway = mock(PaymentGateway.class);
private final OrderService orderService = new OrderService(orderRepository, paymentGateway);
@Test
void should_create_and_save_order_successfully() {
var orderRequest = new OrderRequest(
"customer-123",
List.of(new OrderItem("product-1", 2))
);
var savedOrder = anOrder()
.withId("order-456")
.withCustomerId("customer-123")
.build();
when(orderRepository.save(any(Order.class))).thenReturn(savedOrder);
var result = orderService.createOrder(orderRequest);
assertThat(result)
.isNotNull()
.satisfies(order -> {
assertThat(order.getId()).isEqualTo("order-456");
assertThat(order.getCustomerId()).isEqualTo();
assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
});
verify(orderRepository).save(argThat(order ->
order.getCustomerId().equals() &&
order.getItems().size() ==
));
}
{
anOrder()
.withId()
.withStatus(OrderStatus.PENDING)
.build();
(, order.getTotal());
(orderRepository.findById()).thenReturn(Optional.of(order));
(paymentGateway.processPayment(paymentRequest)).thenReturn(
(, )
);
orderService.processPayment();
verify(orderRepository).save(argThat(saved ->
saved.getStatus() == OrderStatus.PAID &&
saved.getPaymentTransactionId().equals()
));
}
}
Testing with Functional Interface (Fake, not Mock)
class ValidationServiceTest {
@Test
void should_validate_email_format() {
EmailValidator emailValidator = email ->
email.contains("@") && email.contains(".");
var service = new ValidationService(emailValidator);
var validEmail = "user@example.com";
var invalidEmail = "invalid-email";
var validResult = service.validateEmail(validEmail);
var invalidResult = service.validateEmail(invalidEmail);
assertThat(validResult.isValid()).isTrue();
assertThat(invalidResult.isValid()).isFalse();
}
@Test
void should_apply_custom_business_rule() {
BusinessRule<Order> minimumOrderRule = order ->
order.getTotal().compareTo(Money.of(10.00)) >= 0;
var service = new OrderValidationService(minimumOrderRule);
var anOrder().withTotal(Money.of()).build();
anOrder().withTotal(Money.of()).build();
service.validate(validOrder);
service.validate(invalidOrder);
assertThat(validResult.isValid()).isTrue();
assertThat(invalidResult.isValid()).isFalse();
assertThat(invalidResult.getError())
.contains();
}
}