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).
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.
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.
@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.
@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.
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.
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
Break isolation on purpose
In the tiny runner, move
Object instance = ...newInstance();above theforloop so all tests share oneCartTest. Predict the output:freshCartEachTestnow sees the pear left byaddItemand fails withexpected: <1> but was: <2>. That is exactly why JUnit creates a new instance per test. - 2
Add @AfterEach
Add an
@interface AfterEach {}with RUNTIME retention, a method@AfterEach void tearDown() { System.out.println(" cleanup"); }inCartTest, and make the runner call@AfterEachmethods in afinallyblock. Predict how many times "cleanup" prints (4: once per test that ran, including failing ones). - 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
This is JUnit's core loop in 50 lines: find @Test methods, make a fresh instance, run @BeforeEach, invoke, and classify what was thrown.
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: 1NumberFormatException passed as an IllegalArgumentException because it's a subclass, just as in JUnit. assertAll reported both failures instead of stopping at the first.
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>]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.
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>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.
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.
Break #3
A runner that finds no tests
In the tiny runner, delete @Retention(RetentionPolicy.RUNTIME) from the Test annotation.
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
Why not mock LocalDate.now() with a mocking library?
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'sSpringExtensionstarts an application context, Testcontainers starts real databases in Docker. Extensions hook lifecycle callbacks and parameter resolution, which is how@TempDir Path dirorTestInfo infoget injected. - ▸
@TestInstance(Lifecycle.PER_CLASS)switches to one instance per class, which allows non-static@BeforeAlland is handy in Kotlin, but reintroduces shared mutable state. Parallel execution (junit.jupiter.execution.parallel.enabled=trueinjunit-platform.properties) speeds up large suites but needs tests free of shared state, or@ResourceLock. - ▸
assertTimeoutruns the code in the same thread and fails after it finishes if it was too slow;assertTimeoutPreemptivelyruns it in another thread and aborts it, which breaks code that relies onThreadLocals (such as Spring transactions).@Timeouton a test applies a preemptive-style limit through the extension model.
Remember this
- 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
In JUnit 5, a test is a method annotated **
@Test** in a test class undersrc/test/java. The method returnsvoid, takes no parameters (except with parameterized tests or injected parameters), and must not beprivate. Test classes and methods don't need to bepublic. JUnit creates a new instance of the test class for every test method, so fields set in one test never leak into another. - 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 throwsAssertionFailedError, which JUnit records as a failure; any other exception is reported as an error. - 4
Lifecycle methods run around tests:
@BeforeEachand@AfterEachrun before and after every test (fresh fixtures, cleanup);@BeforeAlland@AfterAllrun once per class and must bestatic(expensive shared setup like starting a test container).@Disabled("reason")skips a test,@DisplayNamegives it a readable name,@Nestedgroups related tests in inner classes,@Taglabels tests for selective runs, and@Timeoutfails slow tests. - 5
Parameterized tests run one test method with many inputs:
@ParameterizedTestwith a source such as@ValueSource(ints = {1, 2, 3}),@CsvSource({"1, 2, 3", "4, 5, 9"}),@EnumSourceor@MethodSource("cases"). Each row is reported as its own test. They live in thejunit-jupiter-paramsartifact, included in thejunit-jupiteraggregate. - 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
testtask drive the platform (Topic 15.4), and IDEs do the same. JUnit 6 (released in 2025) keeps the sameorg.junit.jupiter.apiannotations and requires Java 17. For replacing real collaborators with fakes, the usual companion library is Mockito.
Explain it without notes
What is a unit test, and what makes a test good?
Explain the JUnit 5 lifecycle annotations and the per-method instance rule.
How would you test that a method throws an exception with a particular message?
When would you use a parameterized test, and what sources are available?
How does JUnit find and run your tests under the hood?
Practice
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.
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.
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.