Skip to main content

Unit Testing Guide

Overview

This comprehensive guide covers unit testing practices for the Ink platform using JUnit 5, Mockito, and AssertJ. Learn how to write effective, maintainable unit tests that ensure code quality and reliability.

Target Audience: All developers
Prerequisites: Java programming, JUnit basics
Estimated Time: 45-60 minutes

Prerequisites

  • Java 21+ knowledge
  • Understanding of JUnit 5
  • Familiarity with Mockito
  • Basic testing principles

Testing Architecture

Installation Steps

1. Testing Dependencies

<!-- filepath: /Users/jetstart/dev/jetrev/ink/pom.xml -->
<!-- ...existing code... -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<!-- ...existing code... -->

2. Test Configuration

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/config/TestConfig.java
@TestConfiguration
public class TestConfig {

@Bean
@Primary
public Clock fixedClock() {
return Clock.fixed(
Instant.parse("2024-01-01T00:00:00Z"),
ZoneId.of("UTC")
);
}

@Bean
public TestDataBuilder testDataBuilder() {
return new TestDataBuilder();
}
}

3. Base Test Class

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/base/BaseUnitTest.java
@ExtendWith(MockitoExtension.class)
public abstract class BaseUnitTest {

@Mock
protected Clock clock;

@BeforeEach
void setUpBase() {
when(clock.instant()).thenReturn(Instant.parse("2024-01-01T00:00:00Z"));
when(clock.getZone()).thenReturn(ZoneId.of("UTC"));
}

protected <T> T createMock(Class<T> clazz) {
return Mockito.mock(clazz);
}
}

Configuration

Service Layer Testing

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/service/UserServiceTest.java
@ExtendWith(MockitoExtension.class)
class UserServiceTest {

@Mock
private UserRepository userRepository;

@Mock
private PasswordEncoder passwordEncoder;

@Mock
private UserMapper userMapper;

@InjectMocks
private UserService userService;

private User testUser;
private CreateUserRequest createRequest;

@BeforeEach
void setUp() {
testUser = User.builder()
.id(1L)
.username("testuser")
.email("test@example.com")
.password("encodedPassword")
.roles(Set.of("USER"))
.build();

createRequest = CreateUserRequest.builder()
.username("testuser")
.email("test@example.com")
.password("Password123!")
.roles(List.of("USER"))
.build();
}

@Test
@DisplayName("Should create user successfully")
void shouldCreateUserSuccessfully() {
// Given
when(userRepository.existsByUsername(createRequest.getUsername()))
.thenReturn(false);
when(userRepository.existsByEmail(createRequest.getEmail()))
.thenReturn(false);
when(passwordEncoder.encode(createRequest.getPassword()))
.thenReturn("encodedPassword");
when(userRepository.save(any(User.class)))
.thenReturn(testUser);
when(userMapper.toDto(testUser))
.thenReturn(new UserDto());

// When
UserDto result = userService.createUser(createRequest);

// Then
assertThat(result).isNotNull();
verify(userRepository).existsByUsername(createRequest.getUsername());
verify(userRepository).existsByEmail(createRequest.getEmail());
verify(passwordEncoder).encode(createRequest.getPassword());
verify(userRepository).save(any(User.class));
verify(userMapper).toDto(testUser);
}

@Test
@DisplayName("Should throw exception when username already exists")
void shouldThrowExceptionWhenUsernameExists() {
// Given
when(userRepository.existsByUsername(createRequest.getUsername()))
.thenReturn(true);

// When & Then
assertThatThrownBy(() -> userService.createUser(createRequest))
.isInstanceOf(UserAlreadyExistsException.class)
.hasMessage("Username already exists: testuser");

verify(userRepository).existsByUsername(createRequest.getUsername());
verify(userRepository, never()).save(any(User.class));
}

@Test
@DisplayName("Should find user by username")
void shouldFindUserByUsername() {
// Given
when(userRepository.findByUsername("testuser"))
.thenReturn(Optional.of(testUser));
when(userMapper.toDto(testUser))
.thenReturn(new UserDto());

// When
UserDto result = userService.findByUsername("testuser");

// Then
assertThat(result).isNotNull();
verify(userRepository).findByUsername("testuser");
}

@Test
@DisplayName("Should throw exception when user not found")
void shouldThrowExceptionWhenUserNotFound() {
// Given
when(userRepository.findByUsername("nonexistent"))
.thenReturn(Optional.empty());

// When & Then
assertThatThrownBy(() -> userService.findByUsername("nonexistent"))
.isInstanceOf(UserNotFoundException.class)
.hasMessage("User not found: nonexistent");
}
}

