AdvancedJava · Lesson 1 of 9

Maven Projects & Testing with JUnit

Standard project layout, pom.xml dependencies, and unit tests with JUnit including parameterized tests.

Real Java projects use a build tool. Maven (and Gradle) download dependencies, compile, run tests and package your application. Maven projects follow a standard layout: production code in src/main/java, tests in src/test/java, and everything configured in pom.xml.

JUnit is the standard testing framework. Annotate test methods with @Test, check results with assertEquals, assertTrue and assertThrows, and use @ParameterizedTest to run one test with many inputs — ideal for boundary values.

mvn test compiles and runs all tests; mvn package also builds a .jar. The Maven Wrapper (./mvnw) pins the Maven version for everyone on the team.

Project layoutText
grading/
├── pom.xml
└── src/
    ├── main/java/tz/dolese/grading/Grading.java
    └── test/java/tz/dolese/grading/GradingTest.java
pom.xmlXML
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>tz.dolese</groupId>
  <artifactId>grading</artifactId>
  <version>1.0.0</version>

  <properties>
    <maven.compiler.release>21</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

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

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.4</version>
      </plugin>
    </plugins>
  </build>
</project>
src/main/java/tz/dolese/grading/Grading.javaJava
package tz.dolese.grading;

public final class Grading {
    private Grading() {}

    public static String gradeFor(int score) {
        if (score < 0 || score > 100) {
            throw new IllegalArgumentException("score must be between 0 and 100");
        }
        if (score >= 75) return "A";
        if (score >= 65) return "B";
        if (score >= 45) return "C";
        if (score >= 30) return "D";
        return "F";
    }

    public static double average(int... scores) {
        if (scores.length == 0) return 0;
        long total = 0;
        for (int s : scores) total += s;
        return (double) total / scores.length;
    }
}
src/test/java/tz/dolese/grading/GradingTest.javaJava
package tz.dolese.grading;

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

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;

class GradingTest {

    @ParameterizedTest(name = "{0} -> {1}")
    @CsvSource({"100,A", "75,A", "74,B", "65,B", "64,C", "45,C", "30,D", "29,F", "0,F"})
    void gradeBoundaries(int score, String expected) {
        assertEquals(expected, Grading.gradeFor(score));
    }

    @ParameterizedTest
    @ValueSource(ints = {-1, 101})
    void rejectsOutOfRange(int score) {
        var e = assertThrows(IllegalArgumentException.class, () -> Grading.gradeFor(score));
        assertTrue(e.getMessage().contains("between 0 and 100"));
    }

    @Test
    @DisplayName("average of no scores is zero")
    void emptyAverage() {
        assertEquals(0.0, Grading.average());
    }

    @Test
    void averageOfScores() {
        assertEquals(77.67, Grading.average(78, 64, 91), 0.01);
    }
}
TerminalShell
mvn test        # compile and run all tests
mvn package     # build target/grading-1.0.0.jar

Key points

  • Standard layout: src/main/java, src/test/java, configured by pom.xml.
  • Test boundaries and failure cases; @ParameterizedTest avoids copy-pasted tests.
  • mvn test on every change (and in CI) keeps regressions out.

Exercise

Add a ReportCard class to the project that takes a map of subject → score and computes the average, best subject and overall grade. Write JUnit tests for it, including an empty report card.

Show solution

Try the exercise yourself first — then compare your approach with this one.

ReportCard takes a copy of the subject → score map, so callers can't change it afterwards. The empty case is handled explicitly: average 0, no best subject (an empty Optional) and grade F. The tests cover a normal report, a tie-free best subject and the empty report.

src/main/java/tz/dolese/grading/ReportCard.javaJava
package tz.dolese.grading;

import java.util.Map;
import java.util.Optional;

public final class ReportCard {
    private final Map<String, Integer> scores;

    public ReportCard(Map<String, Integer> scores) {
        this.scores = Map.copyOf(scores);
    }

    public double average() {
        return scores.values().stream().mapToInt(Integer::intValue).average().orElse(0);
    }

    public Optional<String> bestSubject() {
        return scores.entrySet().stream().max(Map.Entry.comparingByValue()).map(Map.Entry::getKey);
    }

    public String overallGrade() {
        return Grading.gradeFor((int) Math.round(average()));
    }
}
src/test/java/tz/dolese/grading/ReportCardTest.javaJava
package tz.dolese.grading;

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

import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;

class ReportCardTest {

    @Test
    void summarisesScores() {
        var card = new ReportCard(Map.of("Maths", 88, "Biology", 79, "English", 91));
        assertEquals(86.0, card.average(), 0.01);
        assertEquals(Optional.of("English"), card.bestSubject());
        assertEquals("A", card.overallGrade());
    }

    @Test
    void gradesFromTheRoundedAverage() {
        var card = new ReportCard(Map.of("Maths", 64, "Biology", 65));   // 64.5 rounds to 65
        assertEquals("B", card.overallGrade());
    }

    @Test
    void handlesAnEmptyReportCard() {
        var card = new ReportCard(Map.of());
        assertEquals(0.0, card.average());
        assertTrue(card.bestSubject().isEmpty());
        assertEquals("F", card.overallGrade());
    }
}

Check your understanding

  1. Where do tests live in a standard Maven project?

  2. What does @ParameterizedTest with @CsvSource do?

  3. How do you check that code throws an exception in JUnit?

  4. What does the Maven Wrapper (./mvnw) give a team?

Ask AI