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:

ModuleRole
JUnit PlatformFoundation layer. Defines the TestEngine API and launches test suites. IDEs and build tools (Maven, Gradle) integrate here.
JUnit JupiterThe JUnit 5 programming model. Provides all the annotations (@Test, @BeforeEach, etc.) and the engine that runs them.
JUnit VintageBackwards 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
#

AnnotationRunsDescription
@TestPer testMarks a method as a test case.
@BeforeEachBefore each testSet up fresh state before every test method.
@AfterEachAfter each testTear down or clean up after every test method.
@BeforeAllOnce before all testsRuns once before any test in the class. Must be static
@AfterAllOnce after all testsRuns 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.

ModeBehaviourUse when
PER_METHOD (default)New instance per test. @BeforeAll/@AfterAll must be static.Tests are fully isolated and share no state.
PER_CLASSOne 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
#

AnnotationSkips unless
@EnabledOnOs / @DisabledOnOsRunning 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).

MethodBehaviour
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
#

TypeScopeSpeedUse for
UnitSingle class, dependencies mockedFastBusiness logic, edge cases
IntegrationMultiple components, real dependenciesSlowDB queries, HTTP calls, wiring

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

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

Source
#