Testing (JUnit, Mockito)

Unit Testing and Mocking in Java Applications

← Back to Index

Why Testing Matters

Automated testing is essential for maintaining code quality, catching bugs early, and enabling confident refactoring. Well-tested code is easier to maintain and extend.

Testing Benefits
  • Bug Prevention - Catch issues before they reach production
  • Documentation - Tests show how code should be used
  • Refactoring Safety - Change code with confidence
  • Design Feedback - Hard-to-test code often has design issues
  • Regression Protection - Ensure fixes don't break existing features

Testing Pyramid

// Testing Pyramid (bottom to top)

         /\
        /  \         E2E Tests (few)
       /----\        - Full system tests
      /      \       - Slowest, most expensive
     /--------\
    /          \     Integration Tests (some)
   /------------\    - Test component interactions
  /              \   - Databases, APIs, services
 /----------------\
/                  \ Unit Tests (many)
                     - Test individual classes/methods
                     - Fastest, cheapest, most numerous

JUnit 5

JUnit 5 (Jupiter) is the current standard for Java unit testing. It provides powerful features for writing and organizing tests.

Maven Dependencies

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.1</version>
    <scope>test</scope>
</dependency>

<!-- For Spring Boot projects -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

Basic Test Structure

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

class CalculatorTest {

    private Calculator calculator;

    @BeforeEach
    void setUp() {
        calculator = new Calculator();
    }

    @Test
    @DisplayName("Should add two positive numbers")
    void addPositiveNumbers() {
        // Given
        int a = 5;
        int b = 3;

        // When
        int result = calculator.add(a, b);

        // Then
        assertEquals(8, result);
    }

    @Test
    @DisplayName("Should throw exception when dividing by zero")
    void divideByZeroThrowsException() {
        assertThrows(ArithmeticException.class, () -> {
            calculator.divide(10, 0);
        });
    }

    @AfterEach
    void tearDown() {
        calculator = null;
    }
}

Common Assertions

import static org.junit.jupiter.api.Assertions.*;

// Equality
assertEquals(expected, actual);
assertEquals(expected, actual, "Custom failure message");
assertNotEquals(unexpected, actual);

// Boolean
assertTrue(condition);
assertFalse(condition);

// Null checks
assertNull(object);
assertNotNull(object);

// Same reference
assertSame(expected, actual);
assertNotSame(unexpected, actual);

// Arrays
assertArrayEquals(expectedArray, actualArray);

// Exceptions
assertThrows(IllegalArgumentException.class, () -> service.process(null));

Exception ex = assertThrows(CustomException.class, () -> service.run());
assertEquals("Expected message", ex.getMessage());

// Timeout
assertTimeout(Duration.ofSeconds(2), () -> slowOperation());

// Multiple assertions (all run, reports all failures)
assertAll("User properties",
    () -> assertEquals("John", user.getName()),
    () -> assertEquals(30, user.getAge()),
    () -> assertNotNull(user.getEmail())
);

Test Lifecycle Annotations

class LifecycleTest {

    @BeforeAll
    static void initAll() {
        // Runs once before all tests
        // Must be static (unless using @TestInstance(PER_CLASS))
    }

    @BeforeEach
    void init() {
        // Runs before each test
    }

    @Test
    void testMethod() {
        // Test code
    }

    @AfterEach
    void tearDown() {
        // Runs after each test
    }

    @AfterAll
    static void tearDownAll() {
        // Runs once after all tests
    }

    @Test
    @Disabled("Not implemented yet")
    void skippedTest() {
        // This test is skipped
    }
}

Parameterized Tests

Run the same test with different inputs to reduce duplication and improve coverage.

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;

class ParameterizedTests {

    // Value source - simple values
    @ParameterizedTest
    @ValueSource(ints = {1, 2, 3, 4, 5})
    void testPositiveNumbers(int number) {
        assertTrue(number > 0);
    }

    @ParameterizedTest
    @ValueSource(strings = {"hello", "world", "junit"})
    void testStringsNotEmpty(String str) {
        assertFalse(str.isEmpty());
    }

    // Null and empty sources
    @ParameterizedTest
    @NullSource
    @EmptySource
    @ValueSource(strings = {" ", "\t", "\n"})
    void testNullEmptyAndBlank(String text) {
        assertTrue(text == null || text.isBlank());
    }

    // CSV source - multiple arguments
    @ParameterizedTest
    @CsvSource({
        "1, 2, 3",
        "5, 5, 10",
        "-1, 1, 0"
    })
    void testAddition(int a, int b, int expected) {
        assertEquals(expected, calculator.add(a, b));
    }

    // Method source - complex objects
    @ParameterizedTest
    @MethodSource("provideUsers")
    void testUserValidation(User user, boolean expected) {
        assertEquals(expected, validator.isValid(user));
    }

    static Stream<Arguments> provideUsers() {
        return Stream.of(
            Arguments.of(new User("John", 25), true),
            Arguments.of(new User("", 25), false),
            Arguments.of(new User("Jane", -1), false)
        );
    }

    // Enum source
    @ParameterizedTest
    @EnumSource(DayOfWeek.class)
    void testAllDaysOfWeek(DayOfWeek day) {
        assertNotNull(day);
    }
}

Mockito

Mockito is the most popular mocking framework for Java. It allows you to create mock objects and verify interactions in unit tests.

Maven Dependency

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>5.8.0</version>
    <scope>test</scope>
</dependency>

<!-- JUnit 5 integration -->
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <version>5.8.0</version>
    <scope>test</scope>
</dependency>

Creating Mocks

import org.mockito.Mock;
import org.mockito.InjectMocks;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @Mock
    private EmailService emailService;

    @InjectMocks
    private UserService userService;

    @Test
    void findUserById() {
        // Given - define mock behavior
        User mockUser = new User(1L, "John");
        when(userRepository.findById(1L)).thenReturn(Optional.of(mockUser));

        // When - call the method under test
        User result = userService.findById(1L);

        // Then - verify the result
        assertEquals("John", result.getName());
        verify(userRepository).findById(1L);
    }
}

Stubbing Methods

// Return a value
when(repository.findById(1L)).thenReturn(Optional.of(user));

// Return different values on consecutive calls
when(service.getNext())
    .thenReturn("first")
    .thenReturn("second")
    .thenReturn("third");

// Throw an exception
when(service.process(null)).thenThrow(new IllegalArgumentException());

// Use argument matchers
when(repository.findByName(anyString())).thenReturn(user);
when(repository.findByAge(eq(25))).thenReturn(users);

// Answer - dynamic response based on input
when(repository.save(any(User.class))).thenAnswer(invocation -> {
    User u = invocation.getArgument(0);
    u.setId(100L);
    return u;
});

// Void methods
doNothing().when(emailService).sendEmail(anyString());
doThrow(new RuntimeException()).when(service).dangerousMethod();

Verification

// Verify method was called
verify(repository).save(user);

// Verify with specific arguments
verify(repository).findById(1L);

// Verify call count
verify(repository, times(2)).findAll();
verify(repository, never()).delete(any());
verify(repository, atLeast(1)).findById(anyLong());
verify(repository, atMost(3)).save(any());

// Verify order of calls
InOrder inOrder = inOrder(repository, emailService);
inOrder.verify(repository).save(user);
inOrder.verify(emailService).sendWelcomeEmail(user);

// Verify no more interactions
verifyNoMoreInteractions(repository);

// Argument captor - capture and inspect arguments
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(repository).save(captor.capture());
User savedUser = captor.getValue();
assertEquals("John", savedUser.getName());

Testing Best Practices

Test Best Practices
  • Follow AAA pattern - Arrange, Act, Assert
  • Test one thing per test - Single assertion focus
  • Use descriptive names - should_returnNull_when_userNotFound
  • Keep tests independent - No shared state between tests
  • Don't test implementation - Test behavior, not internals
  • Make tests fast - Unit tests should run in milliseconds

Test Naming Conventions

// Option 1: methodName_stateUnderTest_expectedBehavior
void findById_userExists_returnsUser() { }
void findById_userNotFound_throwsException() { }

// Option 2: should_expectedBehavior_when_stateUnderTest
void should_returnUser_when_userExists() { }
void should_throwException_when_userNotFound() { }

// Option 3: Given-When-Then with @DisplayName
@Test
@DisplayName("Given existing user, when findById called, then return user")
void testFindById() { }

Test Structure Example

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;

    @Mock
    private PaymentService paymentService;

    @Mock
    private InventoryService inventoryService;

    @InjectMocks
    private OrderService orderService;

    @Nested
    @DisplayName("When creating order")
    class CreateOrder {

        @Test
        @DisplayName("should create order successfully")
        void createOrderSuccess() {
            // Arrange
            OrderRequest request = new OrderRequest("item-1", 2);
            when(inventoryService.checkAvailability("item-1", 2)).thenReturn(true);
            when(paymentService.process(any())).thenReturn(PaymentResult.success());

            // Act
            Order result = orderService.createOrder(request);

            // Assert
            assertNotNull(result);
            assertEquals(OrderStatus.CREATED, result.getStatus());
            verify(orderRepository).save(any(Order.class));
        }

        @Test
        @DisplayName("should throw exception when item not available")
        void createOrderItemNotAvailable() {
            // Arrange
            OrderRequest request = new OrderRequest("item-1", 100);
            when(inventoryService.checkAvailability("item-1", 100)).thenReturn(false);

            // Act & Assert
            assertThrows(InsufficientInventoryException.class,
                () -> orderService.createOrder(request));

            verify(paymentService, never()).process(any());
        }
    }
}

Integration Testing

Spring Boot Integration Tests

@SpringBootTest
@AutoConfigureMockMvc
class UserControllerIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void createUser_validRequest_returnsCreated() throws Exception {
        UserDTO user = new UserDTO("John", "john@example.com");

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(user)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.name").value("John"))
            .andExpect(jsonPath("$.email").value("john@example.com"));
    }

    @Test
    void getUser_notFound_returns404() throws Exception {
        mockMvc.perform(get("/api/users/999"))
            .andExpect(status().isNotFound());
    }
}

Database Integration Tests

@DataJpaTest
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    void findByEmail_userExists_returnsUser() {
        // Given
        User user = new User("John", "john@test.com");
        entityManager.persistAndFlush(user);

        // When
        Optional<User> found = userRepository.findByEmail("john@test.com");

        // Then
        assertTrue(found.isPresent());
        assertEquals("John", found.get().getName());
    }
}

Test Coverage

JaCoCo is the standard tool for measuring test coverage in Java projects.

Maven JaCoCo Configuration

<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>check</id>
            <goals>
                <goal>check</goal>
            </goals>
            <configuration>
                <rules>
                    <rule>
                        <element>BUNDLE</element>
                        <limits>
                            <limit>
                                <counter>LINE</counter>
                                <value>COVEREDRATIO</value>
                                <minimum>0.80</minimum>
                            </limit>
                        </limits>
                    </rule>
                </rules>
            </configuration>
        </execution>
    </executions>
</plugin>
Coverage Is Not Everything

High coverage doesn't guarantee good tests. Focus on testing important behavior, edge cases, and error conditions rather than chasing a coverage percentage.