Build Tools & Test-Driven Development
Create a multi-module Maven project with TDD and code coverage
Objectives
By the end of this practical work, you will be able to:
- Create a multi-module Maven project from scratch
- Configure dependency management in a parent POM
- Write tests using TDD (Red-Green-Refactor cycle)
- Configure JaCoCo for code coverage reporting
- Understand and use the Maven build lifecycle
- Analyze project dependencies with Maven tools
Prerequisites
- Java 17+ installed (
java -versionto verify) - Maven 3.8+ installed (
mvn -versionto verify) - A text editor or IDE (IntelliJ IDEA, VS Code, or Eclipse)
- Basic Java knowledge (classes, methods, exceptions)
Note: If Maven is not installed, download it from maven.apache.org and add it to your PATH.
Project Overview
You will build a StringCalculator utility library using TDD. This is a classic coding kata that teaches test-first development.
Project Structure
- string-calculator/
- pom.xml (parent POM)
- calculator-core/
- pom.xml
- src/main/java/com/example/calculator/
- StringCalculator.java
- src/test/java/com/example/calculator/
- StringCalculatorTest.java
Instructions
Step 1: Create Project Directory Structure
Create the project directories:
# Create the parent project directory
mkdir string-calculator
cd string-calculator
# Create the module directory structure
mkdir -p calculator-core/src/main/java/com/example/calculator
mkdir -p calculator-core/src/test/java/com/example/calculator
Step 2: Create Parent POM
Create string-calculator/pom.xml with dependency management:
<?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</groupId> <!-- (#1:Unique organization identifier) -->
<artifactId>string-calculator</artifactId> <!-- (#2:Project name) -->
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging> <!-- (#3:Parent POM packaging type) -->
<name>String Calculator</name>
<description>A TDD kata project for learning Maven and testing</description>
<modules> <!-- (#4:Declare child modules) -->
<module>calculator-core</module>
</modules>
<properties> <!-- (#5:Centralized version management) -->
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>5.10.2</junit.version>
<jacoco.version>0.8.11</jacoco.version>
</properties>
<dependencyManagement> <!-- (#6:Manage versions for all modules) -->
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
<plugin> <!-- (#7:JaCoCo for code coverage) -->
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>
Step 3: Create Module POM
Create calculator-core/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>
<parent> <!-- (#1:Inherit from parent POM) -->
<groupId>com.example</groupId>
<artifactId>string-calculator</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>calculator-core</artifactId>
<name>Calculator Core</name>
<dependencies>
<dependency> <!-- (#2:No version needed - inherited from parent) -->
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin> <!-- (#3:Enable JaCoCo coverage) -->
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal> <!-- (#4:Instrument classes for coverage) -->
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals>
<goal>report</goal> <!-- (#5:Generate HTML report) -->
</goals>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal> <!-- (#6:Fail if coverage below threshold) -->
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum> <!-- (#7:80% minimum coverage) -->
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Step 4: TDD Iteration 1 - Empty String Returns Zero
Following TDD, we write the test FIRST (RED), then implement to make it pass (GREEN).
Create calculator-core/src/test/java/com/example/calculator/StringCalculatorTest.java:
package com.example.calculator;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import static org.junit.jupiter.api.Assertions.*;
class StringCalculatorTest {
private StringCalculator calculator;
@BeforeEach // (#1:Runs before each test)
void setUp() {
calculator = new StringCalculator();
}
@Test // (#2:First test - empty string)
void shouldReturnZeroForEmptyString() {
assertEquals(0, calculator.add(""));
}
}
Now create the minimal implementation in calculator-core/src/main/java/com/example/calculator/StringCalculator.java:
package com.example.calculator;
public class StringCalculator {
public int add(String numbers) {
if (numbers.isEmpty()) { // (#1:Handle empty string)
return 0;
}
return 0; // (#2:Placeholder for next iteration)
}
}
Run the test:
cd string-calculator
mvn test
Expected Output:
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
Step 5: TDD Iteration 2 - Single Number
Add a new test for a single number:
@Test
void shouldReturnNumberForSingleNumber() {
assertEquals(1, calculator.add("1"));
assertEquals(5, calculator.add("5"));
}
Update the implementation:
public int add(String numbers) {
if (numbers.isEmpty()) {
return 0;
}
return Integer.parseInt(numbers); // (#1:Parse single number)
}
Run tests again:
mvn test
Step 6: TDD Iteration 3 - Two Numbers with Comma
Add test for two numbers:
@Test
void shouldReturnSumForTwoNumbers() {
assertEquals(3, calculator.add("1,2"));
assertEquals(10, calculator.add("4,6"));
}
Update implementation:
public int add(String numbers) {
if (numbers.isEmpty()) {
return 0;
}
if (numbers.contains(",")) { // (#1:Check for delimiter)
String[] parts = numbers.split(","); // (#2:Split by comma)
int sum = 0;
for (String part : parts) {
sum += Integer.parseInt(part.trim()); // (#3:Sum all parts)
}
return sum;
}
return Integer.parseInt(numbers);
}
Step 7: TDD Iteration 4 - Multiple Numbers
Add test for multiple numbers:
@Test
void shouldReturnSumForMultipleNumbers() {
assertEquals(6, calculator.add("1,2,3"));
assertEquals(15, calculator.add("1,2,3,4,5"));
}
The implementation already handles this case! Run tests to confirm:
mvn test
Step 8: TDD Iteration 5 - Newlines as Delimiters
Add test for newline support:
@Test
void shouldHandleNewlinesAsDelimiters() {
assertEquals(6, calculator.add("1\n2,3")); // (#1:Mix of newlines and commas)
assertEquals(10, calculator.add("1\n2\n3\n4"));
}
Update implementation to handle newlines:
public int add(String numbers) {
if (numbers.isEmpty()) {
return 0;
}
// Replace newlines with commas for uniform processing
String normalized = numbers.replace("\n", ","); // (#1:Normalize delimiters)
String[] parts = normalized.split(",");
int sum = 0;
for (String part : parts) {
if (!part.trim().isEmpty()) {
sum += Integer.parseInt(part.trim());
}
}
return sum;
}
Step 9: TDD Iteration 6 - Custom Delimiters
Add test for custom delimiter syntax //[delimiter]\n[numbers]:
@Test
void shouldSupportCustomDelimiter() {
assertEquals(3, calculator.add("//;\n1;2")); // (#1:Semicolon delimiter)
assertEquals(6, calculator.add("//|\n1|2|3")); // (#2:Pipe delimiter)
}
Update implementation:
public int add(String numbers) {
if (numbers.isEmpty()) {
return 0;
}
String delimiter = ",|\n"; // (#1:Default delimiters: comma or newline)
String numbersPart = numbers;
// Check for custom delimiter
if (numbers.startsWith("//")) { // (#2:Custom delimiter syntax)
int delimiterEnd = numbers.indexOf("\n");
delimiter = numbers.substring(2, delimiterEnd); // (#3:Extract delimiter)
// Escape special regex characters
delimiter = java.util.regex.Pattern.quote(delimiter); // (#4:Escape for regex)
numbersPart = numbers.substring(delimiterEnd + 1);
}
String[] parts = numbersPart.split(delimiter);
int sum = 0;
for (String part : parts) {
if (!part.trim().isEmpty()) {
sum += Integer.parseInt(part.trim());
}
}
return sum;
}
Step 10: TDD Iteration 7 - Negative Numbers Exception
Add test for negative number handling:
@Test
void shouldThrowExceptionForNegativeNumbers() {
IllegalArgumentException exception = assertThrows( // (#1:Expect exception)
IllegalArgumentException.class,
() -> calculator.add("1,-2,3")
);
assertTrue(exception.getMessage().contains("-2")); // (#2:Message includes negative)
}
@Test
void shouldShowAllNegativesInException() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> calculator.add("-1,-2,3")
);
assertTrue(exception.getMessage().contains("-1"));
assertTrue(exception.getMessage().contains("-2"));
}
Update implementation:
public int add(String numbers) {
if (numbers.isEmpty()) {
return 0;
}
String delimiter = ",|\n";
String numbersPart = numbers;
if (numbers.startsWith("//")) {
int delimiterEnd = numbers.indexOf("\n");
delimiter = numbers.substring(2, delimiterEnd);
delimiter = java.util.regex.Pattern.quote(delimiter);
numbersPart = numbers.substring(delimiterEnd + 1);
}
String[] parts = numbersPart.split(delimiter);
int sum = 0;
java.util.List<Integer> negatives = new java.util.ArrayList<>(); // (#1:Collect negatives)
for (String part : parts) {
if (!part.trim().isEmpty()) {
int num = Integer.parseInt(part.trim());
if (num < 0) {
negatives.add(num); // (#2:Track negative numbers)
} else {
sum += num;
}
}
}
if (!negatives.isEmpty()) { // (#3:Throw if any negatives found)
throw new IllegalArgumentException(
"Negatives not allowed: " + negatives
);
}
return sum;
}
Step 11: Run Full Build with Coverage
Run the complete build lifecycle with coverage:
mvn clean verify
Expected Output:
[INFO] --- jacoco:0.8.11:check (check) @ calculator-core ---
[INFO] All coverage checks have been met.
[INFO] ------------------------------------------------------------------------
[INFO] Reactor Summary:
[INFO]
[INFO] String Calculator .................................. SUCCESS
[INFO] Calculator Core .................................... SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
View the coverage report:
# Open the HTML report in your browser
# Location: calculator-core/target/site/jacoco/index.html
Step 12: Analyze Dependencies
Use Maven to analyze project dependencies:
# View dependency tree
mvn dependency:tree
Expected Output:
[INFO] com.example:calculator-core:jar:1.0.0-SNAPSHOT
[INFO] \- org.junit.jupiter:junit-jupiter:jar:5.10.2:test
[INFO] +- org.junit.jupiter:junit-jupiter-api:jar:5.10.2:test
[INFO] +- org.junit.jupiter:junit-jupiter-params:jar:5.10.2:test
[INFO] \- org.junit.jupiter:junit-jupiter-engine:jar:5.10.2:test
# Check for dependency conflicts
mvn dependency:analyze
Final Test Class
Your complete test class should look like this:
package com.example.calculator;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import static org.junit.jupiter.api.Assertions.*;
class StringCalculatorTest {
private StringCalculator calculator;
@BeforeEach
void setUp() {
calculator = new StringCalculator();
}
@Test
void shouldReturnZeroForEmptyString() {
assertEquals(0, calculator.add(""));
}
@Test
void shouldReturnNumberForSingleNumber() {
assertEquals(1, calculator.add("1"));
assertEquals(5, calculator.add("5"));
}
@Test
void shouldReturnSumForTwoNumbers() {
assertEquals(3, calculator.add("1,2"));
assertEquals(10, calculator.add("4,6"));
}
@Test
void shouldReturnSumForMultipleNumbers() {
assertEquals(6, calculator.add("1,2,3"));
assertEquals(15, calculator.add("1,2,3,4,5"));
}
@Test
void shouldHandleNewlinesAsDelimiters() {
assertEquals(6, calculator.add("1\n2,3"));
assertEquals(10, calculator.add("1\n2\n3\n4"));
}
@Test
void shouldSupportCustomDelimiter() {
assertEquals(3, calculator.add("//;\n1;2"));
assertEquals(6, calculator.add("//|\n1|2|3"));
}
@Test
void shouldThrowExceptionForNegativeNumbers() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> calculator.add("1,-2,3")
);
assertTrue(exception.getMessage().contains("-2"));
}
@Test
void shouldShowAllNegativesInException() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> calculator.add("-1,-2,3")
);
assertTrue(exception.getMessage().contains("-1"));
assertTrue(exception.getMessage().contains("-2"));
}
}
Expected Output
After completing this practical work, you should have:
- A multi-module Maven project with proper parent-child relationship
- 8 passing tests covering all functionality
- Code coverage above 80% (viewable in JaCoCo HTML report)
- Understanding of TDD red-green-refactor cycle
Deliverables
- Source Code: Complete
string-calculatorproject folder - Screenshot: JaCoCo coverage report showing >80% coverage
- Screenshot: Successful
mvn clean verifyoutput
Bonus Challenges
- Challenge 1: Add support for delimiters of any length:
//[***]\n1***2***3 - Challenge 2: Add support for multiple delimiters:
//[*][%]\n1*2%3 - Challenge 3: Ignore numbers bigger than 1000:
2,1001returns 2 - Challenge 4: Create a Gradle build as alternative (
build.gradle.kts) - Challenge 5: Add GitHub Actions workflow for CI/CD