Skip to content
Educora
Advanced22 min14 / 18

Testing with JUnit 5

Write automated unit tests with JUnit 5: assertions, parameterized tests and replacing dependencies with Mockito.

Check yourself
In this lesson you will learn
  • Write a test class with @Test and assertEquals, 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.

Java
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";
    }
}
Expected output
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
The essence of a test: comparing the expected and the actual result

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

Java
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 above

Every 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.

AnnotationWhat it does
@Testmarks a method as a test
@DisplayName("...")gives a readable name in the report
@BeforeEach / @AfterEachruns before / after every test
@BeforeAll / @AfterAllruns once for the whole class (a static method)
@Disabledtemporarily turns a test off
@ParameterizedTestruns 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:

Terminal
mvn test
Expected output
[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
Sample output (shortened): the test caught the bug at once — 80 points is no longer a “B”
JUnit 4 (old)
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());
    }
}
JUnit 5 (modern)
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());
    }
}
In JUnit 5 classes and methods need not be public, and you can check the exception's message too

Parameterized 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.

Copy-paste tests
@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...
Parameterized tests: a new case = a new row
@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.

Java
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));
    }
}
The dependency comes in through the constructor — this makes the class easy to test
Java
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);
    }
}
Mockito needs the 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 in src/test/java.
  • The AAA pattern: Arrange → Act → Assert; every test runs on a new object and does not depend on the others.
  • @ParameterizedTest + @CsvSource / @ValueSource runs 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.

1 / 10
Why does the test assertEquals(0.3, 0.1 + 0.2) fail?