On this page9 sections
CommandsIntermediate7-9 min reference

JUnit 5

A practical reference for JUnit 5 (Jupiter) — modern annotations, lambda-friendly assertions, parameterized tests, and the extension model that replaces JUnit 4's runners.

Annotations

import org.junit.jupiter.api.*;
 
class CheckoutTest {
 
  @BeforeAll  static void onceBefore()  { /* setup once for the class */ }
  @AfterAll   static void onceAfter()   { /* teardown once */ }
 
  @BeforeEach void beforeEach()         { /* before every test */ }
  @AfterEach  void afterEach()          { /* after every test */ }
 
  @Test
  @DisplayName("Cart total is the sum of item prices")
  void cartTotal() { /* ... */ }
 
  @Test
  @Disabled("flaky on CI — see #1234")
  void brokenTest() { }
 
  @Test
  @Tag("smoke")
  void smokeOnly() { }
 
  @Test
  @Timeout(5)                       // seconds; fails if test runs longer
  void timeBound() { }
 
  @Test
  @Order(1)                         // requires @TestMethodOrder
  void runsFirst() { }
}
AnnotationPurpose
@TestMarks a test method (no attributes — use companion annotations).
@DisplayName("…")Human-readable name in IDE / reports.
@Disabled("reason")Skip the test (or class).
@BeforeEach / @AfterEachRun before / after every @Test in the class.
@BeforeAll / @AfterAllRun once. Must be static unless @TestInstance(PER_CLASS).
@Tag("smoke")Group tests for filtering at the runner level.
@NestedInner class — groups related tests; inherits outer @BeforeEach.
@TimeoutFail if test takes too long.
@TestInstance(Lifecycle.PER_CLASS)One instance shared across tests; non-static lifecycle hooks allowed.
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)Honour @Order(n) on methods.

@TestInstance and @BeforeAll

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class CartTest {
  Cart cart;                      // shared across all tests
 
  @BeforeAll
  void seed() {                   // no need for `static`
    cart = new Cart(seedItems());
  }
}

