Now that the concepts are done, it’s time to get hands-on. Since its release in 2017, JUnit 5 has become the de facto standard for Java testing. This may be the first time you’ve seen a test framework that was “officially rewritten and thoroughly overhauled” — from architecture to API, everything is new. This article takes you from zero to actually using JUnit 5.
JUnit 5’s Architecture
JUnit 5 is not the kind of framework you imagine where “one jar does everything.” It consists of three modules:
┌─────────────────────────────────────────┐
│ JUnit 5 platform │
│ (junit-platform-engine) │
│ Discovers and runs tests │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Jupiter │ │ Vintage │ │
│ │ (new API) │ │ (JUnit 3/4) │ │
│ │ junit-jupiter │ │ junit-vintage │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────┘
- JUnit Platform: the underlying platform. IDEs, Maven, and Gradle use it to discover and execute tests
- JUnit Jupiter: the new API we write tests with (
@Test,@BeforeEach, and so on) - JUnit Vintage: the compatibility layer. It lets old JUnit 3/4 tests and new JUnit 5 tests coexist
A project can run old JUnit 4 tests and new JUnit 5 tests at the same time, migrating smoothly — that’s the meaning of Vintage.
Quick Start
Maven Configuration
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.3.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.3.1</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.0</version>
</plugin>
</plugins>
</build>
Note: maven-surefire-plugin must be 2.22.0 or above, otherwise it won’t recognize JUnit 5 tests.
Gradle Configuration
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.3.1'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.3.1'
}
test {
useJUnitPlatform()
}
Run Your First Test
mvn test
Output:
-------------------------------------------------------
T E S T S
-------------------------------------------------------
Running com.example.CalculatorTest
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
All green — you’ve written the first JUnit 5 tests of your life.
Core Annotations
JUnit 5’s annotation names are more intuitive; you can see the life cycle at a glance:
| Annotation | JUnit 4 equivalent | Purpose |
|---|---|---|
@Test |
@Test |
Marks a test method |
@BeforeEach |
@Before |
Runs before each test method |
@AfterEach |
@After |
Runs after each test method |
@BeforeAll |
@BeforeClass |
Runs once before all test methods (must be static) |
@AfterAll |
@AfterClass |
Runs once after all test methods (must be static) |
@Disabled |
@Ignore |
Skips that test |
The difference is in the naming: BeforeEach vs Before — “Each” tells you it runs before every test method, “All” tells you it runs once for all of them.
class LifecycleDemoTest {
@BeforeAll
static void initAll() {
System.out.println("全局初始化——只跑一次");
}
@BeforeEach
void init() {
System.out.println("每个测试前");
}
@Test
void test1() {
System.out.println("测试 1");
}
@Test
void test2() {
System.out.println("测试 2");
}
@AfterEach
void tearDown() {
System.out.println("每个测试后");
}
@AfterAll
static void tearDownAll() {
System.out.println("全局清理——只跑一次");
}
}
Output:
全局初始化——只跑一次
每个测试前
测试 1
每个测试后
每个测试前
测试 2
每个测试后
全局清理——只跑一次
Each test method is isolated from the others — this point matters. JUnit creates a new instance of the test class for each @Test method, so there’s no state pollution between tests.
Assertions
Assertions are the soul of testing — what you expect, what actually happened, and failure if they don’t match.
Basic Assertions
import static org.junit.jupiter.api.Assertions.*;
@Test
void basicAssertions() {
assertEquals(5, calculator.add(2, 3));
assertTrue(list.isEmpty());
assertFalse(condition);
assertNull(result);
assertNotNull(user);
assertSame(expected, actual); // the same reference
assertNotSame(a, b);
}
Grouped Assertions
When you have several assertions to run and want to see every failure reason even when they all fail (not just the first one):
@Test
void groupedAssertions() {
assertAll("user",
() -> assertEquals("张三", user.getName()),
() -> assertEquals(25, user.getAge()),
() -> assertTrue(user.isActive())
);
}
assertAll executes all the assertions; even if the first one fails, it won’t stop the rest. You see the complete failure information, instead of fixing one only to discover the next is wrong.
Exception Assertions
The problem with JUnit 4’s @Test(expected=...) is that you can only “expect some method to throw,” but you can’t assert on the exception message or which line the exception occurred on. JUnit 5’s assertThrows solves this:
@Test
void exceptionTesting() {
Exception exception = assertThrows(
IllegalArgumentException.class,
() -> calculator.divide(10, 0)
);
assertEquals("除数不能为零", exception.getMessage());
}
assertThrows returns the caught exception object, and you can then assert on its message, cause, and so on.
Timeout Assertions
@Test
void timeoutNotExceeded() {
String result = assertTimeout(
Duration.ofSeconds(1),
() -> service.heavyComputation()
);
assertEquals("done", result);
}
It fails if it takes more than 1 second. This keeps a test from hanging on an infinite loop or a blocked network call.
Parameterized Tests
One test method, multiple sets of input data — that’s the value of parameterized tests: using one table of data to drive multiple test executions.
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"2, 3, 5",
"0, 0, 0",
"-1, 1, 0",
"100, 200, 300"
})
void add(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}
Each set of CsvSource data generates one test execution. The output report:
[1] 1,1 → 2 √
[2] 2,3 → 5 √
[3] 0,0 → 0 √
[4] -1,1 → 0 √
[5] 100,200 → 300 √
If the third set fails, you can tell right in the IDE which set of data is the problem, without debugging line by line.
Other Data Sources
// Single-value test
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t", "\n"})
void shouldBeBlank(String input) {
assertTrue(StringUtils.isBlank(input));
}
// A method provides the data
@ParameterizedTest
@MethodSource("provideNumbers")
void testWithMethodSource(int input, boolean expected) {
assertEquals(expected, NumberUtils.isEven(input));
}
static Stream<Arguments> provideNumbers() {
return Stream.of(
Arguments.of(2, true),
Arguments.of(3, false),
Arguments.of(0, true)
);
}
// Enumerate all enum values
@ParameterizedTest
@EnumSource(DayOfWeek.class)
void testIsWorkday(DayOfWeek day) {
// ...
}
Nested Tests
When the class under test has multiple scenarios, use nested tests to organize related tests together:
@DisplayName("用户服务测试")
class UserServiceTest {
UserService service = new UserService();
@Nested
@DisplayName("注册功能")
class Register {
@Test
@DisplayName("合法的用户名和密码,注册成功")
void shouldRegisterWithValidInput() {
assertTrue(service.register("user1", "pass123"));
}
@Test
@DisplayName("用户名为空,抛出异常")
void shouldThrowWhenUsernameEmpty() {
assertThrows(IllegalArgumentException.class,
() -> service.register("", "pass123"));
}
}
@Nested
@DisplayName("登录功能")
class Login {
@Test
@DisplayName("正确的凭证,登录成功")
void shouldLoginWithCorrectCredentials() {
assertTrue(service.login("user1", "pass123"));
}
@Test
@DisplayName("错误的密码,登录失败")
void shouldFailWithWrongPassword() {
assertFalse(service.login("user1", "wrong"));
}
}
}
The IDE displays it as a tree structure:
用户服务测试
├── Registration
│ ├── ✓ A valid username and password, registration succeeds
│ └── ✓ Empty username, throws an exception
└── Login
├── ✓ Correct credentials, login succeeds
└── ✓ Wrong password, login fails
Classes inside @Nested can also have their own @BeforeEach / @AfterEach; the outer life cycle runs first, the inner one after.
Display Names
@DisplayName lets you describe a test’s intent using Chinese, spaces, and special symbols:
@Test
@DisplayName("When the username is empty → throws IllegalArgumentException")
void test1() { /* ... */ }
The report displays this description instead of a meaningless method name like test1. For team collaboration, that’s far friendlier than being forced to write English method names in camelCase.
Conditional Execution
Decide whether to skip a test based on the run environment:
@Test
@EnabledOnOs(OS.MAC)
void onlyOnMac() { /* ... */ }
@Test
@EnabledOnOs(OS.WINDOWS)
void onlyOnWindows() { /* ... */ }
@Test
@EnabledOnJre(JRE.JAVA_9)
void onlyOnJava9() { /* ... */ }
@Test
@EnabledIfSystemProperty(named = "env", matches = "ci")
void onlyInCI() { /* ... */ }
Migrating from JUnit 4
If you have a large amount of JUnit 4 test code, you can migrate gradually:
- Bring in both
junit-vintage-engineandjunit-jupiter-enginein yourpom.xml - Leave the old JUnit 4 tests alone and keep running them
- Write new tests with the JUnit 5 API
<!-- Compatible with old JUnit 4 tests -->
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<version>5.3.1</version>
<scope>test</scope>
</dependency>
One mvn test runs the JUnit 4 and JUnit 5 tests together, each going green or red on its own, without interfering with each other.
Summary
JUnit 5 isn’t an “upgraded version” of JUnit 4, but a rethink of the Java testing framework:
- Modular architecture: Platform + Jupiter + Vintage, each with its own job
- A more human-friendly API:
@BeforeEach,@DisplayName,assertThrows - Native parameterized tests:
@CsvSource,@MethodSourcework out of the box - Nested tests: organized layer by layer by scenario, with tree-shaped reports
- Peaceful migration: the Vintage engine lets old and new tests coexist
Next time, we’ll run into a practical problem: the object under test depends on a database, an HTTP service, or the file system — how do you handle these external dependencies? That’s when Mockito takes the stage.
A small step forward every day is a new height!