Unit test patterns with JUnit 5, Mockito, and Hamcrest — covers test structure, mocking, assertions, test data builders, and naming conventions
Unit tests verify individual components in isolation using mocks for external dependencies. Every service, mapper, and non-trivial utility must have corresponding unit tests. This skill defines the test structure, mocking patterns, assertion conventions, and test data strategies that produce reliable, readable, and maintainable tests.
Core principles:
| Rule | Requirement | Level | Key Point |
|---|---|---|---|
| UT01 | Use MockitoExtension | MUST | @ExtendWith(MockitoExtension.class) on every test class |
| UT02 | Use @InjectMocks for class under test | MUST | Dependencies use @Mock, never manual instantiation |
| UT03 | Follow Given-When-Then structure | MUST | Three sections with comments in every test |
| UT04 | Use descriptive test names | MUST | Format: test_{method}{scenario}{result} |
| UT05 | Use when().thenReturn() for stubbing | MUST | thenReturn, thenAnswer, or thenThrow for mocks |
| UT06 | Use verify() for interaction verification | MUST | verify, times, never, verifyNoMoreInteractions |
| UT07 | Use ArgumentCaptor for complex verification | SHOULD | Capture and inspect arguments passed to mocks |
| UT08 | Prefer Hamcrest for readable assertions | SHOULD | is, equalTo, hasSize, contains, allOf matchers |
| UT09 | Use JUnit 5 for exception assertions | MUST | assertThrows with message verification |
| UT10 | Use fluent builder pattern for test data | SHOULD | Builder classes with sensible defaults |
| UT11 | Provide sensible defaults | SHOULD | withDefaults() method, override only relevant fields |
| UT12 | Use @ParameterizedTest for multiple scenarios | SHOULD | @MethodSource for complex, @ValueSource for simple |
| UT13 | Use @ValueSource for simple cases | MAY | Strings, ints; combine with @NullAndEmptySource |
| Framework | Purpose |
|---|---|
| JUnit 5 (Jupiter) | Core testing framework |
| Mockito | Mocking and stubbing dependencies |
| Hamcrest | Readable assertion matchers |
| AssertJ | Fluent assertions (alternative to Hamcrest) |
Requirement Level: MUST
Every unit test class must use @ExtendWith(MockitoExtension.class):
@ExtendWith(MockitoExtension.class)
class ContractServiceTest {
@Mock
private ContractRepository contractRepository;
@Mock
private ApplicationEventPublisher eventPublisher;
@InjectMocks
private ContractService contractService;
// tests...
}
Requirement Level: MUST
@InjectMocks@MockRequirement Level: MUST
Every test must have three clearly separated sections with comments:
@Test
void test_transitionStatus_validTransition_updatesStatusAndPublishesEvent() {
// Given
Contract contract = ContractBuilder.newBuilder()
.withId("contract-1")
.withStatus(ContractStatus.DRAFT)
.withDefaults()
.build();
when(contractRepository.findById("contract-1")).thenReturn(Optional.of(contract));
when(contractRepository.save(any(Contract.class))).thenAnswer(i -> i.getArgument(0));
// When
Contract result = contractService.transitionStatus("contract-1", ContractStatus.PENDING_APPROVAL);
// Then
assertThat(result.getStatus(), is(ContractStatus.PENDING_APPROVAL));
assertThat(result.getStatusHistory(), hasSize(1));
verify(contractRepository).save(contract);
verify(eventPublisher).publishEvent(any(ContractStatusChangedEvent.class));
}
Requirement Level: MUST
Format: test_{methodName}_{scenario}_{expectedResult}
| Pattern | Example |
|---|---|
| Success case | test_create_validContract_returnsCreatedContract |
| Error case | test_create_duplicateNumber_throwsDuplicateException |
| Null handling | test_getById_nonExistentId_throwsNotFoundException |
| Edge case | test_calculateScore_noInvoices_returnsMaxScore |
| Boundary | test_calculateScore_allOverdue_returnsZero |
| State transition | test_transitionStatus_draftToPending_succeeds |
| Invalid transition | test_transitionStatus_expiredToActive_throwsException |
Requirement Level: MUST
// Simple return value
when(contractRepository.findById("id-1")).thenReturn(Optional.of(contract));
// Return argument (for save operations)
when(contractRepository.save(any(Contract.class))).thenAnswer(i -> i.getArgument(0));
// Multiple calls return different values
when(contractRepository.findById("id-1"))
.thenReturn(Optional.of(contractV1))
.thenReturn(Optional.of(contractV2));
// Throw exception
when(contractRepository.findById("bad-id")).thenReturn(Optional.empty());
Requirement Level: MUST
Verify that the class under test interacts correctly with its dependencies:
// Verify method was called
verify(contractRepository).save(contract);
// Verify called exactly N times
verify(contractRepository, times(1)).save(any());
// Verify never called
verify(eventPublisher, never()).publishEvent(any());
// Verify no more interactions
verifyNoMoreInteractions(contractRepository);
// Verify no interactions at all
verifyNoInteractions(auditService);
Requirement Level: SHOULD
When you need to verify the content of arguments passed to mocked methods:
@Captor
private ArgumentCaptor<ContractStatusChangedEvent> eventCaptor;
@Test
void test_transitionStatus_publishesEventWithCorrectData() {
// Given
Contract contract = ContractBuilder.newBuilder()
.withId("c-1").withStatus(ContractStatus.DRAFT).withDefaults().build();
when(contractRepository.findById("c-1")).thenReturn(Optional.of(contract));
when(contractRepository.save(any())).thenAnswer(i -> i.getArgument(0));
// When
contractService.transitionStatus("c-1", ContractStatus.PENDING_APPROVAL);
// Then
verify(eventPublisher).publishEvent(eventCaptor.capture());
ContractStatusChangedEvent event = eventCaptor.getValue();
assertThat(event.getContract().getId(), is("c-1"));
assertThat(event.getPreviousStatus(), is(ContractStatus.DRAFT));
}
Requirement Level: SHOULD
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
// Equality
assertThat(result.getStatus(), is(ContractStatus.ACTIVE));
assertThat(result.getCompanyName(), equalTo("Acme Corp"));
// Null checks
assertThat(result, is(notNullValue()));
assertThat(result.getDeletedAt(), is(nullValue()));
// Collections
assertThat(result.getLineItems(), hasSize(3));
assertThat(result.getTags(), contains("enterprise", "premium"));
assertThat(result.getTags(), hasItem("enterprise"));
assertThat(result.getContracts(), is(empty()));
// Strings
assertThat(result.getContractNumber(), startsWith("CNT-"));
assertThat(result.getDescription(), containsString("renewal"));
// Numbers
assertThat(result.getHealthScore(), greaterThan(50));
assertThat(result.getTotal(), closeTo(BigDecimal.valueOf(100.00), BigDecimal.valueOf(0.01)));
// Combined
assertThat(result, allOf(
hasProperty("status", is(ContractStatus.ACTIVE)),
hasProperty("clientId", is("client-1"))
));
Requirement Level: MUST
@Test
void test_getById_nonExistentId_throwsEntityNotFoundException() {
// Given
when(contractRepository.findById("bad-id")).thenReturn(Optional.empty());
// When / Then
EntityNotFoundException exception = assertThrows(
EntityNotFoundException.class,
() -> contractService.getById("bad-id"));
assertThat(exception.getMessage(), containsString("bad-id"));
assertThat(exception.getMessage(), containsString("Contract"));
}
Requirement Level: SHOULD
Create builder classes that produce domain objects with sensible defaults:
public class ContractBuilder {
private String id;
private String clientId;
private String contractNumber;
private ContractStatus status = ContractStatus.DRAFT;
private Money value;
private LocalDate startDate;
private LocalDate endDate;
private List<LineItem> lineItems = new ArrayList<>();
private List<StatusChange> statusHistory = new ArrayList<>();
public static ContractBuilder newBuilder() {
return new ContractBuilder();
}
public ContractBuilder withId(String id) {
this.id = id;
return this;
}
public ContractBuilder withClientId(String clientId) {
this.clientId = clientId;
return this;
}
public ContractBuilder withStatus(ContractStatus status) {
this.status = status;
return this;
}
public ContractBuilder withDefaults() {
this.id = "contract-" + UUID.randomUUID().toString().substring(0, 8);
this.clientId = "client-default";
this.contractNumber = "CNT-" + System.currentTimeMillis();
this.value = new Money(BigDecimal.valueOf(10000), "USD");
this.startDate = LocalDate.now();
this.endDate = LocalDate.now().plusYears(1);
return this;
}
public Contract build() {
return Contract.builder()
.id(id)
.clientId(clientId)
.contractNumber(contractNumber)
.status(status)
.value(value)
.startDate(startDate)
.endDate(endDate)
.lineItems(lineItems)
.statusHistory(statusHistory)
.build();
}
}
Requirement Level: SHOULD
Every builder should have a withDefaults() method that sets all required fields to valid, non-null values. Tests should only override the specific fields relevant to the test scenario:
// Only set what matters for this test
Contract contract = ContractBuilder.newBuilder()
.withDefaults()
.withStatus(ContractStatus.EXPIRED) // this is what we're testing
.build();
Requirement Level: SHOULD
When testing multiple inputs with the same assertion pattern:
@ParameterizedTest
@MethodSource("validTransitions")
void test_transitionStatus_validTransitions_succeeds(
ContractStatus from, ContractStatus to) {
// Given
Contract contract = ContractBuilder.newBuilder()
.withDefaults().withStatus(from).build();
when(contractRepository.findById(any())).thenReturn(Optional.of(contract));
when(contractRepository.save(any())).thenAnswer(i -> i.getArgument(0));
// When
Contract result = contractService.transitionStatus(contract.getId(), to);
// Then
assertThat(result.getStatus(), is(to));
}
private static Stream<Arguments> validTransitions() {
return Stream.of(
Arguments.of(ContractStatus.DRAFT, ContractStatus.PENDING_APPROVAL),
Arguments.of(ContractStatus.PENDING_APPROVAL, ContractStatus.ACTIVE),
Arguments.of(ContractStatus.ACTIVE, ContractStatus.RENEWAL),
Arguments.of(ContractStatus.ACTIVE, ContractStatus.TERMINATED),
Arguments.of(ContractStatus.RENEWAL, ContractStatus.ACTIVE)
);
}
@ParameterizedTest
@MethodSource("invalidTransitions")
void test_transitionStatus_invalidTransitions_throwsException(
ContractStatus from, ContractStatus to) {
// Given
Contract contract = ContractBuilder.newBuilder()
.withDefaults().withStatus(from).build();
when(contractRepository.findById(any())).thenReturn(Optional.of(contract));
// When / Then
assertThrows(InvalidContractTransitionException.class,
() -> contractService.transitionStatus(contract.getId(), to));
}
private static Stream<Arguments> invalidTransitions() {
return Stream.of(
Arguments.of(ContractStatus.DRAFT, ContractStatus.ACTIVE),
Arguments.of(ContractStatus.EXPIRED, ContractStatus.ACTIVE),
Arguments.of(ContractStatus.TERMINATED, ContractStatus.DRAFT)
);
}
Requirement Level: MAY
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t"})
void test_create_blankCompanyName_throwsException(String name) {
CreateClientRequest request = CreateClientRequest.builder()
.companyName(name).build();
assertThrows(ConstraintViolationException.class,
() -> clientService.create(request));
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" "})
void test_create_invalidCompanyName_throwsException(String name) {
// covers null, "", and whitespace-only
}
src/test/java/com/enterprise/cms/
service/
ContractServiceTest.java
ClientServiceTest.java
HealthScoreServiceTest.java
mapper/
ContractMapperTest.java
ClientMapperTest.java
controller/
ContractControllerTest.java # @WebMvcTest (optional)
testdata/
ContractBuilder.java
ClientBuilder.java
InvoiceBuilder.java
Before submitting unit tests, verify:
@ExtendWith(MockitoExtension.class)@InjectMocks@Mocktest_{method}_{scenario}_{result} namingassertThrows() and verify the messageverify()