- Write a test class with
@TestandassertEquals,assertThrows,assertAll - Run one test for many data sets with
@ParameterizedTest - Replace a dependency with Mockito and check calls with
verify
You made a small change to a method and the program still runs. But did something break in ten other places? You cannot check everything by hand every time. Professional teams write automated tests for this: small methods that call your code and compare the result with the expected value. Hundreds of tests run in a few seconds and catch a bug before a user does.
What is a test?
A unit test checks one small piece of code — usually one method — in isolation. The idea is simple: you know the expected result, you call the method and you compare the two. Below is a tiny test “framework” written without any library. The third check has a wrong expectation on purpose, so that you can see what a failing test looks like.
import java.util.List;
public class Main {
static int passed = 0, failed = 0;
static void check(String name, Object expected, Object actual) {
if (expected.equals(actual)) {
passed++;
System.out.println("PASS " + name);
} else {
failed++;
System.out.println("FAIL " + name + ": expected " + expected + " but was " + actual);
}
}
public static void main(String[] args) {
check("average of 85, 90, 95", 90.0, Grades.average(List.of(85, 90, 95)));
check("95 is an A", "A", Grades.letter(95));
check("79 is a B", "B", Grades.letter(79));
System.out.println(passed + " passed, " + failed + " failed");
}
}
class Grades {
static double average(List<Integer> scores) {
if (scores.isEmpty()) throw new IllegalArgumentException("No scores");
return scores.stream().mapToInt(Integer::intValue).average().getAsDouble();
}
static String letter(int score) {
if (score < 0 || score > 100) throw new IllegalArgumentException("Score out of range: " + score);
return score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : score >= 50 ? "D" : "F";
}
}PASS average of 85, 90, 95 PASS 95 is an A FAIL 79 is a B: expected B but was C 2 passed, 1 failed
JUnit does the same job professionally: it finds the test methods itself, runs each one separately, collects the failures and produces a neat report in the IDE, Maven or Gradle. The org.junit.jupiter:junit-jupiter dependency (with test scope) from the previous lesson is all you need. The main part of JUnit 5 is called Jupiter, so its packages start with org.junit.jupiter.
Your first JUnit 5 tests
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GradesTest {
@Test
void averageOfThreeScores() {
List<Integer> scores = List.of(85, 90, 95); // Arrange
double result = Grades.average(scores); // Act
assertEquals(90.0, result, 0.001); // Assert
}
@Test
@DisplayName("An empty list is rejected")
void emptyListThrows() {
var ex = assertThrows(IllegalArgumentException.class, () -> Grades.average(List.of()));
assertEquals("No scores", ex.getMessage());
}
@Test
void lettersAtBoundaries() {
assertAll(
() -> assertEquals("A", Grades.letter(90)),
() -> assertEquals("B", Grades.letter(80)),
() -> assertEquals("F", Grades.letter(49)));
}
}src/test/java/GradesTest.java — the Grades class is the same as aboveEvery test method has the @Test annotation; it does not have to be public and returns nothing. The first test follows the AAA pattern: Arrange (prepare the data), Act (call the method), Assert (check the result). assertEquals(expected, actual, delta) compares floating-point numbers with a precision of 0.001, because in Java 0.1 + 0.2 is not exactly 0.3 but 0.30000000000000004. assertThrows checks that an exception is thrown and returns it, so you can check its message too. assertAll runs all the checks and does not stop at the first failure.
| Annotation | What it does |
|---|---|
@Test | marks a method as a test |
@DisplayName("...") | gives a readable name in the report |
@BeforeEach / @AfterEach | runs before / after every test |
@BeforeAll / @AfterAll | runs once for the whole class (a static method) |
@Disabled | temporarily turns a test off |
@ParameterizedTest | runs the test once per row of data |
JUnit creates a new object for every test, so tests do not affect each other and can run in any order. You run tests with the green ▶ button in the IDE, or with mvn test or ./gradlew test in a terminal. Imagine someone accidentally changed the condition score >= 80 in letter to score > 80:
mvn test[INFO] Running GradesTest [ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0 [ERROR] Failures: [ERROR] GradesTest.lettersAtBoundaries:25 Multiple Failures (1 failure) org.opentest4j.AssertionFailedError: expected: <B> but was: <C> [INFO] BUILD FAILURE
import org.junit.Test; // JUnit 4
import static org.junit.Assert.assertEquals;
public class GradesTest {
@Test(expected = IllegalArgumentException.class)
public void emptyListThrows() {
Grades.average(List.of());
}
}import org.junit.jupiter.api.Test; // JUnit 5
import static org.junit.jupiter.api.Assertions.*;
class GradesTest {
@Test
void emptyListThrows() {
var ex = assertThrows(IllegalArgumentException.class, () -> Grades.average(List.of()));
assertEquals("No scores", ex.getMessage());
}
}public, and you can check the exception's message tooParameterized tests
To repeat the same check with different data you do not need to copy the test. @ParameterizedTest runs the test separately for each data set: @CsvSource passes each comma-separated row to the method's parameters, and @ValueSource is for a simple list of values. The report shows every case on its own, for example 85 -> B. These annotations live in the junit-jupiter-params module, which is already included in the junit-jupiter dependency.
@Test void letterFor95() { assertEquals("A", Grades.letter(95)); }
@Test void letterFor85() { assertEquals("B", Grades.letter(85)); }
@Test void letterFor72() { assertEquals("C", Grades.letter(72)); }
@Test void letterFor49() { assertEquals("F", Grades.letter(49)); }
// one more case = one more copy-pasted method...@ParameterizedTest(name = "{0} -> {1}")
@CsvSource({ "95, A", "85, B", "72, C", "49, F" })
void letterForScore(int score, String expected) {
assertEquals(expected, Grades.letter(score));
}
@ParameterizedTest
@ValueSource(ints = { -1, 101, 150 })
void outOfRangeScoresAreRejected(int score) {
assertThrows(IllegalArgumentException.class, () -> Grades.letter(score));
}Mockito: replacing dependencies
A real class often depends on other objects: a database, an email server, an external API. Calling them for real in a unit test is slow and unreliable. The solution is to pass the dependency in through the constructor and replace it in the test with a mock (a fake object). The ReportService below gets scores from the ScoreRepository interface and does not know whether it reads a database or is a fake.
interface ScoreRepository {
List<Integer> findScores(String student); // e.g. reads a database
}
class ReportService {
private final ScoreRepository repository;
ReportService(ScoreRepository repository) {
this.repository = repository;
}
String report(String student) {
return student + ": " + Grades.average(repository.findScores(student));
}
}import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;
import java.util.List;
import org.junit.jupiter.api.Test;
class ReportServiceTest {
@Test
void reportUsesScoresFromRepository() {
ScoreRepository repo = mock(ScoreRepository.class);
when(repo.findScores("Aysel")).thenReturn(List.of(90, 80));
ReportService service = new ReportService(repo);
String report = service.report("Aysel");
assertEquals("Aysel: 85.0", report);
verify(repo).findScores("Aysel");
verifyNoMoreInteractions(repo);
}
}org.mockito:mockito-junit-jupiter dependency (test scope)mock(...) creates an empty fake copy of the interface. when(...).thenReturn(...) teaches it “if asked about Aysel, return 90 and 80” — this is called a stub. verify checks that the service called the repository exactly once and with the right argument, and verifyNoMoreInteractions confirms there were no extra calls. In larger test classes the same is done with annotations: @ExtendWith(MockitoExtension.class) on the class and @Mock on a field.
Key points
- A unit test checks a small piece of code in isolation: it compares the expected result with the actual one.
- JUnit 5:
@Test,assertEquals(expected, actual),assertThrows,assertAll; tests live insrc/test/java. - The AAA pattern: Arrange → Act → Assert; every test runs on a new object and does not depend on the others.
@ParameterizedTest+@CsvSource/@ValueSourceruns one test with many data sets.- Mockito replaces dependencies:
mock,when(...).thenReturn(...),verify; passing a dependency through the constructor makes testing easy.
Check yourself
10 questions. Every correct answer earns XP.
assertEquals(0.3, 0.1 + 0.2) fail?