⬅️ Back to Testing Index

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



⬅️ Back to Testing Index