Unit Testing Patterns
Overview
Unit tests verify individual components in isolation with mocked dependencies. They are the foundation of the testing pyramid, providing fast feedback for logic errors.
Unit Testing Characteristics
| Aspect | Detail |
|---|---|
| Scope | Single class/method in isolation |
| Dependencies | All mocked (repositories, services, external APIs) |
| Speed | Milliseconds per test |
| Framework | JUnit 5 + Mockito, no Spring context |
| Database | None - all interactions mocked |
| Failure Cause | Clearly pinpoints logic error |
JUnit 5 & Mockito Setup
Annotations
@ExtendWith(MockitoExtension.class) // Enable Mockito for this test class
@MockitoSettings(strictness = Strictness.LENIENT) // Optional: lenient stub checking
class MyServiceTest {
@Mock // Create mock dependency
private UserRepository userRepository;
@InjectMocks // Inject mocks into service
private UserService userService;
@Test // This is a test method
void someTest() { }
@BeforeEach // Setup before each test
void setup() { }
}Basic Assertions
// Static imports
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
// Assertions
assertEquals(expected, actual);
assertNotNull(value);
assertThrows(Exception.class, () -> method());
assertTrue(condition);
assertFalse(condition);Enum Unit Tests
Enums often look “covered” because other tests reference constants, but JaCoCo will still report missed instructions if the enum contains executable logic (helper methods, parsing, switch expressions, static category sets) that is never invoked.
Guideline: - If an enum contains non-trivial methods, add a
small unit test in src/test/java/.../enums/ that
calls the helper methods directly.
Concrete example in this repository: -
src/test/java/com/smartsupplypro/inventory/enums/StockChangeReasonTest.java
Mapper Unit Tests
Mappers in this repository are static utility classes that often include executable logic beyond simple field copies (null-safety branches, computed fields, string sanitization, enum parsing, and utility-class constructor guards). These details are frequently exercised only indirectly via service/controller tests, which can leave JaCoCo reporting missed branches/instructions.
Guideline: - If a mapper contains non-trivial logic or
defensive branches, add a small unit test under
src/test/java/com/smartsupplypro/inventory/mapper/
that calls the mapper methods directly.
Concrete examples in this repository: -
src/test/java/com/smartsupplypro/inventory/mapper/InventoryItemMapperTest.java
-
src/test/java/com/smartsupplypro/inventory/mapper/StockHistoryMapperTest.java
-
src/test/java/com/smartsupplypro/inventory/mapper/SupplierMapperTest.java
Design note: - Prefer black-box tests via public mapper methods. Use reflection only when required to cover a private helper branch that cannot be reliably triggered through the public mapping API.
Utility & Builder Unit Tests
Some of the most important JaCoCo gaps in a Spring Boot codebase are not in services/controllers, but in small “infrastructure helpers” and DTO builders that implement fallback logic.
Typical examples: - Environment/profile driven toggles (dialect selection, feature flags) - DTO builders with defensive defaults (blank/null fallbacks, auto-generation of IDs/timestamps)
Guideline: - Prefer pure unit tests with mocks for the minimal dependency surface. - Explicitly cover both sides of decision points (match vs no match, null/blank vs value). - Use reflection only when the production API intentionally does not expose setters but you still need to lock down “already set” branches in the implementation.
Concrete examples in this repository: -
src/test/java/com/smartsupplypro/inventory/repository/custom/util/DatabaseDialectDetectorTest.java
(mocked {@code
Environment#getActiveProfiles()} to cover profile matching
branches) -
src/test/java/com/smartsupplypro/inventory/exception/ErrorResponseBuilderTest.java
(covers {@link
com.smartsupplypro.inventory.exception.dto.ErrorResponse.Builder}
fallbacks and auto-generation)
Entity (Model) Unit Tests
Some model entities contain lightweight lifecycle logic (for
example, defaulting audit fields or resolving denormalized
foreign keys) in JPA callback methods like
@PrePersist.
Guideline: - If an entity contains executable lifecycle
logic, add a small unit test under
src/test/java/com/smartsupplypro/inventory/model/
that invokes the callback method directly.
Design notes: - Keep these tests pure Java (no Spring/JPA context). The goal is to validate deterministic logic, not persistence behavior. - Prefer explicit Arrange/Act/Assert comments and cover both “field missing” and “field already set” paths.
Concrete examples in this repository: -
src/test/java/com/smartsupplypro/inventory/model/InventoryItemTest.java
(covers InventoryItem#onCreate()) -
src/test/java/com/smartsupplypro/inventory/model/StockHistoryTest.java
(covers StockHistory#prePersist())
Validator Unit Tests
In this repository, validator tests are organized as a small set of focused suites so that each validator contract (base DTO checks, uniqueness rules, authorization/field-level guards, and special-case business rules) can be exercised directly without duplicating service/controller tests.
Current validator-related suites under
src/test/java/com/smartsupplypro/inventory/validation/:
- InventoryItemValidatorTest -
InventoryItemValidatorBusinessRulesTest
(incremental business rule branches) -
InventoryItemSecurityValidatorTest
(RBAC/field-level update guards) -
SupplierValidatorTest -
SupplierValidatorUniquenessAndDeletionEdgeCasesTest
(incremental edge cases) -
StockHistoryValidationTest -
StockHistoryValidatorPriceChangeAndEnumValidationTest
(PRICE_CHANGE + enum whitelist)
Example: InventoryItemValidatorTest
@ExtendWith(MockitoExtension.class)
class InventoryItemValidatorTest {
@Mock
private InventoryItemRepository repo;
private InventoryItemDTO validDTO() {
return InventoryItemDTO.builder()
.name("Monitor")
.quantity(10)
.price(new BigDecimal("199.99"))
.supplierId("supplier-1")
.build();
}
// ========== validateBase() tests ==========
@Test
void validateBase_validInput_passes() {
assertDoesNotThrow(() ->
InventoryItemValidator.validateBase(validDTO())
);
}
@Test
void validateBase_nullName_throwsException() {
InventoryItemDTO dto = validDTO();
dto.setName(null);
Exception e = assertThrows(IllegalArgumentException.class, () ->
InventoryItemValidator.validateBase(dto)
);
assertEquals("Product name cannot be null or empty", e.getMessage());
}
@Test
void validateBase_negativeQuantity_throwsException() {
InventoryItemDTO dto = validDTO();
dto.setQuantity(-1);
assertThrows(IllegalArgumentException.class, () ->
InventoryItemValidator.validateBase(dto)
);
}
@Test
void validateBase_zeroPriceOrBelow_throwsException() {
InventoryItemDTO dto = validDTO();
dto.setPrice(BigDecimal.ZERO);
assertThrows(IllegalArgumentException.class, () ->
InventoryItemValidator.validateBase(dto)
);
}
// ========== validateInventoryItemNotExists() tests ==========
@Test
void validateDuplicate_noExisting_passes() {
when(repo.findByNameIgnoreCase("Monitor"))
.thenReturn(List.of());
assertDoesNotThrow(() ->
InventoryItemValidator.validateInventoryItemNotExists(
"Monitor", new BigDecimal("199.99"), repo)
);
}
@Test
void validateDuplicate_existingWithSameName_Price_throwsConflict() {
InventoryItem existing = InventoryItem.builder()
.id("item-1")
.name("Monitor")
.price(new BigDecimal("199.99"))
.build();
when(repo.findByNameIgnoreCase("Monitor"))
.thenReturn(List.of(existing));
assertThrows(DuplicateResourceException.class, () ->
InventoryItemValidator.validateInventoryItemNotExists(
"Monitor", new BigDecimal("199.99"), repo)
);
}
@Test
void validateDuplicate_sameName_differentPrice_passes() {
InventoryItem existing = InventoryItem.builder()
.id("item-1")
.name("Monitor")
.price(new BigDecimal("199.99"))
.build();
when(repo.findByNameIgnoreCase("Monitor"))
.thenReturn(List.of(existing));
// Different price should NOT throw
assertDoesNotThrow(() ->
InventoryItemValidator.validateInventoryItemNotExists(
"Monitor", new BigDecimal("249.99"), repo) // ← Different price
);
}
// ========== validateExists() tests ==========
@Test
void validateExists_itemExists_returnsItem() {
InventoryItem item = InventoryItem.builder()
.id("item-1")
.name("Monitor")
.build();
when(repo.findById("item-1"))
.thenReturn(Optional.of(item));
InventoryItem result = InventoryItemValidator.validateExists("item-1", repo);
assertEquals("item-1", result.getId());
}
@Test
void validateExists_itemNotFound_throws404() {
when(repo.findById("nonexistent"))
.thenReturn(Optional.empty());
assertThrows(ResponseStatusException.class, () ->
InventoryItemValidator.validateExists("nonexistent", repo)
);
}
// ========== assertFinalQuantityNonNegative() tests ==========
@Test
void assertFinalQuantity_zeroAllowed() {
assertDoesNotThrow(() ->
InventoryItemValidator.assertFinalQuantityNonNegative(0)
);
}
@Test
void assertFinalQuantity_positive_passes() {
assertDoesNotThrow(() ->
InventoryItemValidator.assertFinalQuantityNonNegative(100)
);
}
@Test
void assertFinalQuantity_negative_throws422() {
ResponseStatusException e = assertThrows(ResponseStatusException.class, () ->
InventoryItemValidator.assertFinalQuantityNonNegative(-50)
);
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, e.getStatusCode());
}
}Service Unit Tests
Example: InventoryItemServiceImplSaveTest
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class InventoryItemServiceImplSaveTest {
@Mock private InventoryItemRepository repository;
@Mock private SupplierRepository supplierRepository;
@Mock private StockHistoryService stockHistoryService;
@Mock private InventoryItemValidationHelper validationHelper;
@Mock private InventoryItemAuditHelper auditHelper;
@InjectMocks
private InventoryItemServiceImpl service;
private InventoryItemDTO baseDto;
@BeforeEach
void setup() {
// Authenticate as OAuth2 user for service context
InventoryItemServiceImplTestHelper.authenticateAsOAuth2("admin", "ADMIN");
baseDto = new InventoryItemDTO();
baseDto.setName("Widget");
baseDto.setQuantity(100);
baseDto.setPrice(new BigDecimal("10.00"));
baseDto.setSupplierId("S1");
baseDto.setCreatedBy("admin");
// Lenient mocking - don't fail on unexpected calls
lenient().when(supplierRepository.existsById(anyString()))
.thenReturn(true);
lenient().when(repository.existsByNameIgnoreCase(anyString()))
.thenReturn(false);
}
@Test
@DisplayName("save: returns saved item with generated ID and logs INITIAL_STOCK")
void save_shouldReturnSavedItem() {
// Map DTO to entity
InventoryItem toPersist = InventoryItemMapper.toEntity(baseDto);
// Create saved entity with generated ID
InventoryItem saved = copyOf(toPersist);
saved.setId("item-1");
// Mock repository to return saved entity
when(repository.save(any(InventoryItem.class)))
.thenReturn(saved);
// Execute
InventoryItemDTO result = service.save(baseDto);
// Verify returned item has ID
assertEquals("item-1", result.getId());
assertEquals(new BigDecimal("10.00"), result.getPrice());
// Verify audit helper called (which logs INITIAL_STOCK)
verify(auditHelper).logInitialStock(any(InventoryItem.class));
}
@Test
@DisplayName("save: duplicate name throws 409 CONFLICT")
void save_duplicateName_throwsConflict() {
// Mock repository to find duplicate
when(repository.existsByNameIgnoreCase(anyString()))
.thenReturn(true);
// Should throw before reaching repository.save()
assertThrows(DuplicateResourceException.class, () ->
service.save(baseDto)
);
}
@Test
@DisplayName("save: supplier not found throws 404 NOT_FOUND")
void save_supplierNotFound_throws404() {
// Mock supplier not existing
when(supplierRepository.existsById("S1"))
.thenReturn(false);
assertThrows(ResponseStatusException.class, () ->
service.save(baseDto)
);
}
@Test
@DisplayName("save: missing supplier ID throws 400 BAD_REQUEST")
void save_missingSupplierId_throwsBadRequest() {
baseDto.setSupplierId(null);
assertThrows(InvalidRequestException.class, () ->
service.save(baseDto)
);
}
}Parameterized Tests
Testing Multiple Scenarios
@ParameterizedTest
@ValueSource(strings = { "", " ", " " })
@DisplayName("validateBase: blank names rejected")
void validateBase_blankNames_throw(String blankName) {
InventoryItemDTO dto = validDTO();
dto.setName(blankName);
assertThrows(IllegalArgumentException.class, () ->
InventoryItemValidator.validateBase(dto)
);
}
@ParameterizedTest
@ValueSource(ints = { -1, -100, Integer.MIN_VALUE })
@DisplayName("validateBase: negative quantities rejected")
void validateBase_negativeQuantities_throw(int qty) {
InventoryItemDTO dto = validDTO();
dto.setQuantity(qty);
assertThrows(IllegalArgumentException.class, () ->
InventoryItemValidator.validateBase(dto)
);
}
@ParameterizedTest
@CsvSource({
"0.00, true", // Zero - should throw
"-1.50, true", // Negative - should throw
"0.01, false", // Positive - should pass
"999.99, false" // Positive - should pass
})
@DisplayName("validateBase: price validation")
void validateBase_priceValidation(String priceStr, boolean shouldThrow) {
InventoryItemDTO dto = validDTO();
dto.setPrice(new BigDecimal(priceStr));
if (shouldThrow) {
assertThrows(IllegalArgumentException.class, () ->
InventoryItemValidator.validateBase(dto)
);
} else {
assertDoesNotThrow(() ->
InventoryItemValidator.validateBase(dto)
);
}
}Mocking Best Practices
When to Use Mocks vs Real Objects
// ✅ GOOD: Mock external dependencies
@Mock private InventoryItemRepository repo; // External DB
@Mock private StockHistoryService service; // External service
@Mock private HttpClient httpClient; // External API
// ❌ AVOID: Mock the object being tested
@Mock private InventoryItemValidator validator; // ← Don't do this
// ✅ GOOD: Use real objects for value types
private BigDecimal price = new BigDecimal("25.99");
private InventoryItemDTO dto = InventoryItemDTO.builder()...build();Stubbing vs Verification
// STUBBING - Tell mock what to return
when(repo.findById("item-1"))
.thenReturn(Optional.of(item));
// VERIFICATION - Verify mock was called correctly
verify(repo).save(any(InventoryItem.class));
verify(repo, times(2)).findById(anyString());
verify(repo, never()).delete(any());
// LENIENT - Don't fail on unexpected calls
lenient().when(repo.existsById(anyString()))
.thenReturn(true);Test Organization Patterns
1. Given-When-Then (GWT) Pattern
@Test
@DisplayName("create: valid data creates item and returns 201")
void create_validData_returns201() {
// GIVEN - Setup initial state
InventoryItemDTO dto = validDTO();
InventoryItem savedItem = InventoryItemMapper.toEntity(dto);
savedItem.setId("item-123");
when(repository.save(any())).thenReturn(savedItem);
// WHEN - Execute the action
InventoryItemDTO result = service.create(dto);
// THEN - Verify the result
assertNotNull(result.getId());
assertEquals("item-123", result.getId());
verify(repository).save(any());
}2. Arrange-Act-Assert (AAA) Pattern
@Test
void validateExists_itemNotFound_throws404() {
// ARRANGE - Setup dependencies
when(repo.findById("nonexistent"))
.thenReturn(Optional.empty());
// ACT - Execute the method
ResponseStatusException exception = assertThrows(
ResponseStatusException.class, () ->
InventoryItemValidator.validateExists("nonexistent", repo)
);
// ASSERT - Verify outcomes
assertEquals(HttpStatus.NOT_FOUND, exception.getStatusCode());
}3. Test Method Naming
// Pattern: [methodName]_[scenario]_[expectedResult]
void validateBase_nullName_throwsException()
void validateBase_validInput_passes()
void validateDuplicate_sameNameAndPrice_throwsConflict()
void validateDuplicate_sameName_differentPrice_passes()
void assertFinalQuantity_negative_throws422()Common Pitfalls
❌ Overly Complex Mocking
// Bad: Complex setup obscures test purpose
when(repository.save(argThat(item ->
item.getName() != null &&
item.getPrice().compareTo(BigDecimal.ZERO) > 0 &&
item.getQuantity() >= 0
))).thenReturn(...);
// Good: Simple, clear setup
when(repository.save(any(InventoryItem.class)))
.thenReturn(savedItem);❌ Testing Implementation Details
// Bad: Overly specific verification
verify(repository, times(1)).findById(eq("item-1"));
verify(mapper, times(1)).toDTO(any());
// Good: Verify behavior, not implementation
verify(repository).save(any(InventoryItem.class));
assertEquals(expected, result);❌ Test Interdependencies
// Bad: Tests depend on each other
@Test void test1() { repo.save(item); }
@Test void test2() { assertEquals(1, repo.count()); } // Depends on test1!
// Good: Each test is independent
@BeforeEach void setup() { /* Reset state */ }
@Test void test1() { /* Complete setup */ }
@Test void test2() { /* Independent setup */ }Best Practices Checklist
✅ DO
❌ DON’T
Related Documentation
- Testing Index - Complete testing strategy overview
- Test Fixtures & Data Builders - Helper patterns and test data creation
- Integration Testing - Repository and database testing
- Security Testing - Authentication and authorization tests
- Validation Framework - Validator examples and patterns