Command Palette

Search for a command to run...

PHASE 15Advanced ~36 min· topic 5 of 6

Topic 15.5

Unit Testing with JUnit 5

In one line

A unit test is a small program that calls your code with known inputs and checks the result automatically. JUnit 5 (the Jupiter API) finds methods marked @Test, runs each on a fresh instance of the test class, and reports passes and failures; assertions such as assertEquals and assertThrows, lifecycle methods like @BeforeEach, and @ParameterizedTest make tests short and readable.

Think of it like this

Before a plane takes off, the pilot goes through a checklist: fuel full? flaps working? doors locked? Each check is quick, has a clear yes or no answer, and is done the same way every flight. Unit tests are your code's pre-flight checklist: every time you change something, a machine runs hundreds of quick checks in seconds and tells you exactly which one failed.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Unit test
A small automatic check that calls one piece of your code with known inputs and verifies the result.
Assertion
A check inside a test, like assertEquals(4, add(2, 2)). If it's false, the test fails with a message.
Test fixture
The objects and data a test needs, prepared before it runs.
@BeforeEach
Marks a method JUnit runs before every test in the class, usually to build fresh fixtures.
Parameterized test
One test method run several times with different input rows, each reported separately.
Failure vs error
A failure is an assertion that didn't hold; an error is any other unexpected exception thrown by the test.
Test runner
The program that finds tests, runs them and reports the results. For JUnit 5 this is the JUnit Platform with the Jupiter engine.
Mock
A fake object standing in for a real dependency (like a payment gateway) so a test can control and check how it's used.

Step by step

01The code under test

We test a small PriceCalculator: it multiplies a unit price by a quantity, applies a percentage discount, and rejects bad input. Prices are in paise (a long) so there are no floating-point rounding issues (Topic 1.4).

src/main/java/com/shop/orders/PriceCalculator.javawhole filejava
package com.shop.orders;

public class PriceCalculator {
    public long total(long unitPrice, int quantity, int discountPercent) {
        if (quantity <= 0) throw new IllegalArgumentException("quantity must be positive: " + quantity);
        if (discountPercent < 0 || discountPercent > 100)
            throw new IllegalArgumentException("discount out of range: " + discountPercent);
        long gross = unitPrice * quantity;
        return gross - gross * discountPercent / 100;
    }
}

02A first test class

The test class lives in src/test/java in the same package, so it can see package-private members. @BeforeEach builds a fresh calculator before each test. Each @Test method arranges inputs, acts by calling total, and asserts the result.

import static org.junit.jupiter.api.Assertions.*; lets you write assertEquals instead of Assertions.assertEquals.

src/test/java/com/shop/orders/PriceCalculatorTest.javawhole filejava
package com.shop.orders;

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

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

class PriceCalculatorTest {
    private PriceCalculator calc;

    @BeforeEach
    void setUp() {
        calc = new PriceCalculator();          // a fresh object for every test
    }

    @Test
    @DisplayName("no discount: price times quantity")
    void noDiscount() {
        assertEquals(30_000, calc.total(10_000, 3, 0));   // expected first, actual second
    }
}

03Testing exceptions

assertThrows takes the expected exception type and a lambda (an Executable) containing the code. It fails if nothing is thrown or a different type is thrown, and returns the exception so you can check its message. Subclasses count: assertThrows(IllegalArgumentException.class, …) also accepts a NumberFormatException. Use assertThrowsExactly when the exact type matters.

@Nested inner classes group related tests; they share the outer class's fields and @BeforeEach.

PriceCalculatorTest.java (continued)whole filejava
@Nested
class InvalidInput {
    @Test
    void zeroQuantityIsRejected() {
        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
                () -> calc.total(10_000, 0, 0));
        assertEquals("quantity must be positive: 0", e.getMessage());
    }

    @Test
    void discountAbove100IsRejected() {
        assertThrows(IllegalArgumentException.class, () -> calc.total(10_000, 1, 101));
    }
}

04Many cases, one method

@ParameterizedTest with @CsvSource runs the method once per row. JUnit converts each column to the parameter's type (long, int, enums, dates and more). The name pattern makes each row readable in reports.

Choose rows that cover the boundaries: zero discount, 100% discount, a quantity of one. Boundary values find more bugs than random middle values.

