RizTech Academy logo
RizTech Academy
Build Tools and TestingLesson 2 of 435 min

Unit testing with JUnit 5

A test is a program that runs your program and complains if the answer is wrong. That is all. The value is not in the running — you already run your code — it is that the test keeps running, unchanged, every time anybody touches the project.

And tests are code. They need maintaining, and a bad suite is a liability: a hundred tests nobody trusts means a red build that gets ignored, which is worse than no tests at all. This module is as much about which tests to write as about how.

A first test

package com.riztech.tiffin;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

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

class MoneyTest {

    @Test
    @DisplayName("formats whole rupees with a thousands separator")
    void formatsLargeAmounts() {
        assertEquals("Rs 2,141.10", Money.format(214110));
    }
}

In src/test/java/com/riztech/tiffin/MoneyTest.java — the same package as the class under test, so package-private members are reachable.

mvn test
[INFO] Running com.riztech.tiffin.MoneyTest
[INFO] Tests run: 16, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.045 s
[INFO] Results:
[INFO] Tests run: 36, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Test classes and methods do not need to be public in JUnit 5. Package-private is the convention.

The dependency

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

junit-jupiter is an aggregate bringing the API, the engine and the parameterised support. JUnit 5 is not JUnit 4 — the packages are org.junit.jupiter.api, not org.junit, and @Before became @BeforeEach. Mixing imports from both produces tests that silently do not run.

The annotations

Annotation Does
@Test This method is a test
@DisplayName("…") A readable name in reports
@BeforeEach / @AfterEach Runs before/after every test
@BeforeAll / @AfterAll Once per class; must be static
@Disabled("reason") Skips it — always give the reason
@Nested An inner class grouping related tests
@Tag("slow") Label, for running subsets
@ParameterizedTest Runs once per input
@TempDir Injects a temporary directory, deleted afterwards
private BillingService billing;

@BeforeEach
void setUp() {
    billing = new BillingService(List.of(
            new Subscriber("Priya", "411207", Plan.VEG, LocalDate.of(2026, 9, 1)),
            new Subscriber("Arjun", "411014", Plan.JAIN, LocalDate.of(2026, 9, 1))));
}

@BeforeEach runs before each test, and JUnit creates a new instance of the test class for every test method. That is deliberate: tests cannot leak state into each other, and they can run in any order.

Assertions

assertEquals(expected, actual);
assertEquals(expected, actual, "message shown on failure");
assertNotEquals(a, b);
assertTrue(condition, "message");
assertFalse(condition, "message");
assertNull(x);
assertNotNull(x);
assertSame(a, b);          // same object, not just equal
assertArrayEquals(a, b);
assertIterableEquals(a, b);
assertThrows(Type.class, () -> code());
assertDoesNotThrow(() -> code());
assertAll(() -> ..., () -> ...);   // reports every failure, not just the first
assertTimeout(Duration.ofSeconds(1), () -> ...);

The expected value comes first. Swap them and the failure message reads backwards, which wastes a minute every time.

Failures are readable because JUnit prints both values:

org.opentest4j.AssertionFailedError: expected:
  <[Delivery[date=2026-09-01, customer=Priya, tiffins=2],
    Delivery[date=2026-09-02, customer=Kale, Kavita, tiffins=3]]>
but was:
  <[Delivery[date=2026-09-01, customer=Priya, tiffins=2]]>
	at com.riztech.tiffin.DeliveryStoreTest.roundTripsThroughSaveAndLoad(DeliveryStoreTest.java:56)

That is a real failure from building this course's capstone, and it is worth saying what it found. The store wrote CSV with proper quoting — "Kale, Kavita" — and read it back with a plain split(","). The row vanished on the way in. The writer and the reader disagreed, exactly as the CSV lesson warned, and nothing in the application ever complained.

Notice how much of that message is doing work: the two lists side by side, the missing element visible, and the file and line to open. It is legible because Delivery is a record with a generated toString.

Testing exceptions

@Test
void namesTheCustomerWhenThereIsNoSuchSubscriber() {
    IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
            () -> billing.billPaise("Nobody", 1));
    assertEquals("no subscriber named [Nobody]", e.getMessage());
}

assertThrows returns the exception, so you can assert on it. Assert on the message when the message is the feature — module 7 spent a lesson arguing that a good message is most of an exception's value, and this is how you keep it good.

Parameterised tests

The same check against many inputs:

@ParameterizedTest
@CsvSource({
        "82.35,  8235",
        "82.3,   8230",
        "82,     8200",
        "0.05,      5",
        "1234.56, 123456",
        "-12.50, -1250"
})
void parsesRupees(String text, long expectedPaise) {
    assertEquals(expectedPaise, Money.parseRupees(text));
}

@ParameterizedTest
@ValueSource(strings = {"", " ", "abc", "82.345", "12,34,567.8.9", "Rs 82"})
void rejectsRubbish(String text) {
    IllegalArgumentException e =
            assertThrows(IllegalArgumentException.class, () -> Money.parseRupees(text));
    assertTrue(e.getMessage().contains(text.strip()),
            "message should quote the offending input, was: " + e.getMessage());
}

