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.
grading/
├── pom.xml
└── src/
├── main/java/tz/dolese/grading/Grading.java
└── test/java/tz/dolese/grading/GradingTest.java<?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>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;
}
}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);
}
}mvn test # compile and run all tests
mvn package # build target/grading-1.0.0.jarKey points
- Standard layout:
src/main/java,src/test/java, configured bypom.xml. - Test boundaries and failure cases;
@ParameterizedTestavoids copy-pasted tests. mvn teston 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.
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()));
}
}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());
}
}