PriceCalculatorTest.java (continued)whole filejava
@ParameterizedTest(name = "{0} x {1} at {2}% off = {3}")
@CsvSource({
        "10000, 1, 10, 9000",
        "10000, 3, 50, 15000",
        "999,   2, 100, 0"
})
void discounts(long unit, int qty, int pct, long expected) {
    assertEquals(expected, calc.total(unit, qty, pct));
}

05Running the tests

mvn test (or ./gradlew test) compiles main and test code and runs every test class whose name matches Surefire's default patterns (*Test, Test*, *Tests, *TestCase). Nested classes are reported separately. The six tests are: one plain test, three parameterized rows and two nested tests.

terminal
$ mvn test
── expected output ──
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.shop.orders.PriceCalculatorTest
[INFO] Running com.shop.orders.PriceCalculatorTest$InvalidInput
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.011 s -- in com.shop.orders.PriceCalculatorTest$InvalidInput
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.318 s -- in com.shop.orders.PriceCalculatorTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] BUILD SUCCESS

06How JUnit runs a test class

For each test method, JUnit creates a new instance, runs all @BeforeEach methods, the test, then all @AfterEach methods, even if the test failed. @BeforeAll/@AfterAll wrap the whole class. The order of test methods is deterministic but deliberately not obvious; never write tests that depend on each other's order (use @TestMethodOrder only for special cases).

That fresh-instance rule is why instance fields are a safe place for fixtures. static fields are shared across tests, and are a common source of tests that pass alone and fail together.

How JUnit runs a test classdiagram
Rendering diagram…

07What makes a good unit test

Fast (milliseconds), isolated (no shared state, no network, no real clock: pass a Clock in, Topic 12.5), repeatable (same result every run), self-checking (asserts, never "look at the output"), and focused (one behaviour per test, named after it, like zeroQuantityIsRejected).

Test behaviour through the public API, not private methods. When a class talks to a slow or unpredictable collaborator (a payment gateway, a database), pass it in through the constructor so a test can substitute a fake or a Mockito mock. Code that is easy to test is usually code that is well designed.

Try it yourself

  1. 1

    Break isolation on purpose

    In the tiny runner, move Object instance = ...newInstance(); above the for loop so all tests share one CartTest. Predict the output: freshCartEachTest now sees the pear left by addItem and fails with expected: <1> but was: <2>. That is exactly why JUnit creates a new instance per test.

  2. 2

    Add @AfterEach

    Add an @interface AfterEach {} with RUNTIME retention, a method @AfterEach void tearDown() { System.out.println(" cleanup"); } in CartTest, and make the runner call @AfterEach methods in a finally block. Predict how many times "cleanup" prints (4: once per test that ran, including failing ones).

  3. 3

    Fix the wrong expectation

    In the parameterized example, change the last row to "500, 2, 20, 800" and add a row "1, 1, 0, 1". Predict all five lines before running.

Code & diagrams

A tiny test runner built from annotations and reflection Java 9+ New tab

This is JUnit's core loop in 50 lines: find @Test methods, make a fresh instance, run @BeforeEach, invoke, and classify what was thrown.

Sign in to run this example in your browser.

Expected output

PASS  addItem
ERROR crashes: java.lang.IndexOutOfBoundsException: Index 5 out of bounds for length 1
SKIP  discount: waiting for discount rules
PASS  freshCartEachTest
FAIL  wrongTotal: expected: <3> but was: <1>
Tests run: 4, passed: 2, failed: 2, skipped: 1
assertThrows and assertAll, written by hand Java 9+ New tab

NumberFormatException passed as an IllegalArgumentException because it's a subclass, just as in JUnit. assertAll reported both failures instead of stopping at the first.

Sign in to run this example in your browser.

Expected output

caught NumberFormatException: For input string: "abc"
assertThrows failed: expected ArithmeticException to be thrown, but nothing was thrown
assertAll failed: 2 of 3 failed: [expected: <Byron> but was: <Lovelace>, expected: <3> but was: <2>]
A parameterized test, built by hand Java 8+ New tab

Row 4's expected value is wrong on purpose: 500 x 2 = 1000, minus 20% is 800. A wrong expectation in a test is a bug too.

Sign in to run this example in your browser.

Expected output

[1] 10000, 1, 10, 9000 -> PASS
[2] 10000, 3, 50, 15000 -> PASS
[3] 999, 2, 100, 0 -> PASS
[4] 500, 2, 20, 900 -> FAIL expected: <900> but was: <800>
Lifecycle, assumptions, timeouts and temp directoriesjava
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

