JUnit 5 Complete Guide
JUnit 5 is the current generation of the Java testing framework, released in 2017 and now the default choice for new projects. It replaces the monolithic JUnit 4 jar with a modular architecture, adds first-class support for parameterized tests, nested test classes, and dynamic test generation, and introduces a clean extension model that replaces the old @Rule and @RunWith mechanisms.
This guide covers everything you need to be productive with JUnit 5: architecture, setup, annotations, assertions, parameterized tests, extensions, and migration from JUnit 4.
JUnit 5 Architecture
JUnit 5 is composed of three separate modules:
JUnit Platform is the foundation. It defines the TestEngine API that test frameworks implement to run on the JVM. It also provides the launcher API used by build tools (Maven Surefire, Gradle) and IDEs to discover and execute tests. The Platform is what makes it possible to run JUnit Jupiter, JUnit Vintage, and third-party engines side by side.
JUnit Jupiter is the new programming model — the annotations and APIs you write your tests with. When people say "JUnit 5", they usually mean Jupiter. It ships as two artifacts: junit-jupiter-api (the API you code against) and junit-jupiter-engine (the engine that runs Jupiter tests on the Platform).
JUnit Vintage is a compatibility shim that runs JUnit 3 and JUnit 4 tests on the JUnit 5 Platform. It lets you migrate incrementally — old tests keep running while you write new ones with Jupiter.
Maven and Gradle Setup
Maven
Add the junit-jupiter aggregate dependency, which pulls in the API and engine together:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>Maven Surefire 2.22.0+ supports JUnit 5 natively. Make sure your pom.xml specifies at least that version:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>Gradle
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
}
tasks.test {
useJUnitPlatform()
}The useJUnitPlatform() call is required — without it Gradle's test task won't discover Jupiter tests.
Core Annotations
JUnit 5 introduces new annotations in the org.junit.jupiter.api package. The old JUnit 4 annotations (org.junit.Test, etc.) are not recognized by the Jupiter engine.
import org.junit.jupiter.api.*;
class CalculatorTest {
@BeforeAll
static void initAll() { /* runs once before all tests in this class */ }
@BeforeEach
void init() { /* runs before each test */ }
@Test
void addition() {
assertEquals(4, 2 + 2);
}
@RepeatedTest(5)
void repeatedAddition(RepetitionInfo info) {
// runs 5 times; info.getCurrentRepetition() available
assertTrue(info.getCurrentRepetition() <= 5);
}
@Test
@Disabled("Not implemented yet")
void skippedTest() {}
@AfterEach
void tearDown() { /* runs after each test */ }
@AfterAll
static void tearDownAll() { /* runs once after all tests */ }
}@TestMethodOrder controls execution order when it matters (e.g., integration tests with shared state):
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class OrderedTest {
@Test @Order(1)
void firstStep() {}
@Test @Order(2)
void secondStep() {}
}Assertions
Jupiter's Assertions class covers all common cases. Unlike JUnit 4, method parameters follow the pattern (expected, actual, message) where message is last — not first.
import static org.junit.jupiter.api.Assertions.*;
@Test
void assertionsDemo() {
// basic equality
assertEquals(42, compute(), "Computation should return 42");
// grouped assertions — all run even if some fail
assertAll("address",
() -> assertEquals("Prague", address.getCity()),
() -> assertEquals("CZ", address.getCountry()),
() -> assertNotNull(address.getPostalCode())
);
// exception testing
IllegalArgumentException ex = assertThrows(
IllegalArgumentException.class,
() -> divide(1, 0)
);
assertTrue(ex.getMessage().contains("zero"));
// timeout
assertTimeout(Duration.ofMillis(100), () -> {
// must complete within 100 ms
fastOperation();
});
}assertAll is particularly useful for validating objects — it collects all failures and reports them together rather than stopping at the first one.
Parameterized Tests
@ParameterizedTest replaces the verbose JUnit 4 @RunWith(Parameterized.class) pattern. You supply a source annotation, and JUnit injects each value as a method argument.
@ValueSource — simple single-argument tests
@ParameterizedTest
@ValueSource(strings = {"racecar", "radar", "level"})
void isPalindrome(String word) {
assertTrue(StringUtils.isPalindrome(word));
}@CsvSource — multiple arguments per test case
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"2, 3, 5",
"10, -5, 5"
})
void add(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}@MethodSource — complex objects from a factory method
@ParameterizedTest
@MethodSource("provideUsers")
void userValidation(User user, boolean expectedValid) {
assertEquals(expectedValid, validator.isValid(user));
}
static Stream<Arguments> provideUsers() {
return Stream.of(
Arguments.of(new User("alice", "alice@example.com"), true),
Arguments.of(new User("", "alice@example.com"), false),
Arguments.of(new User("bob", "not-an-email"), false)
);
}Assumptions
Assumptions allow a test to abort gracefully when a precondition is not met. A failed assumption marks the test as aborted (not failed), which is useful for environment-specific tests.
import static org.junit.jupiter.api.Assumptions.*;
@Test
void onlyOnCI() {
assumeTrue("CI".equals(System.getenv("ENV")),
"Skipping — not running on CI");
// test body only executes when ENV=CI
runCIOnlyCheck();
}
@Test
void conditionalBlock() {
assumingThat(
"DEV".equals(System.getenv("ENV")),
() -> {
// only executed in DEV
assertEquals(8080, config.getPort());
}
);
// this always runs
assertNotNull(config);
}@Nested Tests
@Nested classes let you express hierarchical test structure. Each inner class can have its own @BeforeEach/@AfterEach lifecycle, and the nesting communicates relationships between test groups.
class OrderServiceTest {
OrderService service = new OrderService();
@Nested
class WhenCartIsEmpty {
@Test
void checkoutThrows() {
assertThrows(EmptyCartException.class,
() -> service.checkout(emptyCart()));
}
@Test
void totalIsZero() {
assertEquals(0, service.total(emptyCart()));
}
}
@Nested
class WhenCartHasItems {
Cart cart;
@BeforeEach
void setUp() {
cart = cartWithItems("SKU-1", "SKU-2");
}
@Test
void checkoutSucceeds() {
assertDoesNotThrow(() -> service.checkout(cart));
}
@Test
void totalIsPositive() {
assertTrue(service.total(cart) > 0);
}
}
}Nesting makes test reports readable — the class hierarchy appears in output and IDE runners, so it is immediately clear which scenario each test belongs to.
@ExtendWith and the Extension Model
JUnit 5 replaces @RunWith and @Rule with a single @ExtendWith annotation that accepts any number of extension classes. Extensions implement one or more callback interfaces (BeforeEachCallback, ParameterResolver, TestExecutionExceptionHandler, etc.).
Mockito
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
UserRepository repository;
@InjectMocks
UserService service;
@Test
void findByIdDelegatesToRepository() {
when(repository.findById(1L)).thenReturn(Optional.of(new User(1L, "Alice")));
assertEquals("Alice", service.findById(1L).getName());
}
}Spring Boot
@ExtendWith(SpringExtension.class)
@SpringBootTest
class IntegrationTest {
@Autowired
ApplicationContext context;
@Test
void contextLoads() {
assertNotNull(context);
}
}@SpringBootTest is meta-annotated with @ExtendWith(SpringExtension.class) since Spring Boot 2.1, so you usually don't need to add it explicitly.
Dynamic Tests with @TestFactory
@TestFactory generates tests at runtime. The factory method returns a Stream, Collection, or Iterable of DynamicTest instances. This is useful when test cases come from an external source like a database, file, or API response.
@TestFactory
Stream<DynamicTest> dynamicFromDataSource() {
return loadTestCasesFromFile("test-cases.json")
.stream()
.map(tc -> dynamicTest(
tc.getDescription(),
() -> assertEquals(tc.getExpected(), evaluate(tc.getInput()))
));
}Unlike parameterized tests, each DynamicTest has its own display name, making failures easy to identify in the report.
Migrating from JUnit 4
If you have an existing JUnit 4 test suite, the migration path is straightforward:
1. Add JUnit Vintage to run old tests without changing them:
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>2. Replace imports in new and migrated tests:
| JUnit 4 | JUnit 5 (Jupiter) |
|---|---|
org.junit.Test |
org.junit.jupiter.api.Test |
org.junit.Before |
org.junit.jupiter.api.BeforeEach |
org.junit.After |
org.junit.jupiter.api.AfterEach |
org.junit.BeforeClass |
org.junit.jupiter.api.BeforeAll |
org.junit.AfterClass |
org.junit.jupiter.api.AfterAll |
org.junit.Ignore |
org.junit.jupiter.api.Disabled |
3. Replace @RunWith and @Rule with @ExtendWith. Most popular libraries (Mockito, Spring, WireMock) already ship a JUnit 5 extension.
4. Update assertions — the argument order for assertEquals flipped (expected before actual in both, but message moved to the end in Jupiter). Use your IDE's "migrate JUnit" inspection to catch these automatically.
5. Remove public from test methods — Jupiter does not require test methods or classes to be public.
Classes can be migrated file by file. There is no need to migrate everything at once.
Complete Your Java Test Suite
JUnit 5 handles unit and integration tests. For end-to-end browser testing with AI-generated scenarios and 24/7 monitoring, HelpMeTest covers the browser layer — starting free.