Twenty-seven articles of this course have ended the same way: here is the program, here is what it printed when I ran it, and here is why. That habit is already a test. What has been missing is the part that makes it survive the next change — writing the expected output down in code, so a machine checks it instead of your eyes.
This opens Part 6 of the course: testing and code quality. JUnit 5 is where it starts, because it is the tool every Java project in the ecosystem reaches for, and because most of what people find surprising about it is mechanical rather than philosophical.
![]()
Every command, transcript and failure message below was produced on OpenJDK 21.0.6 (arm64) running JUnit Jupiter 5.11.3 through the JUnit Platform Console Launcher 1.11.3. Run durations printed by the launcher have been removed, because they measure a machine rather than the code.
What a unit test is actually for
A unit test does not prove your code is correct. It cannot: it exercises a handful of the inputs your method accepts, and says nothing at all about the rest. Anyone who tells you a green suite means working software is selling something.
What a test does is pin behaviour in place. You decide that add(2, 3) is 5, you write that down in a form a machine can re-check, and from that moment any change that makes it stop being 5 is noticed within seconds instead of within a support ticket. That is the entire value proposition, and it is enough. Tests are a change detector, not a proof.
Two consequences follow, and both matter more than any API detail in this article. First, a test is only worth what its failure message is worth — a test that fails with "expected true, was false" costs you the debugging session it was supposed to save. Second, a test that cannot fail is worse than no test, because it produces a green tick that nobody re-examines. Both come back later in this article with real output attached.
JUnit 5 is three projects, not one
"JUnit 5" is a bundle of three separate pieces, and knowing which is which saves a lot of confusion about dependencies:
| Piece | What it is | You touch it when |
|---|---|---|
| JUnit Platform | The engine-agnostic launcher and discovery API. Defines what a test engine is and runs whatever engines it finds. | You configure a build, an IDE runner, or the console launcher |
| JUnit Jupiter | The programming model you actually write against: @Test, @BeforeEach, Assertions, @ParameterizedTest, plus the engine that executes them. | You write tests |
| JUnit Vintage | An engine that runs JUnit 3 and JUnit 4 tests on the Platform. | You have a legacy suite you have not migrated |
The split exists so that other frameworks — Spock, Cucumber, Kotest — can plug their own engine into the same Platform and be discovered by the same IDEs and build tools. It is not academic: the launcher used throughout this article is the Platform, and the engines it found are printed in every run.
Running your first test with the console launcher
Real projects let the build tool fetch JUnit and run the suite; that is article 31's territory. To keep this article about JUnit rather than about Maven, everything here uses the standalone console launcher, which is a single jar containing the Platform, both engines and the whole Jupiter API.
curl -sSO https://repo1.maven.org/maven2/org/junit/platform/junit-platform-console-standalone/1.11.3/junit-platform-console-standalone-1.11.3.jarA class under test, and a test class beside it:
public class Calculator {
public int add(int a, int b) { return a + b; }
public int divide(int a, int b) { return a / b; }
}import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class CalculatorTest {
private final Calculator calc = new Calculator();
@Test
void addsTwoPositiveNumbers() {
assertEquals(5, calc.add(2, 3));
}
@Test
void addsNegativeNumbers() {
assertEquals(-5, calc.add(-2, -3));
}
@Test
void addIsNotSubtraction() {
assertEquals(5, calc.add(2, 2));
}
}Note what is not there: no public on the class or the methods, no main, no inheritance from a base class. Jupiter discovers package-private classes and methods by annotation, so the visibility noise JUnit 4 required is gone.
Compile against the jar and run the class through the launcher:
javac -cp junit-platform-console-standalone-1.11.3.jar -d out src/Calculator.java src/CalculatorTest.java
java -jar junit-platform-console-standalone-1.11.3.jar execute -cp out \
--select-class=CalculatorTest --details=tree --disable-ansi-colors --disable-banner╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│ └─ CalculatorTest ✔
│ ├─ addIsNotSubtraction() ✘ expected: <5> but was: <4>
│ ├─ addsTwoPositiveNumbers() ✔
│ └─ addsNegativeNumbers() ✔
└─ JUnit Vintage ✔
Failures (1):
JUnit Jupiter:CalculatorTest:addIsNotSubtraction()
MethodSource [className = 'CalculatorTest', methodName = 'addIsNotSubtraction', methodParameterTypes = '']
=> org.opentest4j.AssertionFailedError: expected: <5> but was: <4>
org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151)
org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132)
org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197)
org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:150)
org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:145)
org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:531)
CalculatorTest.addIsNotSubtraction(CalculatorTest.java:21)
java.base/java.lang.reflect.Method.invoke(Method.java:580)
java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
[ 4 containers found ]
[ 0 containers skipped ]
[ 4 containers started ]
[ 0 containers aborted ]
[ 4 containers successful ]
[ 0 containers failed ]
[ 3 tests found ]
[ 0 tests skipped ]
[ 3 tests started ]
[ 0 tests aborted ]
[ 2 tests successful ]
[ 1 tests failed ]Three things in that output are worth reading properly. The three engines at the top are the Platform reporting what it discovered — Jupiter ran the tests, Vintage found nothing, and the Suite engine found no @Suite classes. The four containers are those three engines plus CalculatorTest; containers hold tests, tests are the leaves. And the stack trace has been trimmed by the launcher to the frames you care about, with CalculatorTest.java:21 naming the exact assertion.
Swap --details=tree for --details=summary to drop the tree, --details=none to keep only your own System.out. Instead of --select-class you can pass --select-package=com.example or --scan-classpath to run everything discoverable.
The lifecycle: what runs when, and how many instances exist
Four callbacks bracket your tests. @BeforeAll and @AfterAll run once for the whole class and must be static by default; @BeforeEach and @AfterEach run around every single test method.

