← Back to Advanced Java
Practical Work 1

Build Tools & Test-Driven Development

Create a multi-module Maven project with TDD and code coverage

Duration 3-4 hours
Difficulty Beginner
Session 1 - Build Tools

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 -version to verify)
  • Maven 3.8+ installed (mvn -version to 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-calculator project folder
  • Screenshot: JaCoCo coverage report showing >80% coverage
  • Screenshot: Successful mvn clean verify output

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,1001 returns 2
  • Challenge 4: Create a Gradle build as alternative (build.gradle.kts)
  • Challenge 5: Add GitHub Actions workflow for CI/CD

Resources