import java.nio.file.*;
import java.time.Duration;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;

class ReportWriterTest {
    @BeforeAll
    static void startOnce() { /* expensive shared setup, e.g. a test database */ }

    @AfterEach
    void cleanUp() { /* runs after every test, even failing ones */ }

    @Test
    void writesReport(@TempDir Path dir) throws Exception {      // JUnit creates and deletes the folder
        Path file = dir.resolve("report.csv");
        Files.writeString(file, "id,total\n1,900\n");
        assertEquals(2, Files.readAllLines(file).size());
    }

    @Test
    void onlyOnCi() {
        assumeTrue(System.getenv("CI") != null, "not running on CI");   // skipped, not failed, elsewhere
        assertTrue(true);
    }

    @Test
    void finishesQuickly() {
        assertTimeout(Duration.ofMillis(200), () -> Thread.sleep(10));
    }
}

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Read a real assertion failure

In the real JUnit test class, change the @CsvSource row "10000, 3, 50, 15000" to expect 1500, then run mvn test.

terminal
$ mvn test
── what you'll see ──
[ERROR] Tests run: 4, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.315 s <<< FAILURE! -- in com.shop.orders.PriceCalculatorTest
[ERROR] com.shop.orders.PriceCalculatorTest.discounts(long, int, int, long)[2] -- Time elapsed: 0.010 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <1500> but was: <15000>
at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151)
at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132)
at org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197)
at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:166)
at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:161)
at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:632)
at com.shop.orders.PriceCalculatorTest.discounts(PriceCalculatorTest.java:33)
...
[ERROR] Failures:
[ERROR] PriceCalculatorTest.discounts:33 expected: <1500> but was: <15000>
[ERROR] Tests run: 6, Failures: 1, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE

Break #2

Mix up JUnit 4 and JUnit 5 imports

Write import org.junit.Test; (JUnit 4) in a project that only depends on junit-jupiter.

terminal
$ mvn test
── what you'll see ──
[ERROR] COMPILATION ERROR :
[ERROR] /home/dev/orders-service/src/test/java/com/shop/orders/PriceCalculatorTest.java:[3,17] package org.junit does not exist

Break #3

A runner that finds no tests

In the tiny runner, delete @Retention(RetentionPolicy.RUNTIME) from the Test annotation.

terminal
$ java Main.java
── what you'll see ──
Tests run: 0, passed: 0, failed: 0, skipped: 0

Myth vs fact

Myth

Test methods run in the order they're written.

Fact

JUnit 5 uses a deterministic but intentionally non-obvious order. Tests must not depend on each other; use @TestMethodOrder only when you truly need it.

Myth

Test classes and methods must be public.

Fact

In JUnit 5 they can be package-private, which is the recommended style. Only private test methods are not run.

Myth

100% code coverage means the code is correct.

Fact

Coverage shows which lines ran, not whether the assertions checked the right things. A test with no assertions can cover every line.

Myth

Unit tests slow development down.

Fact

They cost time to write, but they catch regressions in seconds and let you refactor safely. The slow part is finding bugs in production without them.

Interview problem

The problem

Make a time-dependent class testable

A Coupon class has boolean isValid() { return LocalDate.now().isBefore(expiry); }. Its test passes today and will fail after the expiry date. How do you test it reliably?

You're given

  • Tests must give the same result on any day.
  • Production code must still use the real current date.

The interviewer follows up

01

Why not mock LocalDate.now() with a mocking library?

02

What else commonly makes tests flaky?

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    JUnit 5 is three parts: the Platform (launcher API that IDEs and build tools call), Jupiter (the new programming model and its engine) and Vintage (an engine that runs JUnit 3/4 tests). Other engines plug into the same platform, such as Spock, Cucumber and jqwik for property-based testing.

  • ▸

    Extensions replace JUnit 4's runners and rules: @ExtendWith(MockitoExtension.class) creates mocks, Spring's SpringExtension starts an application context, Testcontainers starts real databases in Docker. Extensions hook lifecycle callbacks and parameter resolution, which is how @TempDir Path dir or TestInfo info get injected.

  • ▸

    @TestInstance(Lifecycle.PER_CLASS) switches to one instance per class, which allows non-static @BeforeAll and is handy in Kotlin, but reintroduces shared mutable state. Parallel execution (junit.jupiter.execution.parallel.enabled=true in junit-platform.properties) speeds up large suites but needs tests free of shared state, or @ResourceLock.

  • ▸

    assertTimeout runs the code in the same thread and fails after it finishes if it was too slow; assertTimeoutPreemptively runs it in another thread and aborts it, which breaks code that relies on ThreadLocals (such as Spring transactions). @Timeout on a test applies a preemptive-style limit through the extension model.