A new instance per test method
The part that is rarely taught: Jupiter constructs a brand new instance of the test class for every @Test method. Printing System.identityHashCode(this) makes it impossible to argue with.
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class LifecycleTest {
private List<String> items = new ArrayList<>();
@BeforeAll
static void beforeAll() {
System.out.println("@BeforeAll (once, static)");
}
@BeforeEach
void beforeEach() {
System.out.println(" @BeforeEach instance=" + System.identityHashCode(this)
+ " items=" + items);
}
@Test
void firstTest() {
items.add("a");
System.out.println(" firstTest instance=" + System.identityHashCode(this)
+ " items=" + items);
assertEquals(1, items.size());
}
@Test
void secondTest() {
items.add("b");
System.out.println(" secondTest instance=" + System.identityHashCode(this)
+ " items=" + items);
assertEquals(1, items.size());
}
@Test
void thirdTest() {
items.add("c");
System.out.println(" thirdTest instance=" + System.identityHashCode(this)
+ " items=" + items);
assertEquals(1, items.size());
}
@AfterEach
void afterEach() {
System.out.println(" @AfterEach instance=" + System.identityHashCode(this));
}
@AfterAll
static void afterAll() {
System.out.println("@AfterAll (once, static)");
}
}@BeforeAll (once, static)
@BeforeEach instance=657736958 items=[]
thirdTest instance=657736958 items=[c]
@AfterEach instance=657736958
@BeforeEach instance=205721196 items=[]
firstTest instance=205721196 items=[a]
@AfterEach instance=205721196
@BeforeEach instance=51554940 items=[]
secondTest instance=51554940 items=[b]
@AfterEach instance=51554940
@AfterAll (once, static)Three different instance identities, and items is empty at the start of each one even though every test added to it. The field was not reset by any cleanup code — the object holding it was thrown away and a new one constructed. Assigning a field in one test can never leak into the next, which is exactly why the design was chosen.
The identity numbers themselves are arbitrary JVM-internal values and carry no meaning; what matters is that there are three distinct ones. The ordering does mean something: the methods ran third, first, second, and repeating the run gives that same order every time. Jupiter's default order is deterministic but deliberately unspecified — it is a stable hash of the method, not source order — precisely so that nobody can accidentally depend on it.
@TestInstance(PER_CLASS) and what it costs
One annotation switches the class to a single shared instance:
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PerClassTest {
private final List<String> items = new ArrayList<>();
@BeforeAll
void beforeAll() {
System.out.println("@BeforeAll is no longer static, instance="
+ System.identityHashCode(this));
}
@Test
void firstTest() {
items.add("a");
System.out.println(" firstTest instance=" + System.identityHashCode(this)
+ " items=" + items);
}
@Test
void secondTest() {
items.add("b");
System.out.println(" secondTest instance=" + System.identityHashCode(this)
+ " items=" + items);
}
}@BeforeAll is no longer static, instance=764419760
firstTest instance=764419760 items=[a]
secondTest instance=764419760 items=[a, b]One identity now, @BeforeAll no longer needs static, and items carries a into the second test. That last line is the whole trade: under PER_CLASS you own the cleanup, and you have just made your tests order-dependent unless you write an @AfterEach that undoes the damage. Use it when a fixture is genuinely expensive to build, or when you need a non-static @MethodSource, and not merely because static is inconvenient.
Forgetting the annotation is a hard error rather than a silent one, which is a kindness:
=> org.junit.platform.commons.JUnitException: @BeforeAll method 'void NonStaticBeforeAllTest.setUp()' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).Since the instance is rebuilt anyway, most fixtures belong in a field initialiser or @BeforeEach. Here is a fixture with a collaborator, hand-written in one line because it is one method:
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.LocalDate;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class SubscriptionTest {
private Subscription subscription;
// a fake collaborator, hand-written: no library needed
private final Clock fixedClock = () -> LocalDate.of(2026, 9, 19);
@BeforeEach
void setUp() {
subscription = new Subscription(LocalDate.of(2026, 9, 30));
}
@Test
void isActiveBeforeTheExpiryDate() {
assertTrue(subscription.isActive(fixedClock));
}
@Test
void isInactiveAfterTheExpiryDate() {
Clock later = () -> LocalDate.of(2026, 10, 1);
assertFalse(subscription.isActive(later));
}
@Test
void isActiveOnTheExpiryDateItself() {
Clock onExpiry = () -> LocalDate.of(2026, 9, 30);
assertTrue(subscription.isActive(onExpiry));
}
}All three pass, and no test framework was involved in faking the clock — Clock is a one-method interface and a lambda satisfies it. When the collaborator is bigger than that, or when you need to check how it was called rather than what it returned, a library does it properly; that is article 29.
Assertions, and why the failure message is the whole point
Every assertion is a static method on org.junit.jupiter.api.Assertions, and the argument order is always (expected, actual). Getting that backwards does not break the test, it breaks the message, which is worse.