Each row is a separate test, so a failure names the input that failed rather than saying one of six cases is wrong.

Source Supplies
@ValueSource(strings/ints/…) One argument per test
@CsvSource({"a,1", "b,2"}) Several arguments per test
@CsvFileSource(resources = "/cases.csv") From a file
@EnumSource(Plan.class) Every enum constant
@MethodSource("cases") From a static method returning a Stream
@NullAndEmptySource null and empty, for string edge cases

@EnumSource is worth remembering: a test over every enum constant fails automatically when somebody adds one, which is the same compiler-writes-your-todo-list benefit as the exhaustive switch.

Files, without leaving mess behind

@TempDir
Path dir;

@Test
void loadsGoodRowsAndReportsBadOnesWithLineNumbers() throws IOException {
    Path file = dir.resolve("deliveries.csv");
    Files.writeString(file, """
            date,customer,tiffins
            2026-09-01,Priya,2
            2026-09-01,Arjun
            2026-09-02,Kavita,notanumber
            2026-09-02,Amit,99
            2026-09-03,Priya,1
            """);

    Parsed<Delivery> parsed = new DeliveryStore(file).load();

    assertEquals(2, parsed.rows().size());
    assertEquals(3, parsed.problems().size());
    assertTrue(parsed.problems().get(0).startsWith("line 3:"), parsed.problems().get(0));
    assertTrue(parsed.problems().get(1).contains("notanumber"), parsed.problems().get(1));
    assertTrue(parsed.problems().get(2).contains("between 0 and 4"), parsed.problems().get(2));
}

@TempDir gives a fresh directory per test and deletes it afterwards. Never write test files into the project directory — they leak between runs, get committed, and make tests order-dependent.

Note what that test asserts: not only the counts but that the messages carry the line number and the offending value. Those messages are a feature, so they are tested.

Grouping with @Nested

@Nested
@DisplayName("an empty month")
class EmptyMonth {

    @Test
    void billsNothing() {
        assertTrue(billing.billsFor(DELIVERIES, YearMonth.of(2026, 1)).isEmpty());
    }

    @Test
    void stillRendersAReportWithAZeroTotal() {
        String report = billing.renderReport(DELIVERIES, YearMonth.of(2026, 1));
        assertTrue(report.contains("Rs 0.00"), report);
    }
}

An inner class shares the outer @BeforeEach and groups a scenario. Reports then read as "BillingService > an empty month > bills nothing".

Running them

mvn test                          everything
mvn test -Dtest=MoneyTest         one class
mvn test -Dtest=MoneyTest#padsPaise   one method
mvn test -Dtest='*StoreTest'      a pattern

In IntelliJ, the green arrow beside a class or method runs it, and Ctrl+Shift+F10 runs whatever the cursor is in. Learn that shortcut. A test you can run in one keystroke gets run; one that needs a terminal does not.

Check your work

Where do tests live, and in which package? src/test/java, mirroring the main package so package-private members are visible.

Do test classes need to be public? No. Package-private is the JUnit 5 convention.

How many instances of the test class does JUnit create? One per test method, so state cannot leak between tests.

Which argument comes first in assertEquals? The expected value. Swapping them makes every failure message read backwards.

What does assertThrows return, and why does that matter? The exception — so you can assert on its message, which module 7 argued is most of its value.

Why parameterise rather than loop inside one test? Each case is a separate test, so a failure names the input that failed instead of the whole method.

What does @TempDir give you? A fresh directory per test, deleted afterwards, so tests never leak files into the project.

What is @EnumSource good for? Covering every enum constant, so adding one automatically extends the test.

Practice 4, the round-trip failure. Writing "Kale, Kavita" with quoting and reading it back with split(",") gives four fields where three were expected, so the row is rejected and the round trip loses it. The failure message shows both lists with the missing element plainly visible. The fix is a quote-aware splitter in the reader — and the general lesson is that a round-trip test catches a reader and a writer disagreeing, which no amount of testing either one alone would have found.

Practice 6, the enum test. @EnumSource(Plan.class) over a test asserting plan.pricePaise() >= 0 and plan.label() is not blank passes for four constants. Add a fifth without a label and the test fails immediately, naming the constant. That is the same benefit as an exhaustive switch, bought for one annotation.

Practice

  1. Write your first test. A MoneyTest asserting Money.format(214110) is "Rs 2,141.10". Run it with mvn test and then from the IDE.

  2. Make it fail on purpose. Change the expected value and read the whole failure message. Note which parts tell you where to look.

  3. Test the edges. 0, 5, 100 and a negative amount. The 5 case is the one that catches a missing %02d.

  4. Write a round-trip test. Save a list containing a customer whose name has a comma in it, load it back, and assert equality. If it passes first time, your reader already understands quoting; if it fails, you have just found the bug this lesson describes.

  5. Parameterise. Convert your edge cases to @CsvSource, and your invalid inputs to @ValueSource. Break one input and confirm the report names it.

  6. Cover an enum. Use @EnumSource(Plan.class) to assert every plan has a non-blank label and a non-negative price. Then add a fifth plan with a blank label and watch the test fail without you writing anything new.

Next: which tests are worth writing, and how to structure them so they stay worth having.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship