Welcome to Chapter 14! In this crucial phase of our project development, we shift our focus to ensuring the reliability and robustness of our applications through rigorous testing. We’ll dive deep into unit and integration testing, leveraging the power of JUnit 5, the de facto standard for testing in Java. This chapter is not just about writing tests; it’s about adopting a testing mindset that leads to more stable, maintainable, and production-ready code.
The importance of testing cannot be overstated. Bugs caught early in the development cycle are significantly cheaper and easier to fix than those discovered in production. By systematically testing our code, we build confidence in our application’s correctness, facilitate refactoring, and provide clear documentation of expected behavior. We’ll apply these principles to components from our Simple Calculator and Basic To-Do List Application, demonstrating how to test individual units and how different components interact.
By the end of this chapter, you will have a solid understanding of how to write effective unit and integration tests using JUnit 5 and Mockito. You’ll be able to identify what to test, how to structure your tests, and how to use assertion libraries and mocking frameworks to isolate components. The expected outcome is a comprehensive test suite for core functionalities, laying the groundwork for a truly production-grade application that you can confidently deploy.
Planning & Design
Before we jump into coding, let’s establish a clear strategy for our testing efforts. We’ll follow the widely accepted “Test Pyramid” principle, which advocates for a higher proportion of fast, isolated unit tests, a moderate number of integration tests, and a smaller set of end-to-end tests. For this chapter, we’ll concentrate on the base and middle layers: unit and integration testing.
Test Pyramid Overview:
- Unit Tests: Focus on testing individual methods or classes in isolation. They should be fast and independent.
- Integration Tests: Verify the interaction between multiple components or layers of the application (e.g., service layer with a repository). These are typically slower than unit tests but provide more confidence in component interactions.
- End-to-End Tests: Simulate real user scenarios across the entire application stack (e.g., UI to database). These are the slowest and most complex.
Component Architecture for Testing: We’ll target two of our existing projects:
- Simple Calculator: Ideal for demonstrating pure unit testing of stateless mathematical operations.
- Basic To-Do List Application: Provides a great opportunity for both unit testing (e.g.,
TodoItemmodel) and integration testing (e.g.,TodoServiceinteracting with aTodoRepository).
File Structure for Tests: In a standard Maven or Gradle project, test files reside in a separate source directory, mirroring the main source package structure. This separation ensures that test code is not bundled with the production code.
project-root/
├── src/
│ ├── main/
│ │ └── java/
│ │ └── com/example/project/
│ │ ├── calculator/
│ │ │ └── Calculator.java
│ │ └── todo/
│ │ ├── model/
│ │ │ └── TodoItem.java
│ │ ├── repository/
│ │ │ ├── TodoRepository.java
│ │ │ └── InMemoryTodoRepository.java
│ │ └── service/
│ │ └── TodoService.java
│ └── test/
│ └── java/
│ └── com/example/project/
│ ├── calculator/
│ │ └── CalculatorTest.java
│ └── todo/
│ ├── model/
│ │ └── TodoItemTest.java
│ ├── service/
│ │ ├── TodoServiceUnitTest.java
│ │ └── TodoServiceIntegrationTest.java
└── pom.xml
This structure clearly separates our production code from our test code, making it easy to manage and run tests.
Step-by-Step Implementation
Let’s begin by setting up our project to support JUnit 5 and then write tests for our applications.
1. Setup/Configuration: Adding JUnit 5 and Mockito Dependencies
First, we need to add the necessary testing dependencies to our pom.xml file. We’ll use JUnit Jupiter (part of JUnit 5) for writing tests and Mockito for creating mock objects in our integration tests.
Action: Open your pom.xml file and add the following dependencies within the <dependencies> block.
<!-- pom.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example.project</groupId>
<artifactId>java-projects-guide</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>24</maven.compiler.source> <!-- Targeting Java 24/25 -->
<maven.compiler.target>24</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.jupiter.version>5.10.1</junit.jupiter.version> <!-- Latest stable as of Dec 2025 -->
<mockito.version>5.8.0</mockito.version> <!-- Latest stable as of Dec 2025 -->
<maven-surefire-plugin.version>3.2.3</maven-surefire-plugin.version>
</properties>
<dependencies>
<!-- JUnit Jupiter API for writing tests -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
<!-- JUnit Jupiter Engine for running tests -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
<!-- Mockito Core for mocking objects -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
<!-- Mockito JUnit Jupiter extension -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>${maven-surefire-plugin.version}</version>
</plugin>
<!-- Optional: You might want to include the Maven Failsafe Plugin for integration tests -->
<!-- <plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>${maven-surefire-plugin.version}</version>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin> -->
</plugins>
</build>
</project>
Explanation:
- We’ve added
junit-jupiter-apifor writing our test code andjunit-jupiter-enginefor executing it. Both are scoped astestbecause they are not needed in the production build. mockito-coreprovides the core mocking capabilities, whilemockito-junit-jupiterintegrates Mockito seamlessly with JUnit 5, allowing us to use annotations like@Mockand@InjectMocks.- We’ve explicitly set
maven.compiler.sourceandmaven.compiler.targetto24to reflect the latest stable Java version as of December 2025 (Java 24.0.2 or later). - The
maven-surefire-pluginis crucial for running unit tests during thetestphase of the Maven build lifecycle. We’ve included its latest stable version. For integration tests, themaven-failsafe-pluginis often used, but we’ll stick to Surefire for now for simplicity, noting that more advanced CI/CD setups might separate them.
2. Core Implementation: Simple Calculator Unit Tests
Let’s start with the Simple Calculator project. We’ll assume you have a basic Calculator class already. If not, here’s a minimal, production-ready version.
Action: Create the Calculator.java file.
File: src/main/java/com/example/project/calculator/Calculator.java
package com.example.project.calculator;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* A simple calculator class providing basic arithmetic operations.
* Designed to be stateless and thread-safe.
*/
public class Calculator {
private static final Logger LOGGER = Logger.getLogger(Calculator.class.getName());
/**
* Adds two numbers.
* @param a The first number.
* @param b The second number.
* @return The sum of a and b.
*/
public double add(double a, double b) {
LOGGER.log(Level.FINE, "Adding {0} and {1}", new Object[]{a, b});
return a + b;
}
/**
* Subtracts the second number from the first.
* @param a The first number.
* @param b The second number.
* @return The result of a - b.
*/
public double subtract(double a, double b) {
LOGGER.log(Level.FINE, "Subtracting {1} from {0}", new Object[]{a, b});
return a - b;
}
/**
* Multiplies two numbers.
* @param a The first number.
* @param b The second number.
* @return The product of a and b.
*/
public double multiply(double a, double b) {
LOGGER.log(Level.FINE, "Multiplying {0} by {1}", new Object[]{a, b});
return a * b;
}
/**
* Divides the first number by the second.
* @param a The dividend.
* @param b The divisor.
* @return The result of a / b.
* @throws IllegalArgumentException if the divisor is zero.
*/
public double divide(double a, double b) {
LOGGER.log(Level.FINE, "Dividing {0} by {1}", new Object[]{a, b});
if (b == 0) {
LOGGER.log(Level.WARNING, "Attempted division by zero: {0} / {1}", new Object[]{a, b});
throw new IllegalArgumentException("Division by zero is not allowed.");
}
return a / b;
}
}
Explanation:
- This
Calculatorclass provides standard arithmetic operations. - We’ve included basic
java.util.loggingto demonstrate production-ready code practices. In a real application, you’d configure a more robust logging framework like Logback or SLF4J. - The
dividemethod includes explicit error handling for division by zero, throwing anIllegalArgumentException. This is a critical edge case we’ll need to test.
Now, let’s write unit tests for this Calculator class.
Action: Create the CalculatorTest.java file.
File: src/test/java/com/example/project/calculator/CalculatorTest.java
package com.example.project.calculator;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for the {@link Calculator} class.
* Demonstrates basic arithmetic operations and error handling for edge cases.
*/
@DisplayName("Calculator Operations Test Suite") // A descriptive name for the test class
class CalculatorTest {
private final Calculator calculator = new Calculator(); // Instance of the class under test
@Test
@DisplayName("Test addition of two positive numbers")
void testAddPositiveNumbers() {
// Arrange - set up test data
double num1 = 5.0;
double num2 = 3.0;
double expected = 8.0;
// Act - perform the operation
double result = calculator.add(num1, num2);
// Assert - verify the result
assertEquals(expected, result, "Adding two positive numbers should yield the correct sum.");
}
@Test
@DisplayName("Test addition with negative numbers")
void testAddNegativeNumbers() {
assertEquals(-2.0, calculator.add(-5.0, 3.0), "Adding a negative and positive number.");
assertEquals(-8.0, calculator.add(-5.0, -3.0), "Adding two negative numbers.");
}
@Test
@DisplayName("Test subtraction of positive numbers")
void testSubtractPositiveNumbers() {
assertEquals(2.0, calculator.subtract(5.0, 3.0), "Subtracting smaller from larger positive number.");
assertEquals(-2.0, calculator.subtract(3.0, 5.0), "Subtracting larger from smaller positive number.");
}
@Test
@DisplayName("Test multiplication of positive numbers")
void testMultiplyPositiveNumbers() {
assertEquals(15.0, calculator.multiply(5.0, 3.0), "Multiplying two positive numbers.");
}
@Test
@DisplayName("Test multiplication with zero")
void testMultiplyByZero() {
assertEquals(0.0, calculator.multiply(5.0, 0.0), "Multiplying by zero should be zero.");
assertEquals(0.0, calculator.multiply(0.0, 10.0), "Multiplying zero by any number should be zero.");
}
@Test
@DisplayName("Test division of positive numbers")
void testDividePositiveNumbers() {
assertEquals(5.0, calculator.divide(10.0, 2.0), "Dividing positive numbers.");
assertEquals(0.5, calculator.divide(1.0, 2.0), "Dividing to get a fractional result.");
}
@Test
@DisplayName("Test division by zero should throw IllegalArgumentException")
void testDivideByZero() {
// Assert that a specific exception is thrown
IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> {
calculator.divide(10.0, 0.0);
}, "Division by zero should throw IllegalArgumentException.");
// Optionally, assert the message of the thrown exception
assertEquals("Division by zero is not allowed.", thrown.getMessage(), "Exception message should match.");
}
@Test
@DisplayName("Test division of zero by a non-zero number")
void testDivideZeroByNumber() {
assertEquals(0.0, calculator.divide(0.0, 5.0), "Dividing zero by a non-zero number should be zero.");
}
}
Explanation:
@DisplayName: Provides a human-readable name for the test class and individual test methods, which is very helpful in test reports.@Test: Marks a method as a test method that JUnit should execute.Calculator calculator = new Calculator();: We create an instance of the class we want to test. SinceCalculatoris stateless, one instance is sufficient for all tests.assertEquals(expected, actual, message): The core assertion method fromorg.junit.jupiter.api.Assertions. It compares anexpectedvalue with anactualvalue. Themessageis displayed if the assertion fails.assertThrows(ExpectedException.class, () -> { ... }, message): This assertion is specifically used to verify that a particular exception is thrown when a piece of code is executed. This is crucial for testing error handling.- AAA Pattern (Arrange, Act, Assert): Notice how each test method follows this pattern:
- Arrange: Set up the test data and the environment.
- Act: Execute the method under test.
- Assert: Verify that the outcome is as expected using assertions.
3. Core Implementation: Basic To-Do List Application - Model Unit Tests
Now, let’s move to the Basic To-Do List Application. We’ll start by defining a TodoItem model and then write unit tests for it.
Action: Create the TodoItem.java file.
File: src/main/java/com/example/project/todo/model/TodoItem.java
package com.example.project.todo.model;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Represents a single to-do item with its properties.
* This class is immutable for its core state (id, description, creation time)
* but allows marking as completed.
*/
public class TodoItem {
private static final Logger LOGGER = Logger.getLogger(TodoItem.class.getName());
private final String id;
private final String description;
private final LocalDateTime createdAt;
private boolean completed;
private LocalDateTime completedAt; // New field for completion timestamp
/**
* Constructs a new TodoItem.
* @param description The description of the to-do item. Cannot be null or empty.
* @throws IllegalArgumentException if description is null or empty.
*/
public TodoItem(String description) {
if (description == null || description.trim().isEmpty()) {
LOGGER.log(Level.WARNING, "Attempted to create TodoItem with null or empty description.");
throw new IllegalArgumentException("Description cannot be null or empty.");
}
this.id = UUID.randomUUID().toString(); // Generate a unique ID
this.description = description.trim();
this.createdAt = LocalDateTime.now();
this.completed = false;
LOGGER.log(Level.INFO, "Created new TodoItem: {0}", id);
}
// Getters
public String getId() {
return id;
}
public String getDescription() {
return description;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public boolean isCompleted() {
return completed;
}
public LocalDateTime getCompletedAt() {
return completedAt;
}
/**
* Marks the to-do item as completed.
* If already completed, logs a message and does nothing.
*/
public void markAsCompleted() {
if (!this.completed) {
this.completed = true;
this.completedAt = LocalDateTime.now();
LOGGER.log(Level.INFO, "TodoItem {0} marked as completed.", id);
} else {
LOGGER.log(Level.FINE, "TodoItem {0} is already completed.", id);
}
}
/**
* Marks the to-do item as not completed (reopens it).
* If already not completed, logs a message and does nothing.
*/
public void markAsPending() {
if (this.completed) {
this.completed = false;
this.completedAt = null; // Clear completion timestamp
LOGGER.log(Level.INFO, "TodoItem {0} marked as pending.", id);
} else {
LOGGER.log(Level.FINE, "TodoItem {0} is already pending.", id);
}
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
TodoItem todoItem = (TodoItem) o;
return Objects.equals(id, todoItem.id); // Equality based on ID
}
@Override
public int hashCode() {
return Objects.hash(id);
}
@Override
public String toString() {
return "TodoItem{" +
"id='" + id + '\'' +
", description='" + description + '\'' +
", createdAt=" + createdAt +
", completed=" + completed +
", completedAt=" + completedAt +
'}';
}
}
Explanation:
- The
TodoItemclass encapsulates the data for a single to-do task. - It generates a
UUIDfor itsid, ensuring uniqueness. - The constructor validates the
descriptionto prevent empty items, throwing anIllegalArgumentException. - Methods
markAsCompleted()andmarkAsPending()modify thecompletedstatus andcompletedAttimestamp. equals()andhashCode()are overridden to ensureTodoItemobjects are considered equal if their IDs are the same, which is standard practice for entity identification.- Logging is included for lifecycle events.
Now, let’s write unit tests for the TodoItem class.
Action: Create the TodoItemTest.java file.
File: src/test/java/com/example/project/todo/model/TodoItemTest.java
package com.example.project.todo.model;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDateTime;
/**
* Unit tests for the {@link TodoItem} model class.
* Focuses on constructor validation, state changes, and property access.
*/
@DisplayName("TodoItem Model Test Suite")
class TodoItemTest {
@Test
@DisplayName("Should create TodoItem successfully with valid description")
void testTodoItemCreation_ValidDescription() {
String description = "Learn JUnit 5";
TodoItem item = new TodoItem(description);
assertNotNull(item.getId(), "ID should not be null.");
assertFalse(item.getId().isEmpty(), "ID should not be empty.");
assertEquals(description, item.getDescription(), "Description should match the input.");
assertNotNull(item.getCreatedAt(), "Creation timestamp should not be null.");
assertFalse(item.isCompleted(), "New item should not be completed.");
assertNull(item.getCompletedAt(), "Completion timestamp should be null for new item.");
}
@Test
@DisplayName("Should throw IllegalArgumentException for null description")
void testTodoItemCreation_NullDescription() {
IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> {
new TodoItem(null);
}, "Null description should throw IllegalArgumentException.");
assertEquals("Description cannot be null or empty.", thrown.getMessage());
}
@Test
@DisplayName("Should throw IllegalArgumentException for empty description")
void testTodoItemCreation_EmptyDescription() {
IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> {
new TodoItem("");
}, "Empty description should throw IllegalArgumentException.");
assertEquals("Description cannot be null or empty.", thrown.getMessage());
}
@Test
@DisplayName("Should throw IllegalArgumentException for blank description")
void testTodoItemCreation_BlankDescription() {
IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> {
new TodoItem(" ");
}, "Blank description should throw IllegalArgumentException.");
assertEquals("Description cannot be null or empty.", thrown.getMessage());
}
@Test
@DisplayName("Should mark item as completed")
void testMarkAsCompleted() {
TodoItem item = new TodoItem("Finish chapter 14");
assertFalse(item.isCompleted(), "Item should initially not be completed.");
assertNull(item.getCompletedAt(), "Completion timestamp should initially be null.");
item.markAsCompleted();
assertTrue(item.isCompleted(), "Item should be marked as completed.");
assertNotNull(item.getCompletedAt(), "Completion timestamp should be set.");
// Verify completedAt is after createdAt (simple check for logical consistency)
assertTrue(item.getCompletedAt().isAfter(item.getCreatedAt()));
}
@Test
@DisplayName("Should not change state if marking already completed item as completed")
void testMarkAsCompleted_AlreadyCompleted() {
TodoItem item = new TodoItem("Already done");
item.markAsCompleted(); // First completion
LocalDateTime firstCompletedAt = item.getCompletedAt();
item.markAsCompleted(); // Second attempt to complete
assertTrue(item.isCompleted(), "Item should remain completed.");
// The timestamp should ideally be the same if the method does nothing,
// but due to LocalDateTime.now(), it might be slightly different.
// For production code, consider making markAsCompleted idempotent without updating time if already complete.
// For this test, we'll just ensure it remains completed.
assertEquals(firstCompletedAt.getDayOfYear(), item.getCompletedAt().getDayOfYear()); // A weak but stable assertion
}
@Test
@DisplayName("Should mark item as pending")
void testMarkAsPending() {
TodoItem item = new TodoItem("Reopen task");
item.markAsCompleted(); // First, complete it
assertTrue(item.isCompleted());
assertNotNull(item.getCompletedAt());
item.markAsPending();
assertFalse(item.isCompleted(), "Item should be marked as pending.");
assertNull(item.getCompletedAt(), "Completion timestamp should be cleared.");
}
@Test
@DisplayName("Should not change state if marking already pending item as pending")
void testMarkAsPending_AlreadyPending() {
TodoItem item = new TodoItem("Already pending");
assertFalse(item.isCompleted());
assertNull(item.getCompletedAt());
item.markAsPending(); // Attempt to mark as pending when already pending
assertFalse(item.isCompleted(), "Item should remain pending.");
assertNull(item.getCompletedAt(), "Completion timestamp should remain null.");
}
@Test
@DisplayName("Equals and HashCode should work based on ID")
void testEqualsAndHashCode() {
TodoItem item1 = new TodoItem("Task A");
TodoItem item2 = new TodoItem("Task B"); // Different ID
TodoItem item1Duplicate = new TodoItem("Task A"); // Different ID, same description
// Simulate two items representing the same underlying entity (same ID)
TodoItem item1SameId = new TodoItem("Dummy"); // Create with dummy description
// Use reflection or a test constructor for setting ID if it were immutable
// For this example, let's assume we can create an item with a specific ID for testing equals/hashCode
// (This often indicates a need for a dedicated constructor for persistence layer or testing)
// For simplicity and adhering to the current constructor, we'll test that items with different UUIDs are not equal
// This implicitly tests that ID is the primary factor.
assertNotEquals(item1, item2, "Items with different IDs should not be equal.");
assertNotEquals(item1, item1Duplicate, "Items created separately will have different IDs and should not be equal.");
// To properly test equals/hashCode based on ID, we'd need a constructor that accepts an ID,
// or a method to set it, which typically isn't good for domain models.
// A more realistic test would involve two instances retrieved from a "repository" that returned the same object reference or object with same ID.
// Let's create two items and then manually set one ID to match the other for this test.
// NOTE: In production code, you wouldn't modify final fields like this. This is purely for demonstrating equals/hashCode behavior.
try {
java.lang.reflect.Field idField = TodoItem.class.getDeclaredField("id");
idField.setAccessible(true);
TodoItem itemA = new TodoItem("Original Task");
TodoItem itemB = new TodoItem("Copy Task"); // Different ID initially
idField.set(itemB, itemA.getId()); // Set itemB's ID to itemA's ID
assertEquals(itemA, itemB, "Items with the same ID should be equal.");
assertEquals(itemA.hashCode(), itemB.hashCode(), "Items with the same ID should have the same hash code.");
assertNotEquals(itemA, null, "Item should not be equal to null.");
} catch (NoSuchFieldException | IllegalAccessException e) {
fail("Reflection error during equals/hashCode test: " + e.getMessage());
}
}
}
Explanation:
- We’ve written tests for the
TodoItemconstructor, ensuring it validates input and initializes properties correctly. - The
assertThrowsmethod is again used to verify exception handling for invalid descriptions. - Tests for
markAsCompleted()andmarkAsPending()verify the state transitions and timestamp updates. - The
testEqualsAndHashCode()method demonstrates how to test the contract ofequals()andhashCode(). Note the use of reflection to manipulate afinalfield for testing purposes; in a real scenario, you might have a constructor that takes an ID for persistence or a separate factory method.
4. Core Implementation: Basic To-Do List Application - Repository & Service
For the To-Do List, we’ll need a way to store and retrieve TodoItems. We’ll define a TodoRepository interface and a simple InMemoryTodoRepository implementation. Then, we’ll create a TodoService that uses this repository.
Action: Create the TodoRepository.java interface.
File: src/main/java/com/example/project/todo/repository/TodoRepository.java
package com.example.project.todo.repository;
import com.example.project.todo.model.TodoItem;
import java.util.List;
import java.util.Optional;
/**
* Interface for managing TodoItem persistence.
*/
public interface TodoRepository {
/**
* Saves a TodoItem. If the item already exists (based on ID), it's updated.
* @param item The TodoItem to save.
* @return The saved TodoItem.
*/
TodoItem save(TodoItem item);
/**
* Finds a TodoItem by its ID.
* @param id The ID of the TodoItem.
* @return An Optional containing the TodoItem if found, or empty if not.
*/
Optional<TodoItem> findById(String id);
/**
* Retrieves all TodoItems.
* @return A list of all TodoItems.
*/
List<TodoItem> findAll();
/**
* Deletes a TodoItem by its ID.
* @param id The ID of the TodoItem to delete.
* @return True if the item was found and deleted, false otherwise.
*/
boolean deleteById(String id);
}
Action: Create the InMemoryTodoRepository.java implementation.
File: src/main/java/com/example/project/todo/repository/InMemoryTodoRepository.java
package com.example.project.todo.repository;
import com.example.project.todo.model.TodoItem;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* An in-memory implementation of {@link TodoRepository} for demonstration and testing.
* Uses a ConcurrentHashMap to store TodoItems, simulating a simple data store.
* This implementation is thread-safe for concurrent access.
*/
public class InMemoryTodoRepository implements TodoRepository {
private static final Logger LOGGER = Logger.getLogger(InMemoryTodoRepository.class.getName());
private final Map<String, TodoItem> todoStore = new ConcurrentHashMap<>();
@Override
public TodoItem save(TodoItem item) {
if (item == null) {
LOGGER.log(Level.WARNING, "Attempted to save a null TodoItem.");
throw new IllegalArgumentException("TodoItem cannot be null.");
}
todoStore.put(item.getId(), item);
LOGGER.log(Level.INFO, "Saved TodoItem with ID: {0}", item.getId());
return item;
}
@Override
public Optional<TodoItem> findById(String id) {
if (id == null || id.trim().isEmpty()) {
LOGGER.log(Level.WARNING, "Attempted to find TodoItem with null or empty ID.");
return Optional.empty();
}
return Optional.ofNullable(todoStore.get(id));
}
@Override
public List<TodoItem> findAll() {
LOGGER.log(Level.FINE, "Retrieving all TodoItems. Count: {0}", todoStore.size());
return new ArrayList<>(todoStore.values());
}
@Override
public boolean deleteById(String id) {
if (id == null || id.trim().isEmpty()) {
LOGGER.log(Level.WARNING, "Attempted to delete TodoItem with null or empty ID.");
return false;
}
TodoItem removedItem = todoStore.remove(id);
if (removedItem != null) {
LOGGER.log(Level.INFO, "Deleted TodoItem with ID: {0}", id);
return true;
} else {
LOGGER.log(Level.FINE, "TodoItem with ID {0} not found for deletion.", id);
return false;
}
}
/**
* Clears all items from the repository. Useful for testing.
*/
public void clear() {
todoStore.clear();
LOGGER.log(Level.INFO, "InMemoryTodoRepository cleared.");
}
}
Explanation:
TodoRepository: An interface defining the contract for data access operations. This is crucial for dependency inversion and testability.InMemoryTodoRepository: A concrete implementation using aConcurrentHashMapto store items. This is perfect for integration testing because it’s fast, self-contained, and doesn’t require a real database setup. It also includes basic logging and null checks.
Now for the TodoService that orchestrates business logic using the repository.
Action: Create the TodoService.java file.
File: src/main/java/com/example/project/todo/service/TodoService.java
package com.example.project.todo.service;
import com.example.project.todo.model.TodoItem;
import com.example.project.todo.repository.TodoRepository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Service layer for managing TodoItems.
* Handles business logic and interacts with the {@link TodoRepository}.
*/
public class TodoService {
private static final Logger LOGGER = Logger.getLogger(TodoService.class.getName());
private final TodoRepository todoRepository;
/**
* Constructs a TodoService with a given repository.
* @param todoRepository The repository to use for data access. Cannot be null.
* @throws IllegalArgumentException if todoRepository is null.
*/
public TodoService(TodoRepository todoRepository) {
if (todoRepository == null) {
LOGGER.log(Level.SEVERE, "TodoRepository cannot be null when initializing TodoService.");
throw new IllegalArgumentException("TodoRepository cannot be null.");
}
this.todoRepository = todoRepository;
LOGGER.log(Level.INFO, "TodoService initialized with repository: {0}", todoRepository.getClass().getSimpleName());
}
/**
* Creates and saves a new TodoItem.
* @param description The description for the new to-do item.
* @return The newly created and saved TodoItem.
* @throws IllegalArgumentException if description is invalid.
*/
public TodoItem createTodo(String description) {
try {
TodoItem newItem = new TodoItem(description);
return todoRepository.save(newItem);
} catch (IllegalArgumentException e) {
LOGGER.log(Level.WARNING, "Failed to create todo due to invalid description: {0}", description);
throw e; // Re-throw to propagate input validation error
} catch (Exception e) {
LOGGER.log(Level.SEVERE, "An unexpected error occurred while creating todo: {0}", e.getMessage(), e);
throw new RuntimeException("Failed to create todo item.", e);
}
}
/**
* Retrieves a TodoItem by its ID.
* @param id The ID of the TodoItem.
* @return An Optional containing the TodoItem if found, or empty.
*/
public Optional<TodoItem> getTodoById(String id) {
LOGGER.log(Level.FINE, "Attempting to retrieve TodoItem by ID: {0}", id);
if (id == null || id.trim().isEmpty()) {
LOGGER.log(Level.WARNING, "Attempted to get todo with null or empty ID.");
return Optional.empty();
}
return todoRepository.findById(id);
}
/**
* Retrieves all TodoItems.
* @return A list of all TodoItems.
*/
public List<TodoItem> getAllTodos() {
LOGGER.log(Level.FINE, "Retrieving all TodoItems.");
return todoRepository.findAll();
}
/**
* Marks a TodoItem as completed.
* @param id The ID of the TodoItem to mark.
* @return An Optional containing the updated TodoItem if found, or empty.
*/
public Optional<TodoItem> markTodoAsCompleted(String id) {
Optional<TodoItem> todoOptional = getTodoById(id);
if (todoOptional.isPresent()) {
TodoItem todo = todoOptional.get();
if (!todo.isCompleted()) { // Only update if not already completed
todo.markAsCompleted();
todoRepository.save(todo); // Persist the change
LOGGER.log(Level.INFO, "TodoItem {0} marked as completed and saved.", id);
} else {
LOGGER.log(Level.FINE, "TodoItem {0} was already completed.", id);
}
return Optional.of(todo);
}
LOGGER.log(Level.WARNING, "Could not find TodoItem with ID {0} to mark as completed.", id);
return Optional.empty();
}
/**
* Marks a TodoItem as pending.
* @param id The ID of the TodoItem to mark.
* @return An Optional containing the updated TodoItem if found, or empty.
*/
public Optional<TodoItem> markTodoAsPending(String id) {
Optional<TodoItem> todoOptional = getTodoById(id);
if (todoOptional.isPresent()) {
TodoItem todo = todoOptional.get();
if (todo.isCompleted()) { // Only update if not already pending
todo.markAsPending();
todoRepository.save(todo); // Persist the change
LOGGER.log(Level.INFO, "TodoItem {0} marked as pending and saved.", id);
} else {
LOGGER.log(Level.FINE, "TodoItem {0} was already pending.", id);
}
return Optional.of(todo);
}
LOGGER.log(Level.WARNING, "Could not find TodoItem with ID {0} to mark as pending.", id);
return Optional.empty();
}
/**
* Deletes a TodoItem by its ID.
* @param id The ID of the TodoItem to delete.
* @return True if the item was found and deleted, false otherwise.
*/
public boolean deleteTodo(String id) {
if (id == null || id.trim().isEmpty()) {
LOGGER.log(Level.WARNING, "Attempted to delete todo with null or empty ID.");
return false;
}
boolean deleted = todoRepository.deleteById(id);
if (deleted) {
LOGGER.log(Level.INFO, "TodoItem with ID {0} successfully deleted.", id);
} else {
LOGGER.log(Level.WARNING, "Failed to delete TodoItem with ID {0}, it might not exist.", id);
}
return deleted;
}
}
Explanation:
TodoService: This class contains the business logic for our To-Do application. It depends onTodoRepositorythrough constructor injection, which is a key pattern for testability and maintainability.- Methods like
createTodo,getTodoById,markTodoAsCompleted, etc., encapsulate the workflow. - Error handling and logging are integrated, especially for cases where items are not found or input is invalid.
5. Core Implementation: Basic To-Do List Application - Service Unit Tests (with Mockito)
Now we’ll write unit tests for TodoService. Since TodoService depends on TodoRepository, we’ll use Mockito to mock the repository. This allows us to test the TodoService’s logic in isolation without actually touching a real data store.
Action: Create the TodoServiceUnitTest.java file.
File: src/test/java/com/example/project/todo/service/TodoServiceUnitTest.java
package com.example.project.todo.service;
import com.example.project.todo.model.TodoItem;
import com.example.project.todo.repository.TodoRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* Unit tests for the {@link TodoService} class using Mockito.
* These tests focus on the business logic within the service,
* isolating it from the actual repository implementation.
*/
@ExtendWith(MockitoExtension.class) // Integrates Mockito with JUnit 5
@DisplayName("TodoService Unit Test Suite (with Mockito)")
class TodoServiceUnitTest {
@Mock // Creates a mock instance of TodoRepository
private TodoRepository todoRepository;
@InjectMocks // Injects the mock repository into a new TodoService instance
private TodoService todoService;
@Test
@DisplayName("Should create a new TodoItem successfully")
void testCreateTodo_Success() {
String description = "Buy groceries";
TodoItem mockItem = new TodoItem(description); // Create a real TodoItem to simulate what repository would return
// Define behavior for the mock repository:
// When todoRepository.save(any(TodoItem.class)) is called, return mockItem.
when(todoRepository.save(any(TodoItem.class))).thenReturn(mockItem);
TodoItem createdTodo = todoService.createTodo(description);
assertNotNull(createdTodo, "Created TodoItem should not be null.");
assertEquals(description, createdTodo.getDescription(), "Description should match.");
assertFalse(createdTodo.isCompleted(), "New item should not be completed.");
// Verify that todoRepository.save was called exactly once with any TodoItem
verify(todoRepository, times(1)).save(any(TodoItem.class));
}
@Test
@DisplayName("Should throw IllegalArgumentException when creating todo with invalid description")
void testCreateTodo_InvalidDescription() {
String invalidDescription = "";
// No need to mock repository.save here, as the exception is thrown before repository interaction.
IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> {
todoService.createTodo(invalidDescription);
}, "Invalid description should throw IllegalArgumentException.");
assertEquals("Description cannot be null or empty.", thrown.getMessage());
// Verify that todoRepository.save was never called
verify(todoRepository, never()).save(any(TodoItem.class));
}
@Test
@DisplayName("Should retrieve a TodoItem by ID")
void testGetTodoById_Found() {
String id = "some-uuid-123";
TodoItem mockItem = new TodoItem("Task to find");
// Use reflection to set the ID for the mock item to match the expected ID
try {
java.lang.reflect.Field idField = TodoItem.class.getDeclaredField("id");
idField.setAccessible(true);
idField.set(mockItem, id);
} catch (NoSuchFieldException | IllegalAccessException e) {
fail("Reflection error during test setup: " + e.getMessage());
}
when(todoRepository.findById(id)).thenReturn(Optional.of(mockItem));
Optional<TodoItem> result = todoService.getTodoById(id);
assertTrue(result.isPresent(), "TodoItem should be found.");
assertEquals(id, result.get().getId(), "Retrieved item ID should match.");
verify(todoRepository, times(1)).findById(id);
}
@Test
@DisplayName("Should return empty Optional when TodoItem not found by ID")
void testGetTodoById_NotFound() {
String id = "non-existent-id";
when(todoRepository.findById(id)).thenReturn(Optional.empty());
Optional<TodoItem> result = todoService.getTodoById(id);
assertFalse(result.isPresent(), "TodoItem should not be found.");
verify(todoRepository, times(1)).findById(id);
}
@Test
@DisplayName("Should return empty Optional for null ID when getting todo")
void testGetTodoById_NullId() {
Optional<TodoItem> result = todoService.getTodoById(null);
assertFalse(result.isPresent(), "Should return empty for null ID.");
verify(todoRepository, never()).findById(anyString()); // Ensure no call to repository
}
@Test
@DisplayName("Should retrieve all TodoItems")
void testGetAllTodos() {
TodoItem item1 = new TodoItem("Task 1");
TodoItem item2 = new TodoItem("Task 2");
List<TodoItem> mockList = Arrays.asList(item1, item2);
when(todoRepository.findAll()).thenReturn(mockList);
List<TodoItem> allTodos = todoService.getAllTodos();
assertNotNull(allTodos, "List of todos should not be null.");
assertEquals(2, allTodos.size(), "Should retrieve two todos.");
assertTrue(allTodos.contains(item1) && allTodos.contains(item2), "List should contain expected items.");
verify(todoRepository, times(1)).findAll();
}
@Test
@DisplayName("Should mark TodoItem as completed")
void testMarkTodoAsCompleted_Success() {
String id = "complete-this-id";
TodoItem itemToComplete = new TodoItem("Pending task");
// Set ID for the item to match
try {
java.lang.reflect.Field idField = TodoItem.class.getDeclaredField("id");
idField.setAccessible(true);
idField.set(itemToComplete, id);
} catch (NoSuchFieldException | IllegalAccessException e) {
fail("Reflection error during test setup: " + e.getMessage());
}
when(todoRepository.findById(id)).thenReturn(Optional.of(itemToComplete));
when(todoRepository.save(any(TodoItem.class))).thenReturn(itemToComplete); // Return the same item after save
Optional<TodoItem> result = todoService.markTodoAsCompleted(id);
assertTrue(result.isPresent(), "Should find and update the todo.");
assertTrue(result.get().isCompleted(), "Todo should be marked as completed.");
assertNotNull(result.get().getCompletedAt(), "Completion timestamp should be set.");
verify(todoRepository, times(1)).findById(id);
verify(todoRepository, times(1)).save(itemToComplete); // Verify save was called with the updated item
}
@Test
@DisplayName("Should not mark already completed TodoItem as completed again")
void testMarkTodoAsCompleted_AlreadyCompleted() {
String id = "already-done-id";
TodoItem alreadyCompletedItem = new TodoItem("Already done");
// Set ID and mark as completed
try {
java.lang.reflect.Field idField = TodoItem.class.getDeclaredField("id");
idField.setAccessible(true);
idField.set(alreadyCompletedItem, id);
} catch (NoSuchFieldException | IllegalAccessException e) {
fail("Reflection error during test setup: " + e.getMessage());
}
alreadyCompletedItem.markAsCompleted();
LocalDateTime originalCompletionTime = alreadyCompletedItem.getCompletedAt();
when(todoRepository.findById(id)).thenReturn(Optional.of(alreadyCompletedItem));
// No need to mock save, as it shouldn't be called
Optional<TodoItem> result = todoService.markTodoAsCompleted(id);
assertTrue(result.isPresent(), "Should find the todo.");
assertTrue(result.get().isCompleted(), "Todo should remain completed.");
assertEquals(originalCompletionTime, result.get().getCompletedAt(), "Completion timestamp should not change.");
verify(todoRepository, times(1)).findById(id);
verify(todoRepository, never()).save(any(TodoItem.class)); // Save should NOT be called
}
@Test
@DisplayName("Should delete a TodoItem successfully")
void testDeleteTodo_Success() {
String id = "delete-this-id";
when(todoRepository.deleteById(id)).thenReturn(true);
boolean deleted = todoService.deleteTodo(id);
assertTrue(deleted, "TodoItem should be successfully deleted.");
verify(todoRepository, times(1)).deleteById(id);
}
@Test
@DisplayName("Should return false when deleting non-existent TodoItem")
void testDeleteTodo_NotFound() {
String id = "non-existent-id";
when(todoRepository.deleteById(id)).thenReturn(false);
boolean deleted = todoService.deleteTodo(id);
assertFalse(deleted, "Deletion should fail for non-existent item.");
verify(todoRepository, times(1)).deleteById(id);
}
@Test
@DisplayName("Should return false for null ID when deleting todo")
void testDeleteTodo_NullId() {
boolean deleted = todoService.deleteTodo(null);
assertFalse(deleted, "Should return false for null ID.");
verify(todoRepository, never()).deleteById(anyString()); // Ensure no call to repository
}
}
Explanation:
@ExtendWith(MockitoExtension.class): This annotation is crucial. It tells JUnit 5 to enable Mockito annotations and manage the lifecycle of mock objects.@Mock private TodoRepository todoRepository;: This creates a mock instance ofTodoRepository. It’s not a realInMemoryTodoRepository; it’s a dummy object that we can program.@InjectMocks private TodoService todoService;: This creates an instance ofTodoServiceand attempts to inject the@MocktodoRepositoryinto its constructor (or fields).when(todoRepository.save(any(TodoItem.class))).thenReturn(mockItem);: This is Mockito’s way of stubbing behavior. It says, “When thesavemethod oftodoRepositoryis called with anyTodoItem, returnmockItem.”verify(todoRepository, times(1)).save(any(TodoItem.class));: This is Mockito’s way of verifying interactions. It asserts that thesavemethod ontodoRepositorywas called exactly once with anyTodoItem.verify(todoRepository, never()).save(any(TodoItem.class));: This verifies that a method was never called.- Isolation: Notice how we don’t care how the
TodoRepositorysaves or finds items. We only care thatTodoServicecalls the correct methods on the repository and handles the returned data appropriately. This is the essence of unit testing with mocks.
6. Core Implementation: Basic To-Do List Application - Service Integration Tests
Now for integration tests for TodoService. Instead of mocking the repository, we’ll use our InMemoryTodoRepository to test the interaction between TodoService and a real (albeit in-memory) data store.
Action: Create the TodoServiceIntegrationTest.java file.
File: src/test/java/com/example/project/todo/service/TodoServiceIntegrationTest.java
package com.example.project.todo.service;
import com.example.project.todo.model.TodoItem;
import com.example.project.todo.repository.InMemoryTodoRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
/**
* Integration tests for the {@link TodoService} class using a real
* {@link InMemoryTodoRepository}.
* These tests verify the interaction between the service and a data store.
*/
@DisplayName("TodoService Integration Test Suite (with InMemoryRepository)")
class TodoServiceIntegrationTest {
private InMemoryTodoRepository todoRepository; // Real repository instance
private TodoService todoService; // Service instance with real repository
@BeforeEach // This method runs before each test method
void setUp() {
todoRepository = new InMemoryTodoRepository(); // Initialize a fresh repository for each test
todoRepository.clear(); // Ensure repository is empty before each test
todoService = new TodoService(todoRepository); // Inject the real repository
}
@Test
@DisplayName("Should create and retrieve a TodoItem")
void testCreateAndGetTodo() {
// Arrange
String description = "Write integration tests";
// Act
TodoItem createdTodo = todoService.createTodo(description);
// Assert
assertNotNull(createdTodo.getId(), "ID should be generated.");
assertEquals(description, createdTodo.getDescription(), "Description should match.");
assertFalse(createdTodo.isCompleted(), "Item should not be completed.");
Optional<TodoItem> retrievedTodo = todoService.getTodoById(createdTodo.getId());
assertTrue(retrievedTodo.isPresent(), "TodoItem should be retrievable by ID.");
assertEquals(createdTodo, retrievedTodo.get(), "Retrieved item should be the same as created item.");
}
@Test
@DisplayName("Should retrieve all TodoItems when multiple exist")
void testGetAllTodos_MultipleItems() {
todoService.createTodo("Task 1");
todoService.createTodo("Task 2");
todoService.createTodo("Task 3");
List<TodoItem> todos = todoService.getAllTodos();
assertNotNull(todos, "List of todos should not be null.");
assertEquals(3, todos.size(), "Should retrieve all three todos.");
}
@Test
@DisplayName("Should mark a TodoItem as completed and persist the change")
void testMarkTodoAsCompleted_Persisted() {
TodoItem createdTodo = todoService.createTodo("Complete this task");
String todoId = createdTodo.getId();
Optional<TodoItem> updatedTodoOptional = todoService.markTodoAsCompleted(todoId);
assertTrue(updatedTodoOptional.isPresent(), "Updated todo should be present.");
assertTrue(updatedTodoOptional.get().isCompleted(), "Todo should be marked as completed.");
assertNotNull(updatedTodoOptional.get().getCompletedAt(), "Completion timestamp should be set.");
// Verify the change is persisted by retrieving it again from the repository
Optional<TodoItem> persistedTodo = todoRepository.findById(todoId);
assertTrue(persistedTodo.isPresent(), "Todo should still exist in repository.");
assertTrue(persistedTodo.get().isCompleted(), "Persisted todo should be completed.");
}
@Test
@DisplayName("Should delete a TodoItem and ensure it's removed from repository")
void testDeleteTodo_Persisted() {
TodoItem createdTodo = todoService.createTodo("Delete this task");
String todoId = createdTodo.getId();
boolean deleted = todoService.deleteTodo(todoId);
assertTrue(deleted, "TodoItem should be successfully deleted.");
// Verify it's no longer in the repository
Optional<TodoItem> retrievedTodo = todoRepository.findById(todoId);
assertFalse(retrievedTodo.isPresent(), "Deleted TodoItem should not be found in repository.");
}
@Test
@DisplayName("Should not delete a non-existent TodoItem")
void testDeleteTodo_NonExistent() {
boolean deleted = todoService.deleteTodo("non-existent-id");
assertFalse(deleted, "Deletion of non-existent item should return false.");
assertEquals(0, todoService.getAllTodos().size(), "Repository should remain empty.");
}
}
Explanation:
@BeforeEach: This annotation marks a method to be executed before each test method in the class. This is perfect for setting up a clean state for every test. Here, we create a newInMemoryTodoRepositoryandTodoServiceinstance, ensuring tests are isolated from each other.- Real Interaction: Unlike unit tests, here
todoServiceinteracts with atodoRepositorythat actually “stores” data in itsConcurrentHashMap. We’re testing the “seam” between the service and the repository. - Persistence Verification: After performing an action (e.g.,
markTodoAsCompleted), we explicitly retrieve the item again from thetodoRepositoryto ensure the changes were correctly persisted through the repository. This is what makes it an integration test.
Testing This Component
To run all the tests we’ve written:
- Open your terminal or command prompt.
- Navigate to your project’s root directory (where
pom.xmlis located). - Execute the Maven test command:
mvn clean test
Expected Behavior:
- Maven will compile your main and test sources.
- The
maven-surefire-pluginwill discover and execute all classes ending withTest(orTests,TestCase) in yoursrc/test/javadirectory. - You should see output indicating that all tests passed successfully. For instance,
[INFO] Tests run: 25, Failures: 0, Errors: 0, Skipped: 0. (The exact number of tests depends on how many individual@Testmethods you’ve written). - If any test fails, Maven will report it, showing which assertion failed and its message.
Debugging Tips:
- Read the stack trace: If a test fails, the stack trace in the console output is your best friend. It will pinpoint the exact line of code in your test or application where the error occurred.
- Use your IDE’s debugger: Set breakpoints in your test methods and the application code they call. Run the test in debug mode to step through the execution and inspect variable values.
- Print statements (as a last resort): Temporarily add
System.out.println()statements to log variable values at different points in your code or test to understand the flow. Remember to remove them afterward! - Isolate the failing test: If many tests fail, comment out all but one failing test to focus on debugging that specific issue.
- Check mock behavior: If using Mockito, ensure your
when()stubs correctly reflect the expected behavior of the mocked dependencies. Also, verify thatverify()calls match the actual interactions.
Production Considerations
Testing is not just a development phase; it’s a continuous process that impacts the production readiness of your application.
- Test Coverage: Aim for high test coverage (e.g., 80% or more for critical business logic). Tools like JaCoCo can integrate with Maven/Gradle to generate coverage reports, helping you identify untested areas. However, remember that coverage is a metric, not a goal in itself; focus on meaningful tests.
- CI/CD Integration: All unit and integration tests should be an integral part of your Continuous Integration/Continuous Deployment (CI/CD) pipeline. Every code commit should trigger an automated test run. If tests fail, the build should fail, preventing faulty code from reaching production.
- Performance Testing: While unit and integration tests focus on correctness, performance testing (e.g., with JMeter or Gatling) is crucial for production. This would typically be a separate stage in your CI/CD pipeline, often for larger, more complex applications.
- Security Testing: Automated security scans (SAST, DAST) and penetration testing are vital. Your integration tests can sometimes include basic security checks (e.g., ensuring authentication/authorization mechanisms work as expected). However, dedicated security testing is a broader topic.
- Logging and Monitoring: Ensure your application’s logging is robust, as demonstrated in our
CalculatorandTodoService. In production, these logs are invaluable for monitoring application health, debugging issues, and understanding user behavior. Your tests should implicitly ensure that logging statements don’t cause runtime errors, but verifying specific log messages might require advanced testing techniques.
Code Review Checkpoint
At this point, you’ve significantly enhanced the reliability of your projects by implementing a comprehensive testing strategy.
Summary of what was built:
- Configured Maven to include JUnit 5 and Mockito dependencies.
- Developed a
Calculatorclass with basic arithmetic operations and robust error handling for division by zero. - Created a
TodoItemmodel with state management and validation. - Implemented a
TodoRepositoryinterface and anInMemoryTodoRepositoryfor data persistence. - Built a
TodoServiceto encapsulate business logic for the To-Do List application. - Wrote extensive unit tests for the
CalculatorandTodoItemclasses, covering normal behavior and edge cases. - Implemented unit tests for
TodoServiceusing Mockito to isolate business logic from repository concerns. - Developed integration tests for
TodoServiceusingInMemoryTodoRepositoryto verify interactions between the service and a data store.
Files created/modified:
pom.xml(modified: added JUnit 5 and Mockito dependencies)src/main/java/com/example/project/calculator/Calculator.java(new)src/test/java/com/example/project/calculator/CalculatorTest.java(new)src/main/java/com/example/project/todo/model/TodoItem.java(new)src/test/java/com/example/project/todo/model/TodoItemTest.java(new)src/main/java/com/example/project/todo/repository/TodoRepository.java(new)src/main/java/com/example/project/todo/repository/InMemoryTodoRepository.java(new)src/main/java/com/example/project/todo/service/TodoService.java(new)src/test/java/com/example/project/todo/service/TodoServiceUnitTest.java(new)src/test/java/com/example/project/todo/service/TodoServiceIntegrationTest.java(new)
How it integrates with existing code:
The tests directly exercise the public API of our Calculator, TodoItem, and TodoService classes. The TodoService relies on the TodoRepository abstraction, which allows us to swap between mocked and in-memory implementations for different testing levels. This modularity is a hallmark of good design and testability.
Common Issues & Solutions
Even with careful planning, you might encounter issues when writing and running tests. Here are some common ones:
Tests Not Running or Being Discovered:
- Issue: You run
mvn test, but it reports “No tests were executed” or skips your tests. - Solution:
- Check Naming Conventions: JUnit 5 typically discovers classes ending in
Test,Tests,TestCase. Ensure your test files follow this pattern (e.g.,CalculatorTest.java). - Verify Dependencies: Make sure
junit-jupiter-apiandjunit-jupiter-engineare correctly added to yourpom.xmlwithscope=test. - Maven Surefire Plugin: Ensure
maven-surefire-pluginis configured in yourbuildsection and its version is compatible with JUnit 5. - IDE Configuration: If running from an IDE, sometimes the IDE’s JUnit configuration needs to be refreshed or pointed to the correct JUnit 5 libraries.
- Check Naming Conventions: JUnit 5 typically discovers classes ending in
- Issue: You run
NullPointerExceptionin Tests (when using Mockito):- Issue: Your service class, which has a
@Mockdependency injected, throws aNullPointerExceptionwhen calling a method on that mock. - Solution:
@ExtendWith(MockitoExtension.class): Ensure your test class is annotated with@ExtendWith(MockitoExtension.class). Without it,@Mockand@InjectMocksannotations won’t be processed, and the mock objects won’t be initialized or injected.@InjectMocksCorrectness: Make sure@InjectMocksis on the class under test, and@Mockis on its dependencies. Mockito needs to know which object to create and where to inject the mocks.- Stubbing: You might have forgotten to
when().thenReturn()a behavior for a method call on your mock. If a method on a mock is called and no behavior is defined, it returnsnullfor objects,0for primitives,falsefor booleans, which can lead toNullPointerExceptionlater.
- Issue: Your service class, which has a
Flaky Tests (Tests that sometimes pass, sometimes fail):
- Issue: A test passes most of the time but occasionally fails without any code changes.
- Solution:
- External Dependencies: Flakiness often indicates reliance on external factors (network, database state, file system). Ensure your tests are truly isolated. Use mocks or in-memory implementations (like
InMemoryTodoRepository) instead of real external systems where possible. - Concurrency Issues: If your application is multi-threaded, tests might expose race conditions. Ensure thread-safe data structures are used, and if testing concurrency, use specific concurrency testing utilities.
- Time-Dependent Logic: Code that relies on
LocalDateTime.now()orSystem.currentTimeMillis()can be flaky. In tests, consider injecting aClockobject or usingMockito.mock(Clock.class)to control time. - Shared State: Ensure
setUp()methods (@BeforeEach) properly reset all shared state to a clean slate before each test.
- External Dependencies: Flakiness often indicates reliance on external factors (network, database state, file system). Ensure your tests are truly isolated. Use mocks or in-memory implementations (like
Testing & Verification
To verify that all the work in this chapter is correctly implemented and functional:
Run All Tests: Execute
mvn clean testfrom your project root.- Expected Output: You should see a successful build message from Maven, indicating that all unit and integration tests passed. The number of tests executed should correspond to the sum of all
@Testmethods inCalculatorTest,TodoItemTest,TodoServiceUnitTest, andTodoServiceIntegrationTest. - Verification: Check the
target/surefire-reportsdirectory for detailed XML and text reports of the test run. These reports provide a comprehensive overview of passed, failed, and skipped tests.
- Expected Output: You should see a successful build message from Maven, indicating that all unit and integration tests passed. The number of tests executed should correspond to the sum of all
Inspect Code Coverage (Optional but Recommended):
- Integrate a code coverage tool like JaCoCo. Add the following plugin to your
pom.xmlwithin the<build><plugins>section:<!-- pom.xml --> <plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>0.8.11</version> <!-- Latest stable as of Dec 2025 --> <executions> <execution> <goals> <goal>prepare-agent</goal> </goals> </execution> <execution> <id>report</id> <phase>prepare-package</phase> <goals> <goal>report</goal> </goals> </execution> </executions> </plugin> - Then run
mvn clean install. After the build, opentarget/site/jacoco/index.htmlin your web browser. - Verification: You should see a detailed report showing the percentage of lines, branches, and methods covered by your tests for each class. Aim for high coverage, especially for core logic.
- Integrate a code coverage tool like JaCoCo. Add the following plugin to your
Summary & Next Steps
Congratulations! You’ve successfully completed Chapter 14, establishing a robust testing foundation for our Java projects. You’ve learned:
- The importance of unit and integration testing in building production-ready applications.
- How to set up JUnit 5 and Mockito in a Maven project.
- How to write comprehensive unit tests for individual classes, including normal flows and edge cases, using JUnit 5 assertions.
- How to effectively use Mockito to isolate dependencies and test service-layer business logic.
- How to perform integration tests to verify interactions between components using an in-memory repository.
- Best practices for structuring tests, running them, and debugging common issues.
This chapter’s work significantly enhances the quality and maintainability of our Simple Calculator and Basic To-Do List Application. You now have a safety net that will catch regressions and provide confidence as we continue to evolve these applications.
In the next chapter, we will build upon the Basic To-Do List Application by introducing a more persistent data storage solution, likely integrating with a lightweight relational database like H2 or SQLite, and adapting our repository layer to interact with it. We’ll also explore how to further enhance our testing strategy for database interactions.