Remember this

  1. 1

    A unit test checks one small unit of behaviour (usually one method or class) in isolation, quickly, with no network or database. It follows Arrange, Act, Assert: set up the inputs, call the code, check the result. Good tests run in milliseconds, give the same result every time, and fail with a message that tells you what went wrong.

  2. 2

    In JUnit 5, a test is a method annotated **@Test** in a test class under src/test/java. The method returns void, takes no parameters (except with parameterized tests or injected parameters), and must not be private. Test classes and methods don't need to be public. JUnit creates a new instance of the test class for every test method, so fields set in one test never leak into another.

  3. 3

    Assertions (static methods in org.junit.jupiter.api.Assertions) check results. assertEquals(expected, actual) (expected first!), assertTrue, assertNull, assertSame, assertArrayEquals, assertIterableEquals. **assertThrows(Type.class, () -> code) checks that code throws and returns the exception so you can check its message. assertAll** runs several assertions and reports every failure, not only the first. A failing assertion throws AssertionFailedError, which JUnit records as a failure; any other exception is reported as an error.

  4. 4

    Lifecycle methods run around tests: @BeforeEach and @AfterEach run before and after every test (fresh fixtures, cleanup); @BeforeAll and @AfterAll run once per class and must be static (expensive shared setup like starting a test container). @Disabled("reason") skips a test, @DisplayName gives it a readable name, @Nested groups related tests in inner classes, @Tag labels tests for selective runs, and @Timeout fails slow tests.

  5. 5

    Parameterized tests run one test method with many inputs: @ParameterizedTest with a source such as @ValueSource(ints = {1, 2, 3}), @CsvSource({"1, 2, 3", "4, 5, 9"}), @EnumSource or @MethodSource("cases"). Each row is reported as its own test. They live in the junit-jupiter-params artifact, included in the junit-jupiter aggregate.

  6. 6

    Under the hood, JUnit is annotations (Topic 15.1) plus reflection (Topic 15.2): the JUnit Platform launches test engines, and the Jupiter engine scans test classes, finds annotated methods, creates instances and invokes them, catching whatever they throw. Maven's Surefire plugin and Gradle's test task drive the platform (Topic 15.4), and IDEs do the same. JUnit 6 (released in 2025) keeps the same org.junit.jupiter.api annotations and requires Java 17. For replacing real collaborators with fakes, the usual companion library is Mockito.

Explain it without notes

01

What is a unit test, and what makes a test good?

02

Explain the JUnit 5 lifecycle annotations and the per-method instance rule.

03

How would you test that a method throws an exception with a particular message?

04

When would you use a parameterized test, and what sources are available?

05

How does JUnit find and run your tests under the hood?

Practice

01

Extend the tiny runner with an @AfterEach annotation whose methods always run, even after a failed test, and print how many methods were called in total, including setup and teardown.

02

Write the JUnit 5 test class (non-runnable here) for a Stack class: a fresh stack per test, push then pop returns the same element, pop on an empty stack throws NoSuchElementException.

03

Write a hand-made assertArrayEquals(int[] expected, int[] actual) that reports the first differing index, and use it on two arrays.

Trade-offs

  • ↔

    Many small unit tests run fast and pinpoint failures, but can't prove the parts work together; add fewer, slower integration tests for that.

  • ↔

    Mocks isolate a unit from slow collaborators, but heavy mocking tests implementation details and breaks on refactors; prefer real objects or simple fakes where they're cheap.

  • ↔

    Parameterized tests remove duplication, but a long table of cases can hide which behaviour each row is checking; name the rows.

Done when you can

  • Done when you can write a JUnit 5 test class with @Test, @BeforeEach and clear assertions in the expected-then-actual order.

  • Done when you can test exceptions with assertThrows and several conditions with assertAll.

  • Done when you can write a @ParameterizedTest with @CsvSource and read a failing test report.

  • Done when you can explain the per-method instance rule and the difference between a failure and an error.

  • Done when you can explain how JUnit uses annotations and reflection, and build a tiny runner yourself.