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() { }
}| Annotation | Purpose |
|---|---|
@Test | Marks a test method (no attributes — use companion annotations). |
@DisplayName("…") | Human-readable name in IDE / reports. |
@Disabled("reason") | Skip the test (or class). |
@BeforeEach / @AfterEach | Run before / after every @Test in the class. |
@BeforeAll / @AfterAll | Run once. Must be static unless @TestInstance(PER_CLASS). |
@Tag("smoke") | Group tests for filtering at the runner level. |
@Nested | Inner class — groups related tests; inherits outer @BeforeEach. |
@Timeout | Fail 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 allowedExceptions 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).
Built-in / popular extensions
@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 interface | Fires |
|---|---|
BeforeAllCallback / AfterAllCallback | Around the test class |
BeforeEachCallback / AfterEachCallback | Around every test method |
BeforeTestExecutionCallback / AfterTestExecutionCallback | Tightest timing — just around the body |
TestExecutionExceptionHandler | Intercept exceptions thrown by tests |
ParameterResolver | Inject custom parameters into constructors and test methods |
TestWatcher | Read-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")
}