Skip to main content
JUnit 5
  1. Posts/

JUnit 5

·1356 words·7 mins·
Roman
Author
Roman
Photographer with MSci in Computer Science and a Home Lab obsession
Table of Contents

Foundations
#

JUnit 5 is composed of three modules:

Module Role
JUnit Platform Foundation layer. Defines the TestEngine API and launches test suites. IDEs and build tools (Maven, Gradle) integrate here.
JUnit Jupiter The JUnit 5 programming model. Provides all the annotations (@Test, @BeforeEach, etc.) and the engine that runs them.
JUnit Vintage Backwards compatibility engine. Runs JUnit 3 and JUnit 4 tests on the Platform without changes.

Jupiter is what you write against day-to-day. The Platform is what your tooling talks to. Vintage is only needed if you have legacy tests to run alongside new ones.

Lifecycle Annotations
#

Annotation Runs Description
@Test Per test Marks a method as a test case.
@BeforeEach Before each test Set up fresh state before every test method.
@AfterEach After each test Tear down or clean up after every test method.
@BeforeAll Once before all tests Runs once before any test in the class. Must be static
@AfterAll Once after all tests Runs once after all tests in the class. Must be static
class OrderServiceTest {

    @BeforeAll
    static void initDatabase() { /* runs once */ }

    @BeforeEach
    void setUp() { /* runs before each test */ }

    @Test
    void placingAnOrder_reducesStock() { /* test */ }

    @AfterEach
    void tearDown() { /* runs after each test */ }

    @AfterAll
    static void closeDatabase() { /* runs once */ }
}

@TestInstance
#

Controls how many instances of the test class JUnit creates.

Mode Behaviour Use when
PER_METHOD (default) New instance per test. @BeforeAll/@AfterAll must be static. Tests are fully isolated and share no state.
PER_CLASS One instance for the whole class. @BeforeAll/@AfterAll can be instance methods. Setup is expensive (e.g. DB connection, server start) and safe to share across tests.
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DatabaseTest {

    Connection connection;

    @BeforeAll
    void openConnection() {  // no static required
        connection = dataSource.getConnection();
    }

    @Test
    void queryReturnsResults() { ... }

    @AfterAll
    void closeConnection() {  // no static required
        connection.close();
    }
}

Conditional Execution
#

Disable or enable tests based on conditions without deleting them.

@Disabled
#

Unconditionally skips a test or entire class. Accepts an optional reason.

@Disabled("Flaky - fix tracked in JIRA-123")
@Test
void legacyBehaviour() { ... }

OS Conditions
#

@EnabledOnOs(OS.LINUX)
@Test
void linuxOnly() { ... }

@DisabledOnOs({OS.WINDOWS, OS.MAC})
@Test
void notOnWindowsOrMac() { ... }

Other Built-in Conditions
#

Annotation Skips unless
@EnabledOnOs / @DisabledOnOs Running on the specified OS
@EnabledOnJre(JRE.JAVA_21) Running on the specified Java version
@EnabledForJreRange(min = JAVA_17) Running within the specified Java version range
@EnabledIfSystemProperty(named = "env", matches = "ci") System property matches the given regex
@EnabledIfEnvironmentVariable(named = "CI", matches = "true") Environment variable matches the given regex

Core Assertions
#

All assertion methods are static imports from org.junit.jupiter.api.Assertions.

Basic Assertions
#

assertEquals(expected, actual);
assertNotEquals(unexpected, actual);
assertTrue(condition);
assertFalse(condition);
assertNull(object);
assertNotNull(object);
assertSame(expected, actual);      // same object reference
assertArrayEquals(expected, actual);

Every assertion accepts an optional message as the last argument:

assertEquals(42, result, "should be 42");
assertEquals(42, result, () -> "expensive message built only on failure");

assertThrows
#

Asserts that the code throws the expected exception type and returns the exception for further inspection.

IllegalArgumentException ex = assertThrows(
    IllegalArgumentException.class,
    () -> service.process(null)
);
assertEquals("input must not be null", ex.getMessage());

assertAll
#

Runs all assertions even if some fail, then reports all failures together.

assertAll("user",
    () -> assertEquals("Alice", user.name()),
    () -> assertEquals(30,      user.age()),
    () -> assertNotNull(        user.email())
);

Without assertAll, the first failing assertion stops the test and later ones are never checked.

assertTimeout
#

Fails if the supplied executable takes longer than the given duration.

assertTimeout(Duration.ofSeconds(2), () -> {
    // must complete within 2 seconds
    service.process();
});

Assumptions
#

Skip a test when a precondition is not met, rather than failing it. Useful when the test is irrelevant in the current environment (e.g. only meaningful on CI, or only with a specific config).

Method Behaviour
assumeTrue(condition) Aborts the test if the condition is false.
assumeFalse(condition) Aborts the test if the condition is true.
@Test
void onlyRunsOnCI() {
    assumeTrue("true".equals(System.getProperty("CI")));
    // skipped locally, runs on CI
}

An aborted test is reported as skipped, not failed.


Repeated & Timeout Tests
#

@RepeatedTest
#

Runs a test method a fixed number of times. Useful for flaky test detection or load-like validation.

@RepeatedTest(5)
void runsFiveTimes() { ... }