assertEquals, assertTrue, assertNull
The core three, each shown failing on purpose so you can read what they actually print:
├─ equalsInt() ✘ expected: <5> but was: <4>
├─ equalsString() ✘ expected: <hello world> but was: <hello wordl>
├─ trueFails() ✘ expected: <true> but was: <false>
├─ nullFails() ✘ expected: <null> but was: <21.0.6>
├─ arrayEqualsFails() ✘ array contents differ at index [1], expected: <2> but was: <9>
├─ arrayLengthFails() ✘ array lengths differ, expected: <3> but was: <2>assertEquals compares with equals(), so it works on any type that implements it properly and prints both values. assertTrue is the weak one: a boolean has nothing to report but true and false, which is why it should always be given a message. assertArrayEquals is not assertEquals on an array — the latter would compare references — and it locates the first differing index for you.
assertSame is not assertEquals
assertEquals asks "are these equal", assertSame asks "are these the same object". Two strings with identical contents fail assertSame:
@Test
void sameFails() {
String a = "ja" + "va";
String b = new String("java");
assertSame(a, b);
}├─ sameFails() ✘ expected: java.lang.String@790da477<java> but was: java.lang.String@5c7933ad<java>Note how the message is built: the identity hash and the value, precisely because "expected java but was java" would be useless. Reach for assertSame when identity is the thing you are asserting — a cache returning the same instance, a singleton, an interned value — and for nothing else.
assertThrows returns the exception
assertThrows fails if the block does not throw, or throws the wrong type. Crucially it returns the exception it caught, so the message is assertable too:
@Test
void divideByZeroReportsWhichOperation() {
Calculator calc = new Calculator();
ArithmeticException ex = assertThrows(
ArithmeticException.class,
() -> calc.divide(10, 0));
System.out.println("caught: " + ex);
assertEquals("/ by zero", ex.getMessage());
}caught: java.lang.ArithmeticException: / by zero
├─ divideByZeroReportsWhichOperation() ✔Both failure modes report clearly:
├─ throwsNothing() ✘ Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.
├─ throwsWrongType() ✘ Unexpected exception type thrown, expected: <java.lang.IllegalStateException> but was: <java.lang.NumberFormatException>That is already better than a try/catch with a fail() at the end, and much better than a try/catch without one — a shape we come back to at the end of this article.
assertAll: one failure must not hide the others
Assertions throw, so the method stops at the first failure and everything after it never runs. assertAll executes every block it is given and reports all the failures together. Same three checks, twice:
@Test
void plainAssertions() {
assertEquals("hoang", name);
assertTrue(name.length() > 0, "name must not be empty");
assertEquals(3, name.length());
}
@Test
void grouped() {
assertAll("user",
() -> assertEquals("hoang", name),
() -> assertTrue(name.length() > 0, "name must not be empty"),
() -> assertEquals(3, name.length()));
}├─ plainAssertions() ✘ expected: <hoang> but was: <>
└─ grouped() ✘ user (3 failures)
org.opentest4j.AssertionFailedError: expected: <hoang> but was: <>
org.opentest4j.AssertionFailedError: name must not be empty ==> expected: <true> but was: <false>
org.opentest4j.AssertionFailedError: expected: <3> but was: <0>One fix-and-rerun cycle instead of three. Use it whenever you are checking several properties of one result — the fields of an object you just parsed, the components of a returned record — and not to staple unrelated tests together.
assertTimeout and the message-supplier overloads
Every assertion has an overload taking a message, and another taking a Supplier of one. The supplier form exists so that an expensive message is only built when the test actually fails:
@Test
void equalsWithMessage() {
assertEquals(5, 2 + 2, "add() is broken");
}
@Test
void equalsWithSupplier() {
List<String> input = List.of("a", "b");
assertEquals(3, input.size(), () -> "wrong size for input " + input);
}├─ equalsWithMessage() ✘ add() is broken ==> expected: <5> but was: <4>
├─ equalsWithSupplier() ✘ wrong size for input [a, b] ==> expected: <3> but was: <2>The ==> separator is JUnit joining your message to its own. Your half says which invariant broke; JUnit's half says with which values.
assertTimeout runs a block, lets it finish, and then fails if it took longer than the budget. It also returns whatever the block returned:
@Test
void slugifyingIsNotSlow() {
String slug = assertTimeout(Duration.ofSeconds(1), () -> Text.slug("Hello World"));
assertEquals("hello-world", slug);
}When it does fail, the message names both the budget and the overshoot. The block below sleeps 300 ms against a 50 ms budget, so the overshoot is fixed by the code rather than by the machine — five runs gave 251 to 255 ms:
execution exceeded timeout of 50 ms by 254 msassertTimeoutPreemptively is the variant that aborts the block on a separate thread the moment the budget expires, rather than waiting for it. Use timeouts to catch a hang, never to assert performance — a shared CI machine will make that flaky, and the duration you write is configuration you chose, not a measurement of anything.
| Assertion | Fails when | Use it for |
|---|---|---|
assertEquals(exp, act) | equals() returns false | Values of any type |
assertNotEquals | The two are equal | Rare, and usually a weak test |
assertTrue / assertFalse | The boolean is the wrong way round | Conditions, always with a message |
assertNull / assertNotNull | Nullness differs | Absence, not emptiness |
assertSame / assertNotSame | Reference identity differs | Caches, singletons, interning |
assertArrayEquals | Lengths or elements differ | Arrays, element by element |
assertIterableEquals | Sizes or elements differ | Lists and other iterables |
assertThrows | Nothing thrown, or wrong type | Error paths, then assert the message |
assertDoesNotThrow | Anything is thrown | Proving a fix to a crash |
assertAll | Any grouped block fails | Several properties of one result |
assertTimeout | The block outran the budget | Catching a hang |
fail() | Always | Marking an unreachable branch |
Naming and grouping tests: @DisplayName, @Nested, @Disabled, @Tag
Method names are compact and ugly; @DisplayName gives the report a readable sentence. @Nested groups tests for one method or one scenario into an inner class, so the report becomes an outline of the behaviour. @Disabled skips with a reason. @Tag labels a test so it can be selected or excluded from a run.
@DisplayName("Calculator")
class OrganisationTest {
private final Calculator calc = new Calculator();
@Nested
@DisplayName("add()")
class Add {
@Test
@DisplayName("returns the sum of two positive numbers")
void positives() {
assertEquals(5, calc.add(2, 3));
}
@Test
@DisplayName("is commutative")
void commutative() {
assertEquals(calc.add(2, 3), calc.add(3, 2));
}
}
@Nested
@DisplayName("divide()")
class Divide {
@Test
@DisplayName("throws ArithmeticException on a zero divisor")
void byZero() {
assertThrows(ArithmeticException.class, () -> calc.divide(1, 0));
}
@Test
@DisplayName("truncates towards zero")
@Disabled("integer division rounding is not decided yet")
void truncation() {
assertEquals(-2, calc.divide(-7, 3));
}
}
@Test
@Tag("slow")
@DisplayName("survives a large batch of additions")
void largeBatch() {
int sum = 0;
for (int i = 0; i < 1_000_000; i++) {
sum = calc.add(sum, 1);
}
assertEquals(1_000_000, sum);
}
@Test
@Tag("fast")
@DisplayName("adds zero without changing the value")
void addZero() {
assertTrue(calc.add(7, 0) == 7);
}
}├─ JUnit Jupiter ✔
│ └─ Calculator ✔
│ ├─ adds zero without changing the value ✔
│ ├─ survives a large batch of additions ✔
│ ├─ divide() ✔
│ │ ├─ truncates towards zero ↷ integer division rounding is not decided yet
│ │ └─ throws ArithmeticException on a zero divisor ✔
│ └─ add() ✔
│ ├─ is commutative ✔
│ └─ returns the sum of two positive numbers ✔The ↷ marks the skip, and the reason string you gave @Disabled is printed next to it — which is the difference between a disabled test somebody will come back to and one that quietly rots. A nested class is a normal inner class, so it can hold its own @BeforeEach and its own fields, and it sees the outer instance.
Running a subset by tag
Tags are matched by the Platform, not by Jupiter, so the same expressions work in every runner. On the console launcher:
java -jar junit-platform-console-standalone-1.11.3.jar execute -cp out \
--select-class=OrganisationTest --include-tag=fast --details=tree├─ JUnit Jupiter ✔
│ └─ Calculator ✔
│ └─ adds zero without changing the value ✔--exclude-tag=slow is the inverse and keeps everything else, disabled tests included. Both flags accept tag expressions, so --include-tag='fast | slow' and --include-tag='fast & !slow' work as written. The usual convention is to tag the minority — the slow or externally-dependent tests — and leave the fast majority untagged.
Parameterized tests: one method, many cases
A loop inside a @Test stops at the first bad value and reports one failure. @ParameterizedTest hands the loop to the engine instead, and every case becomes a separate test with its own name and its own result.