Default is PER_METHOD — each @Test gets a fresh instance. Switch to PER_CLASS only when state genuinely needs to be shared (it's an exception, not a default).

Assertions

import static org.junit.jupiter.api.Assertions.*;
 
assertEquals(expected, actual);
assertEquals(expected, actual, "user id mismatch");
assertEquals(expected, actual, () -> "lazy message: " + expensive());
 
assertNotEquals(unexpected, actual);
assertTrue(condition, "should be visible");
assertFalse(condition);
assertNull(value);
assertNotNull(value);
assertSame(a, b);                 // same reference
assertNotSame(a, b);
 
assertIterableEquals(List.of(1, 2, 3), result);
assertArrayEquals(new int[]{1, 2}, result);
assertLinesMatch(List.of("hello", "(?i)world"), out);   // regex per line allowed

Exceptions and time

// Verify a thrown exception
ValidationException e = assertThrows(
  ValidationException.class,
  () -> svc.create(invalidInput));
assertEquals("email required", e.getMessage());
 
// Verify NO exception
assertDoesNotThrow(() -> svc.create(validInput));
 
// Time-bounded execution
assertTimeout(Duration.ofSeconds(2), () -> svc.compute());
 
// Hard timeout (preempts on a separate thread — careful with thread-bound state)
assertTimeoutPreemptively(Duration.ofMillis(500), () -> service.fetch());

Group multiple assertions — all run, all reported

assertX(...) aborts the test on the first failure. assertAll collects every failure.

assertAll("user shape",
  () -> assertEquals(42, user.id()),
  () -> assertEquals("Ada", user.name()),
  () -> assertTrue(user.active()),
  () -> assertNotNull(user.createdAt()));

If three of those four fail, the test report shows all three — not just the first.

Parameterized Tests

Add org.junit.jupiter:junit-jupiter-params to the classpath, then mark methods @ParameterizedTest and pair with a source.

@ValueSource — single-arg literals

@ParameterizedTest
@ValueSource(strings = { "ada@example.com", "bob@example.com", "carol@example.com" })
void acceptsValidEmails(String email) {
  assertTrue(EmailValidator.isValid(email));
}

Other forms: ints, longs, doubles, booleans, classes.

Edge-case sources — null and empty

@ParameterizedTest
@NullSource
@EmptySource
@ValueSource(strings = { " ", "\t" })
void rejectsBlankInputs(String value) {
  assertThrows(IllegalArgumentException.class, () -> svc.handle(value));
}
 
@ParameterizedTest
@NullAndEmptySource              // shorthand for @NullSource + @EmptySource
@ValueSource(strings = { " " })
void rejects(String value) { /* ... */ }

@EnumSource — every enum value

@ParameterizedTest
@EnumSource(Status.class)
void allStatusesHaveLabel(Status s) {
  assertNotNull(s.label());
}
 
@ParameterizedTest
@EnumSource(value = Status.class, names = {"ACTIVE", "PENDING"})
void onlyTheseTwo(Status s) { /* ... */ }
 
@ParameterizedTest
@EnumSource(value = Status.class, mode = EnumSource.Mode.EXCLUDE, names = {"ARCHIVED"})
void everythingExceptArchived(Status s) { /* ... */ }

@CsvSource — multi-arg literals

@ParameterizedTest(name = "[{index}] {0} + {1} = {2}")
@CsvSource({
  "1,  1,  2",
  "2,  3,  5",
  "10, -3, 7",
  "0,  0,  0"
})
void adds(int a, int b, int sum) {
  assertEquals(sum, a + b);
}
 
// Quotes for strings with commas
@ParameterizedTest
@CsvSource({
  "'admin@test.com', 'Admin123', true",
  "'invalid@x',      'wrong',    false",
  "'',               '',         false"
})
void login(String email, String password, boolean expected) {
  assertEquals(expected, loginPage.login(email, password));
}

@CsvFileSource — external file

@ParameterizedTest
@CsvFileSource(resources = "/test-data/credentials.csv", numLinesToSkip = 1)
void login(String email, String password, boolean expected) { /* ... */ }

credentials.csv on the classpath:

email,password,expected
admin@test.com,Admin123,true
viewer@test.com,View123,true
invalid@test.com,wrong,false

@MethodSource — for anything not literal

@ParameterizedTest
@MethodSource("validEmails")
void acceptsEmails(String email) {
  assertTrue(EmailValidator.isValid(email));
}
 
static Stream<String> validEmails() {
  return Stream.of("ada@example.com", "bob+filter@example.com", "a@b.co");
}
 
@ParameterizedTest
@MethodSource("loginCases")
void login(String email, String password, boolean expected) { /* ... */ }
 
static Stream<Arguments> loginCases() {
  return Stream.of(
    Arguments.of("admin@test.com", "Admin123", true),
    Arguments.of("invalid@x",      "wrong",    false)
  );
}

@MethodSource("com.qa.TestData#emails") references a method in another class.

Lifecycle & Test Instance

By default, JUnit creates a new instance of the test class per test method. That's why @BeforeAll / @AfterAll must be static — there's no shared instance to call them on.

@BeforeAll
  for each test:
    new TestClass()
    @BeforeEach
      @Test
    @AfterEach
@AfterAll

@Nested test classes

Group related tests under a parent. Outer @BeforeEach runs for nested tests; outer @BeforeAll does not.

class UserServiceTest {
 
  UserService svc;
  @BeforeEach void init() { svc = new UserService(); }
 
  @Nested
  @DisplayName("when creating a user")
  class WhenCreating {
 
    @Test
    @DisplayName("rejects empty email")
    void rejectsEmpty() {
      assertThrows(ValidationException.class, () -> svc.create(""));
    }
 
    @Test
    @DisplayName("returns id on success")
    void returnsId() {
      assertNotNull(svc.create("ada@example.com").id());
    }
  }
}

Reads as UserServiceTest > when creating a user > rejects empty email.

Conditional Test Execution

Built-in conditions skip tests when an environment doesn't apply.

@Test
@EnabledOnOs(OS.LINUX)
void linuxOnly() { }
 
@Test
@DisabledOnOs(OS.WINDOWS)
void notOnWindows() { }
 
@Test
@EnabledOnJre(JRE.JAVA_21)
void java21Only() { }
 
@Test
@EnabledForJreRange(min = JRE.JAVA_17)
void java17OrNewer() { }
 
@Test
@EnabledIfSystemProperty(named = "env", matches = "staging")
void onlyOnStaging() { }
 
@Test
@EnabledIfEnvironmentVariable(named = "CI", matches = "true")
void onlyInCI() { }
 
@Test
@EnabledIf("isDatabaseAvailable")          // method that returns boolean
void integrationTest() { }
 
static boolean isDatabaseAvailable() {
  try (var c = DriverManager.getConnection(URL)) { return c.isValid(2); }
  catch (SQLException e) { return false; }
}

Extensions

Extensions replace JUnit 4 runners and rules. Register with @ExtendWith (or via META-INF/services for auto-registration).

@ExtendWith({ MockitoExtension.class, SpringExtension.class })
class IntegrationTest { /* ... */ }

Writing your own — screenshot on failure

public class ScreenshotOnFailure implements TestWatcher, AfterTestExecutionCallback {
  @Override
  public void testFailed(ExtensionContext ctx, Throwable cause) {
    WebDriver driver = (WebDriver) ctx.getStore(NAMESPACE).get("driver");
    if (driver == null) return;
    byte[] png = ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES);
    try {
      Files.write(Path.of("target/screenshots/" + ctx.getDisplayName() + ".png"), png);
    } catch (IOException ignored) {}
  }
}
@ExtendWith(ScreenshotOnFailure.class)
class LoginTest { /* ... */ }

Extension callbacks

Callback interfaceFires
BeforeAllCallback / AfterAllCallbackAround the test class
BeforeEachCallback / AfterEachCallbackAround every test method
BeforeTestExecutionCallback / AfterTestExecutionCallbackTightest timing — just around the body
TestExecutionExceptionHandlerIntercept exceptions thrown by tests
ParameterResolverInject custom parameters into constructors and test methods
TestWatcherRead-only — testFailed, testSuccessful, testAborted, testDisabled

Parameter resolution — inject dependencies

public class WebDriverParameterResolver implements ParameterResolver, AfterEachCallback {
  @Override
  public boolean supportsParameter(ParameterContext pc, ExtensionContext ec) {
    return pc.getParameter().getType() == WebDriver.class;
  }
 
  @Override
  public Object resolveParameter(ParameterContext pc, ExtensionContext ec) {
    WebDriver d = new ChromeDriver();
    ec.getStore(Namespace.create(getClass())).put("driver", d);
    return d;
  }
 
  @Override
  public void afterEach(ExtensionContext ec) {
    WebDriver d = (WebDriver) ec.getStore(Namespace.create(getClass())).get("driver");
    if (d != null) d.quit();
  }
}
 
@ExtendWith(WebDriverParameterResolver.class)
class LoginTest {
  @Test
  void login(WebDriver driver) {                 // injected
    driver.get("https://app.example.com/login");
  }
}

Assumptions

Assumptions skip the test (instead of failing it) when a precondition isn't met.

import static org.junit.jupiter.api.Assumptions.*;
 
@Test
void integrationOnly() {
  assumeTrue("true".equals(System.getenv("RUN_INTEGRATION")),
             "skipping — set RUN_INTEGRATION=true");
  // ... test code
}
 
@Test
void worksWhenDbAvailable() {
  assumingThat(isDatabaseUp(), () -> {
    var rows = svc.fetchUsers();
    assertTrue(rows.size() > 0);
  });
}

Failed assumptions show as skipped, not failed — the difference matters for CI signal.

Dynamic Tests

@TestFactory generates tests at runtime — useful when the test cases come from external data.

@TestFactory
Stream<DynamicTest> validatesAllSampleUsers() throws IOException {
  List<User> users = loadFixtures("users.json");
 
  return users.stream()
    .map(user -> DynamicTest.dynamicTest(
      "user " + user.id() + " — " + user.email(),
      () -> assertTrue(EmailValidator.isValid(user.email()))));
}
 
@TestFactory
Collection<DynamicNode> nestedDynamic() {
  return List.of(
    dynamicContainer("validation", List.of(
      dynamicTest("rejects empty",   () -> assertFalse(v.isValid(""))),
      dynamicTest("rejects no-at",   () -> assertFalse(v.isValid("foo")))
    )),
    dynamicContainer("happy path", List.of(
      dynamicTest("accepts simple", () -> assertTrue(v.isValid("a@b.co")))
    ))
  );
}

Each DynamicTest shows up individually in the report — no static @Test per case needed.

JUnit 5 with Selenium Pattern

Pulling it together — a parallel-safe Selenium suite with @BeforeEach driver setup, an extension for screenshots, and @ParameterizedTest for data-driven cases.

@ExtendWith({ ScreenshotOnFailure.class })
class LoginTest {
 
  WebDriver driver;
 
  @BeforeEach
  void setUp() {
    driver = new ChromeDriver();
    driver.get("https://app.example.com/login");
  }
 
  @AfterEach
  void tearDown() {
    if (driver != null) driver.quit();
  }
 
  @Test
  @Tag("smoke")
  @DisplayName("Successful login lands on dashboard")
  void successfulLogin() {
    new LoginPage(driver).loginAs("admin@test.com", "Admin123");
    assertEquals("Dashboard", driver.getTitle());
  }
 
  @ParameterizedTest(name = "[{index}] {0} → {2}")
  @CsvSource({
    "admin@test.com,    wrong,       Invalid email or password",
    "missing@x.com,     anything,    Invalid email or password",
    "'',                '',          Email is required"
  })
  void invalidLogin(String email, String pw, String expectedError) {
    new LoginPage(driver).loginAs(email, pw);
    assertEquals(expectedError, new LoginPage(driver).errorText());
  }
}

Parallel execution

JUnit 5 runs tests sequentially by default. Enable parallelism via src/test/resources/junit-platform.properties:

junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = same_thread
junit.jupiter.execution.parallel.mode.classes.default = concurrent

concurrent at the class level + same_thread per method runs whole classes in parallel but keeps each class's tests in order — usually the right balance for browser tests where state in @BeforeEach/@AfterEach is per-class.

Build-tool integration

<!-- Maven Surefire — automatic JUnit 5 detection on Surefire ≥ 2.22 -->
<plugin>
  <artifactId>maven-surefire-plugin</artifactId>
  <version>3.2.5</version>
  <configuration>
    <groups>smoke</groups>
    <excludedGroups>wip</excludedGroups>
    <parallel>classes</parallel>
    <threadCount>4</threadCount>
  </configuration>
</plugin>
// Gradle
tasks.test {
  useJUnitPlatform {
    includeTags("smoke")
    excludeTags("wip")
  }
  systemProperty("junit.jupiter.execution.parallel.enabled", "true")
}