Repository Testing

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/repository/UserRepositoryTest.java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryTest {

@Autowired
private UserRepository userRepository;

@Autowired
private TestEntityManager entityManager;

private User testUser;

@BeforeEach
void setUp() {
testUser = User.builder()
.username("testuser")
.email("test@example.com")
.password("password")
.roles(Set.of("USER"))
.build();
}

@Test
@DisplayName("Should save and find user by username")
void shouldSaveAndFindByUsername() {
// Given
entityManager.persist(testUser);
entityManager.flush();

// When
Optional<User> found = userRepository.findByUsername("testuser");

// Then
assertThat(found).isPresent();
assertThat(found.get().getUsername()).isEqualTo("testuser");
assertThat(found.get().getEmail()).isEqualTo("test@example.com");
}

@Test
@DisplayName("Should check if username exists")
void shouldCheckIfUsernameExists() {
// Given
entityManager.persist(testUser);
entityManager.flush();

// When
boolean exists = userRepository.existsByUsername("testuser");
boolean notExists = userRepository.existsByUsername("nonexistent");

// Then
assertThat(exists).isTrue();
assertThat(notExists).isFalse();
}

@Test
@DisplayName("Should find users by role")
void shouldFindUsersByRole() {
// Given
User admin = User.builder()
.username("admin")
.email("admin@example.com")
.password("password")
.roles(Set.of("ADMIN"))
.build();

entityManager.persist(testUser);
entityManager.persist(admin);
entityManager.flush();

// When
List<User> users = userRepository.findByRolesContaining("USER");
List<User> admins = userRepository.findByRolesContaining("ADMIN");

// Then
assertThat(users).hasSize(1);
assertThat(admins).hasSize(1);
}
}

Usage Examples

Testing with ArgumentCaptor

@Test
@DisplayName("Should capture and verify saved user details")
void shouldCaptureAndVerifySavedUser() {
// Given
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
when(userRepository.save(userCaptor.capture())).thenReturn(testUser);

// When
userService.createUser(createRequest);

// Then
User capturedUser = userCaptor.getValue();
assertThat(capturedUser.getUsername()).isEqualTo("testuser");
assertThat(capturedUser.getEmail()).isEqualTo("test@example.com");
assertThat(capturedUser.getRoles()).contains("USER");
}

Parameterized Tests

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/validation/PasswordValidatorTest.java
class PasswordValidatorTest {

private PasswordValidator validator = new PasswordValidator();

@ParameterizedTest
@ValueSource(strings = {
"Password123!",
"Secure@Pass1",
"MyP@ssw0rd"
})
@DisplayName("Should accept valid passwords")
void shouldAcceptValidPasswords(String password) {
assertThat(validator.isValid(password)).isTrue();
}

@ParameterizedTest
@ValueSource(strings = {
"weak",
"NoSpecialChar123",
"nouppercas3!",
"NOLOWERCASE1!"
})
@DisplayName("Should reject invalid passwords")
void shouldRejectInvalidPasswords(String password) {
assertThat(validator.isValid(password)).isFalse();
}

@ParameterizedTest
@CsvSource({
"test@example.com, true",
"user.name@domain.co.uk, true",
"invalid-email, false",
"@example.com, false"
})
@DisplayName("Should validate email addresses")
void shouldValidateEmails(String email, boolean expected) {
assertThat(validator.isValidEmail(email)).isEqualTo(expected);
}
}

Testing Exceptions

@Test
@DisplayName("Should handle repository exception gracefully")
void shouldHandleRepositoryException() {
// Given
when(userRepository.findById(1L))
.thenThrow(new DataAccessException("Database error") {});

// When & Then
assertThatThrownBy(() -> userService.getUserById(1L))
.isInstanceOf(ServiceException.class)
.hasMessageContaining("Failed to retrieve user")
.hasCauseInstanceOf(DataAccessException.class);
}

Verification

Test Coverage Configuration

<!-- filepath: /Users/jetstart/dev/jetrev/ink/pom.xml -->
<!-- ...existing code... -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>jacoco-check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>PACKAGE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<!-- ...existing code... -->

Running Tests

# Run all tests
mvn test

# Run specific test class
mvn test -Dtest=UserServiceTest

# Run tests with coverage
mvn clean test jacoco:report

# View coverage report
open target/site/jacoco/index.html

Troubleshooting

Mock Not Being Injected

// Ensure @InjectMocks is used correctly
@ExtendWith(MockitoExtension.class)
class ServiceTest {
@Mock
private Dependency dependency; // Must be @Mock

@InjectMocks
private Service service; // Will inject mocks
}

Flaky Tests

// Use fixed time for deterministic tests
@Mock
private Clock clock;

@BeforeEach
void setUp() {
Instant fixed = Instant.parse("2024-01-01T00:00:00Z");
when(clock.instant()).thenReturn(fixed);
}

Code Examples

Test Data Builder

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/builder/TestDataBuilder.java
public class TestDataBuilder {

public User.UserBuilder defaultUser() {
return User.builder()
.username("testuser")
.email("test@example.com")
.password("encodedPassword")
.roles(Set.of("USER"))
.createdAt(Instant.now())
.updatedAt(Instant.now());
}

public User.UserBuilder adminUser() {
return defaultUser()
.username("admin")
.email("admin@example.com")
.roles(Set.of("ADMIN"));
}
}

Best Practices

  1. Follow AAA Pattern: Arrange, Act, Assert
  2. One Assertion Per Test: Focus on single behavior
  3. Descriptive Names: Use @DisplayName for clarity
  4. Independent Tests: No shared state between tests
  5. Mock External Dependencies: Only mock what you don't own
  6. Use AssertJ: More readable assertions
  7. Test Edge Cases: Null, empty, boundary values
  8. Avoid Logic in Tests: Keep tests simple
  9. Use Test Builders: Create reusable test data
  10. Maintain Tests: Update tests with code changes

Additional Resources


Next Steps: Learn about Mocking Patterns for advanced testing scenarios.