JUnit 5 Fundamentals
Objective
JUnit 5 is a test lifecycle framework: it creates a fresh test instance per method, runs setup and teardown around it in a fixed order, and evaluates assertions and assumptions about the outcome. @ParameterizedTest, @RepeatedTest, and @Nested extend that basic model to cover many inputs, repeated runs, and grouped sub-scenarios without duplicating test code.
Use Cases
- Grouping setup/teardown around every test method with
@BeforeEach/@AfterEach, and once per class with@BeforeAll/@AfterAll. - Failing fast on a wrong value with
assertEquals/assertTrue, or grouping several related checks withassertAllso every one of them is evaluated and reported even if one fails. - Skipping a test at runtime — rather than failing it — when a precondition isn't met, via
assumeTrue/assumeFalse. - Running the same test logic against many inputs with
@ParameterizedTestand a source like@ValueSourceor@CsvSource, instead of copy-pasting near-identical@Testmethods. - Re-running the same test a fixed number of times with
@RepeatedTest, to surface flakiness in code that involves randomness or timing. - Organizing related tests into a nested class hierarchy with
@Nested, so setup can be scoped to a sub-scenario. - Giving a human-readable name to a test class or method with
@DisplayName, shown by IDEs and build reports instead of the raw method name.
Deep Dive
The test lifecycle
Every test class follows the same fixed order: @BeforeAll once, then for each @Test method a fresh instance's @BeforeEach → the test → @AfterEach, then @AfterAll once at the end.
javaclass SUTTest {
private static ResourceForAllTests resourceForAllTests;
private SUT systemUnderTest;
@BeforeAll
static void setUpClass() {
resourceForAllTests = new ResourceForAllTests("shared resource");
}
@BeforeEach
void setUp() {
systemUnderTest = new SUT("fresh per test");
}
@Test
void testRegularWork() {
assertTrue(systemUnderTest.canReceiveRegularWork());
}
@AfterEach
void tearDown() {
systemUnderTest.close();
}
@AfterAll
static void tearDownClass() {
resourceForAllTests.close();
}
}@BeforeAll/@AfterAll methods must be static unless the class is annotated @TestInstance(Lifecycle.PER_CLASS), because by default JUnit creates a new test instance per test method — there's no instance yet when @BeforeAll would run against it.
Assertions vs. assumptions
Assertions methods fail the test immediately when the check doesn't hold; assertAll groups several assertions so every one of them runs and is reported, even if earlier ones already failed:
java@Test
void accountInvariants() {
Account account = new Account("1", 100);
assertAll("account",
() -> assertEquals("1", account.getId()),
() -> assertTrue(account.getBalance() > 0),
() -> assertThrows(IllegalArgumentException.class,
() -> account.withdraw(-1))
);
}Assumptions look similar but mean something different: when an assumption fails, the test is aborted (reported as skipped), not failed — useful for preconditions the test doesn't own, like "only run this when a config value is present":
java@Test
void onlyRunsWithApiKeyConfigured() {
assumeTrue(System.getenv("API_KEY") != null);
// ... test that needs the API key
}Nested and tagged tests
@Nested groups tests around a sub-scenario, letting an inner class have its own @BeforeEach that composes with the outer one's:
javaclass AccountTest {
Account account;
@BeforeEach
void createAccount() {
account = new Account("1", 100);
}
@Nested
class WhenBalanceIsPositive {
@Test
void canWithdraw() {
account.withdraw(50);
assertEquals(50, account.getBalance());
}
}
}@Tag("slow") on a class or method lets a build select or exclude subsets of tests (e.g., mvn test -Dgroups=slow) without changing test code, and @Disabled("reason") turns a test off while recording why, instead of commenting it out.
Parameterized and repeated tests
@ParameterizedTest runs one test method once per source value, avoiding near-duplicate @Test methods:
java@ParameterizedTest
@ValueSource(strings = {"", " ", "\t"})
void blankStringsAreInvalid(String input) {
assertTrue(input.isBlank());
}
@ParameterizedTest
@CsvSource({"1,1,2", "2,3,5", "-1,1,0"})
void addsTwoNumbers(int a, int b, int expected) {
assertEquals(expected, a + b);
}@RepeatedTest(n) instead re-runs the same test n times with no varying input, injecting a RepetitionInfo if the method needs to know which repetition it's on:
java@RepeatedTest(5)
void repeatedWithInfo(RepetitionInfo info) {
System.out.println("Run " + info.getCurrentRepetition() + " of " + info.getTotalRepetitions());
}Dynamic tests
@TestFactory generates test cases at runtime instead of declaring each one at compile time — useful when the set of cases comes from data rather than from the source code itself:
java@TestFactory
Stream<DynamicTest> dynamicTestsForSquares() {
return IntStream.rangeClosed(1, 3)
.mapToObj(n -> DynamicTest.dynamicTest(
"square of " + n,
() -> assertEquals(n * n, square(n))));
}Trade-offs
@BeforeAllrequiresstatic(orPER_CLASSlifecycle) — because a new test instance is created per test method by default, an instance field isn't visible yet when the (default, per-class) setup method would need to run:
javaclass Broken {
Resource r; // instance field
@BeforeAll
void setUp() { r = new Resource(); } // error: @BeforeAll method must be static
}- Assumptions skip, assertions fail — mixing them up changes what a red build vs. a skipped test actually means, and CI dashboards treat the two very differently:
javaassumeTrue(false); // test reported as ABORTED/skipped, not failed
assertTrue(false); // test reported as FAILED- Parameterized tests trade readability for coverage — one method covering ten inputs is less code than ten near-identical
@Testmethods, but a failure message now has to include which input failed, and debugging means finding the right row in the source, not the right method name. - Dynamic tests aren't statically discoverable — because
@TestFactorygeneratesDynamicTests at runtime, IDEs can't list them ahead of a run the way they list@Testmethods, which makes "run just this one case" workflows clunkier.