The five argument sources
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.stream.Stream;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
class ParameterizedDemoTest {
@ParameterizedTest
@ValueSource(ints = {2, 3, 5, 7, 11})
void primesAreOddOrTwo(int n) {
assertTrue(n == 2 || n % 2 == 1);
}
@ParameterizedTest(name = "slug({0}) is {1}")
@CsvSource({
"'Hello World', hello-world",
"' Spaced out ', spaced-out",
"'JUnit 5!', junit-5"
})
void slugifies(String input, String expected) {
assertEquals(expected, Text.slug(input));
}
@ParameterizedTest
@EnumSource(Priority.class)
void everyPriorityHasAPositiveWeight(Priority p) {
assertTrue(p.weight() > 0);
}
@ParameterizedTest
@EnumSource(value = Priority.class, names = {"LOW", "MEDIUM"})
void lowAndMediumAreUnderNine(Priority p) {
assertTrue(p.weight() < 9);
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", " "})
void blankInputsAreBlank(String input) {
assertTrue(Text.isBlank(input));
}
@ParameterizedTest
@MethodSource("slugCases")
void slugifiesFromMethodSource(String input, String expected) {
assertEquals(expected, Text.slug(input));
}
static Stream<Arguments> slugCases() {
return Stream.of(
Arguments.of("Advanced Java", "advanced-java"),
Arguments.of("JUnit 5 & Mockito", "junit-5-mockito"),
Arguments.of("---dashes---", "dashes"));
}
}│ └─ ParameterizedDemoTest ✔
│ ├─ lowAndMediumAreUnderNine(Priority) ✔
│ │ ├─ [1] LOW ✔
│ │ └─ [2] MEDIUM ✔
│ ├─ slugifiesFromMethodSource(String, String) ✔
│ │ ├─ [1] Advanced Java, advanced-java ✔
│ │ ├─ [2] JUnit 5 & Mockito, junit-5-mockito ✔
│ │ └─ [3] ---dashes---, dashes ✔
│ ├─ everyPriorityHasAPositiveWeight(Priority) ✔
│ │ ├─ [1] LOW ✔
│ │ ├─ [2] MEDIUM ✔
│ │ └─ [3] HIGH ✔
│ ├─ primesAreOddOrTwo(int) ✔
│ │ ├─ [1] 2 ✔
│ │ ├─ [2] 3 ✔
│ │ ├─ [3] 5 ✔
│ │ ├─ [4] 7 ✔
│ │ └─ [5] 11 ✔
│ ├─ blankInputsAreBlank(String) ✔
│ │ ├─ [1] null ✔
│ │ ├─ [2] ✔
│ │ ├─ [3] ✔
│ │ └─ [4] ✔
│ └─ slugifies(String, String) ✔
│ ├─ slug(Hello World) is hello-world ✔
│ ├─ slug( Spaced out ) is spaced-out ✔
│ └─ slug(JUnit 5!) is junit-5 ✔Six methods became twenty tests, and each case is a leaf you can rerun on its own. The default name is the index plus the arguments; name = "slug({0}) is {1}" replaces it with your own, and {0} and {1} are the arguments in order. The blankInputsAreBlank rows show the flip side: whitespace arguments make a display name that is technically correct and visually useless, which is a good reason to supply a name pattern when your inputs are invisible.
Note that @NullAndEmptySource stacked on top of @ValueSource — sources compose, and the cases are simply concatenated. Stacking @NullSource onto a primitive parameter compiles fine and then fails that one case at runtime with Cannot convert null to primitive value of type int, which is the engine telling you the source and the signature disagree.
When a single case fails — here the same slugifies method driven by a different table, one row of which wrongly expects cpp — the report names that row and leaves the rest green:
│ └─ FailingCaseTest ✔
│ └─ slugifies(String, String) ✔
│ ├─ [1] Hello World, hello-world ✔
│ ├─ [2] Ha Noi 2026, ha-noi-2026 ✔
│ ├─ [3] C++, cpp ✘ expected: <cpp> but was: <c>
│ └─ [4] , ✔| Source | Supplies | Notes |
|---|---|---|
@ValueSource | One primitive, String or Class per case | Single-argument methods only |
@CsvSource | Several arguments per case, written inline | Quote with ' to keep spaces or commas |
@CsvFileSource | The same, read from a classpath resource | For a table too big to inline |
@EnumSource | Enum constants | Filter with names and mode |
@MethodSource | Anything a static method can build | Return Stream, Collection or an array |
@NullSource / @EmptySource / @NullAndEmptySource | The awkward inputs | Stack them onto another source |
Assumptions: skipping instead of failing
An assertion says "this must hold". An assumption says "if this does not hold, there is nothing here to test". A failed assumption aborts the test rather than failing it — the code under test is not being accused of anything.
@Test
void driveLettersOnWindows() {
assumeTrue(File.separatorChar == '\\', "Windows-only test");
assertTrue(new File("C:\\").isAbsolute());
}
@Test
void needsAnEnvironmentVariable() {
String url = System.getenv("DATABASE_URL");
assumeTrue(url != null, "DATABASE_URL is not set");
assertTrue(url.startsWith("jdbc:"));
}
@Test
void assertingInsteadOfAssuming() {
assertTrue(System.getenv("DATABASE_URL") != null, "DATABASE_URL is not set");
}│ └─ AssumptionsDemoTest ✔
│ ├─ pathSeparatorIsSlashOnUnix() ✔
│ ├─ assertingInsteadOfAssuming() ✘ DATABASE_URL is not set ==> expected: <true> but was: <false>
│ ├─ driveLettersOnWindows() ■ Assumption failed: Windows-only test
│ └─ needsAnEnvironmentVariable() ■ Assumption failed: DATABASE_URL is not setThe last two report as aborted, and the counters keep them apart from both real failures and @Disabled skips:
[ 4 tests found ]
[ 0 tests skipped ]
[ 4 tests started ]
[ 2 tests aborted ]
[ 1 tests successful ]
[ 1 tests failed ]skipped counts @Disabled, aborted counts failed assumptions, failed counts genuine problems. The honest use of an assumption is an environmental precondition you do not control — an OS, a service that may not be running, a fixture file that may not exist. The dishonest use is silencing a test that fails for a real reason, and it is dishonest precisely because the run still ends green.
Prefer the declarative conditions where one exists — @EnabledOnOs(WINDOWS), @EnabledIfEnvironmentVariable, @EnabledIfSystemProperty — because they skip before the test body starts and say why in the annotation.
What makes a test bad
Everything above is mechanics. This section is the part that actually decides whether a suite is worth its maintenance cost.
Sharing mutable state and depending on order
Jupiter's per-method instances protect ordinary fields. A static field escapes that protection entirely, and the moment two tests share one, the result depends on which ran first. These two classes are byte-for-byte identical except for one annotation:
class SharedStateTest {
private static final List<String> cart = new ArrayList<>();
@Test
void addingAnItemMakesTheCartSizeOne() {
cart.add("book");
assertEquals(1, cart.size());
}
@Test
void aNewCartIsEmpty() {
assertTrue(cart.isEmpty(), "cart should be empty, was " + cart);
}
}│ └─ SharedStateTest ✔
│ ├─ addingAnItemMakesTheCartSizeOne() ✔
│ └─ aNewCartIsEmpty() ✘ cart should be empty, was [book] ==> expected: <true> but was: <false>│ └─ SharedStateOrderedTest ✔
│ ├─ aNewCartIsEmpty() ✔
│ └─ addingAnItemMakesTheCartSizeOne() ✔The second run added @TestMethodOrder(MethodOrderer.MethodName.class) and nothing else. Alphabetical order happens to put the empty-cart check first, so the suite turns green — with the bug still there. That is what an order-dependent test is worth: it reports the schedule, not the code.
@TestMethodOrder is legitimate for a narrow set of cases — a documented walkthrough, or MethodOrderer.Random used deliberately to smoke out coupling. It is not a fix for shared state. The fix is to not share it, or to reset it in @AfterEach.
A test with no assertion
Both of these pass, and neither one can ever fail:
@Test
void emptyBody() {
}
@Test
void divideByZeroThrows() {
try {
calc.divide(10, 2);
} catch (ArithmeticException e) {
// expected
}
}│ ├─ divideByZeroThrows() ✔
│ ├─ emptyBody() ✔The second is the dangerous one, because it looks like a test. Its name claims division by zero throws; it divides by two, catches nothing, and passes. Even with the right arguments it would pass whether or not the exception arrived, since nothing checks that the catch was reached. assertThrows exists exactly so this shape never has to be written.
Any test whose body has no assertion — and no assertThrows, no assertDoesNotThrow — is asserting only that your code did not crash. Sometimes that is genuinely what you want, and then assertDoesNotThrow says so out loud.
Asserting on incidental output
A test should assert the behaviour you promised, not the details that happen to accompany it:
@Test
void assertingOnIncidentalOutput() {
Set<String> tags = new HashSet<>(List.of("java", "junit", "testing"));
assertEquals("[java, junit, testing]", tags.toString());
}├─ assertingOnIncidentalOutput() ✘ expected: <[java, junit, testing]> but was: <[junit, java, testing]>Nothing is broken. HashSet never promised an iteration order, and the code under test never promised a toString format. Assert on the contract — assertTrue(tags.contains("java")), or assertEquals(3, tags.size()) — and the test stops breaking on changes that are not bugs. The same applies to log lines, exception message wording you do not own, floating-point digits, and timestamps.
Testing the framework instead of your code
@Test
void testingTheJdkInsteadOfYourCode() {
assertEquals(4, 2 + 2);
assertEquals("JAVA", "java".toUpperCase());
}It passes, it will always pass, and it verifies the JDK rather than anything you wrote. The same instinct produces tests for generated getters and setters, for a framework annotation doing what the framework documents, and for a mapping library mapping. They cost maintenance and buy nothing. Spend the effort on the branch your code actually decides, on the boundary values, and on the error path — the places where a change can silently mean something different.
⚠️ Coverage percentage measures which lines ran, not which behaviours are pinned. A suite full of assertion-free tests can reach a very high number while detecting nothing.
FAQ
Do I need to write tests for a project this small?
The size of the project is the wrong question; the number of times you will change it is the right one. Code you write once and never touch gains little from a test suite. Code you will edit next month, after forgetting how it works, is where a test pays — it is the only mechanism that tells you the edit broke something, without you re-running the program by hand and remembering what the output used to look like. This course has been re-running programs by hand for twenty-seven articles; a test is that same discipline, written down.
What is the difference between JUnit Platform, Jupiter and Vintage?
Platform is the launcher and the engine API — it discovers and runs tests but defines none of them. Jupiter is the JUnit 5 programming model plus its engine: everything with a org.junit.jupiter import. Vintage is an engine that runs old JUnit 3 and JUnit 4 tests on the Platform, so a legacy suite can keep running while new tests are written in Jupiter. The console launcher in this article is the Platform, and it prints all three engines in every run.
Why is my field reset between tests?
Because it is a different object. Jupiter constructs a new instance of the test class for each @Test method by default, so field values never carry over. That is a feature: it makes tests independent without anyone having to write cleanup code. If you genuinely need state to survive, annotate the class with @TestInstance(TestInstance.Lifecycle.PER_CLASS) and accept that resetting it between tests is now your responsibility.
Can I rely on the order my tests run in?
No, and you should structure tests so the question never comes up. Jupiter's default order is deterministic — the same run gives the same order every time — but it is deliberately not source order and is not part of the contract, so it may change between versions. If you truly need an order, ask for one explicitly with @TestMethodOrder. If you need one because your tests share mutable state, fix the sharing instead.
When should I use assumeTrue instead of assertTrue?
Use assumeTrue when the condition is about the environment rather than the code: the wrong operating system, an absent service, a missing configuration file. The test is then aborted, and the report distinguishes that from a failure. Use assertTrue when the condition is a claim about your code. Never use an assumption to hide a test that fails for a real reason — the run stays green and the bug stays shipped.
How do I test that a method throws?
assertThrows(SomeException.class, () -> code()). It fails if nothing is thrown and if the wrong type is thrown, and it returns the caught exception so you can assert on its message or its cause. Do not write a try/catch with an empty catch block: that test passes whether the exception arrives or not, which makes it worse than having no test at all.
Is high test coverage the goal?
No. Coverage measures which lines executed during the run, which is not the same as which behaviours are checked — a test with no assertions covers every line it touches and detects nothing. Coverage is useful in one direction only: an uncovered branch is definitely untested, so it is a decent tool for finding gaps. As a target it rewards writing tests that execute code rather than tests that pin behaviour.
Conclusion
JUnit 5 is a small API around one idea: write down what you expect, and let the machine notice when it stops being true. Almost everything else follows from that. Assertions are worth using instead of if statements because of the message they build. assertAll exists so one broken expectation does not hide the next. @ParameterizedTest exists so a bad input has a name in the report instead of being the value a loop happened to die on.
The mechanical fact worth carrying away is the one people run into by accident: a new instance of the test class per test method, which is what makes tests independent by construction and what @TestInstance(PER_CLASS) gives up. Everything else in this article — fixtures in @BeforeEach, no shared static state, no dependence on order — is downstream of keeping that independence intact.
What is still missing is the collaborator problem. The Clock in this article was faked with a lambda because it had one method; a repository that talks to a database, a client that talks to an HTTP API, or a service you need to verify was called with particular arguments needs more than that. Article 29 covers Mockito, and with it mocks, stubs and the question of what a test double should and should not pretend to be.