// Access repetition info via injection
@RepeatedTest(3)
void withInfo(RepetitionInfo info) {
    System.out.println("Run " + info.getCurrentRepetition() + " of " + info.getTotalRepetitions());
}

@Timeout
#

Fails a test if it exceeds the given duration. Can be applied to a method or the whole class.

@Test
@Timeout(2) // seconds by default
void completesWithinTwoSeconds() { ... }

@Test
@Timeout(value = 500, unit = TimeUnit.MILLISECONDS)
void completesWithinHalfASecond() { ... }

@Timeout on a class applies to every test method in it.


Parameterized Tests
#

Run the same test with different inputs using @ParameterizedTest paired with a source annotation.

Value Sources
#

@ParameterizedTest
@ValueSource(ints = {1, 2, 3})
void isPositive(int n) {
    assertTrue(n > 0);
}

@ParameterizedTest
@NullSource        // passes null
void acceptsNull(String s) { assertNull(s); }

@ParameterizedTest
@EmptySource       // passes "" for String, empty collection, etc.
void acceptsEmpty(String s) { assertTrue(s.isEmpty()); }

@ParameterizedTest
@NullAndEmptySource // both null and ""
void acceptsNullOrEmpty(String s) { ... }

CSV Sources
#

@ParameterizedTest
@CsvSource({
            "alice, 1",
            "bob, 2"
            })
void fromCsv(String name, int id) { ... }

@ParameterizedTest
@CsvFileSource(resources = "/data.csv", numLinesToSkip = 1)
void fromFile(String name, int id) { ... }

Enum Source
#

@ParameterizedTest
@EnumSource(Day.class)
void forEveryDay(Day day) { ... }

@ParameterizedTest
@EnumSource(value = Day.class, names = {"MONDAY", "FRIDAY"})
void forSpecificDays(Day day) { ... }

Source annotations: @ValueSource | @NullSource | @EmptySource | @NullAndEmptySource | @CsvSource | @CsvFileSource | @EnumSource


Nested Tests
#

@Nested groups related tests inside inner classes. Each inner class can have its own @BeforeEach/@AfterEach, and the outer class lifecycle wraps around it.

Useful for organising tests by state or scenario (e.g. “when logged in”, “when the cart is empty”).

class OrderServiceTest {

    OrderService service = new OrderService();

    @Nested
    class WhenCartIsEmpty {

        @Test
        void checkoutThrows() {
            assertThrows(IllegalStateException.class, () -> service.checkout());
        }

        @Test
        void totalIsZero() {
            assertEquals(0, service.total());
        }
    }

    @Nested
    class WhenCartHasItems {

        @BeforeEach
        void addItem() {
            service.add(new Item("book", 10));
        }

        @Test
        void checkoutSucceeds() {
            assertDoesNotThrow(() -> service.checkout());
        }
    }
}

Inner classes must be non-static.

@TempDir
#

@TempDir injects a temporary directory that JUnit creates before the test and deletes automatically after it completes. Useful for tests that need to read or write files without polluting the real filesystem.

Can be used as a field or a method parameter:

@Test
void writesOutputFile(@TempDir Path tempDir) throws IOException {
    Path output = tempDir.resolve("result.txt");
    Files.writeString(output, "hello");

    assertEquals("hello", Files.readString(output));
    assertTrue(Files.exists(output));
}

As a field, the same directory is shared across all tests in the class:

class ReportServiceTest {

    @TempDir
    Path tempDir;

    @Test
    void generatesReport() throws IOException {
        var service = new ReportService(tempDir);
        service.generate();

        assertTrue(Files.exists(tempDir.resolve("report.csv")));
    }

    @Test
    void cleansOldReports() throws IOException {
        Files.createFile(tempDir.resolve("old.csv"));
        var service = new ReportService(tempDir);
        service.clean();

        assertFalse(Files.exists(tempDir.resolve("old.csv")));
    }
}

Best Practices
#

AAA Pattern (Arrange-Act-Assert)
#

Structure every test in three clear phases.

@Test
void totalReflectsAddedItems() {
    // Arrange
    var cart = new Cart();
    cart.add(new Item("book", 10));
    cart.add(new Item("pen", 2));

    // Act
    int total = cart.total();

    // Assert
    assertEquals(12, total);
}

Naming
#

Test names should read as a sentence describing the behaviour, not the implementation.

// bad
void test1() { ... }
void testCheckout() { ... }

// good
void checkout_withEmptyCart_throwsException() { ... }
void total_reflectsAllAddedItems() { ... }

Use @DisplayName when a longer description is more readable than a method name:

@Test
@DisplayName("Checkout with an empty cart throws IllegalStateException")
void checkoutEmptyCart() { ... }

Test Isolation
#

Each test should set up its own state and not depend on the order of execution or side effects from other tests.

  • Use @BeforeEach to reset state, not a shared mutable field set once in @BeforeAll
  • Never write tests that must run in a specific order

Unit vs Integration Tests
#

Type Scope Speed Use for
Unit Single class, dependencies mocked Fast Business logic, edge cases
Integration Multiple components, real dependencies Slow DB queries, HTTP calls, wiring

Separate them with @Tag so they can be run independently:

@Tag("unit")
@Tag("integration")

Source
#