JUnit 5 Architecture, Maven Setup, and JUnit 4 Migration

Last update: 2026-08-17 10:18:23 +0800 +0800
tags: Java | Junit | Maven | Testing
Available translations: 日本語 / 中文(简体) / 中文(繁體)

JUnit 5 separates the API used to write tests from the infrastructure that discovers and executes them. The separation lets Maven, IDEs, and custom launchers run several test models through one interface.

The examples target JUnit 5.13.4. JUnit 6 retains the same architecture, but a project that must remain on JUnit 5 should use the versioned JUnit 5 documentation.

Platform, Jupiter, and Vintage #

JUnit 5 consists of three components:

ComponentResponsibility
JUnit PlatformDiscovers tests and launches registered test engines. Maven Surefire and IDEs integrate at this level.
JUnit JupiterDefines the JUnit 5 programming model, extension API, and test engine.
JUnit VintageRuns JUnit 3 and JUnit 4 tests on the Platform during migration.

A Jupiter test imports @Test and assertions from org.junit.jupiter.api. Surefire asks the Platform to discover tests, then junit-jupiter-engine executes the discovered methods. Application code does not depend on any JUnit component because test dependencies use Maven’s test scope.

Minimal Maven Project #

The following layout keeps production and test classes separate:

text
junit-hello/
├── pom.xml
└── src
    ├── main/java/com/example/Greeter.java
    └── test/java/com/example/GreeterTest.java

Use org.junit.jupiter:junit-jupiter for the smallest maintainable POM. The artifact aggregates the Jupiter API, parameterized-test support, and runtime engine.

xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
                             https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.example</groupId>
  <artifactId>junit-hello</artifactId>
  <version>1.0-SNAPSHOT</version>

  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <junit.version>5.13.4</junit.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>${junit.version}</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.4</version>
      </plugin>
    </plugins>
  </build>
</project>
java
package com.example;

public final class Greeter {
    public String greet(String name) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("name must not be blank");
        }
        return "Hello, " + name;
    }
}
java
package com.example;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import org.junit.jupiter.api.Test;

class GreeterTest {
    private final Greeter greeter = new Greeter();

    @Test
    void greetsByName() {
        assertEquals("Hello, Ada", greeter.greet("Ada"));
    }

    @Test
    void rejectsBlankNames() {
        var error = assertThrows(
                IllegalArgumentException.class,
                () -> greeter.greet(" "));

        assertEquals("name must not be blank", error.getMessage());
    }
}

Run the test through Maven:

sh
mvn test

Jupiter test classes and methods may have package-private visibility. Test methods must not be private or static, although lifecycle and extension rules permit static in specific places.

Choosing JUnit Artifacts #

Add another artifact only when its capability is required:

ArtifactWhen to Add It
junit-jupiterPrefer the aggregate dependency for an application test suite. It already includes API, parameters, and engine.
junit-jupiter-apiCompile tests or extensions against the API while managing runtime engines separately.
junit-jupiter-engineExecute Jupiter tests when using fine-grained dependencies instead of junit-jupiter. Use test runtime scope.
junit-jupiter-paramsAdd @ParameterizedTest support to a fine-grained setup. The aggregate dependency already includes it.
junit-vintage-engineRun unchanged JUnit 3 or JUnit 4 tests through the Platform.
junit-jupiter-migrationsupportTemporarily adapt selected JUnit 4 rules and @Ignore while converting tests to Jupiter.
junit-platform-suiteDeclare suites with Platform suite annotations.
junit-platform-launcherDiscover and execute tests programmatically, or implement test tooling. Application test suites do not need it directly.
junit-platform-console-standaloneRun tests outside Maven or an IDE from one executable JAR. Keep the JAR outside the project POM.

Import the JUnit BOM when a build declares several JUnit artifacts because the BOM keeps Platform, Jupiter, and Vintage versions compatible:

xml
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.junit</groupId>
      <artifactId>junit-bom</artifactId>
      <version>${junit.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

Artifact declarations covered by the BOM can omit <version>.

An explicit junit-platform-launcher dependency was necessary with Surefire releases before 3.0. Current Surefire 3.x releases align the launcher with the Platform on the test runtime classpath, so adding a launcher to an ordinary test suite creates another version to manage without adding test capability.

Jupiter Programming Model #

Jupiter provides focused annotations for common test structures:

  • @BeforeEach and @AfterEach establish per-test fixtures.
  • @BeforeAll and @AfterAll establish class-level fixtures.
  • @ParameterizedTest executes one test body with data from @ValueSource, @CsvSource, @MethodSource, or another argument source.
  • @Nested groups related cases while preserving lifecycle semantics.
  • @Tag classifies tests for build-time inclusion or exclusion.
  • @ExtendWith registers extensions for parameter resolution, callbacks, conditional execution, and other cross-cutting behavior.

Tests should assert observable results rather than execution order. Jupiter does not guarantee a self-evident order from method names, and ordered tests share state that makes failures dependent on earlier cases.

Running JUnit 4 Tests Unchanged #

Vintage provides the lowest-risk first migration step because Jupiter and JUnit 4 use different Java packages and can coexist in one module:

xml
<dependency>
  <groupId>junit</groupId>
  <artifactId>junit</artifactId>
  <version>4.13.2</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.junit.vintage</groupId>
  <artifactId>junit-vintage-engine</artifactId>
  <version>${junit.version}</version>
  <scope>test</scope>
</dependency>

Surefire then discovers Jupiter and JUnit 4 tests through the Platform. Keep Vintage only while old tests remain because a successful Vintage run does not exercise Jupiter’s programming or extension model.

Converting JUnit 4 Tests #

Common source mappings are:

JUnit 4Jupiter
@Before / @After@BeforeEach / @AfterEach
@BeforeClass / @AfterClass@BeforeAll / @AfterAll
@Ignore@Disabled
@Category@Tag
@RunWithOne or more @ExtendWith extensions
@Rule / @ClassRuleAn extension or Jupiter-native fixture
@Test(expected = E.class)assertThrows(E.class, executable)

@RunWith to @ExtendWith is a conceptual mapping rather than a mechanical rename because a JUnit 4 class can have one runner while Jupiter composes multiple extensions.

JUnit 4 places an optional assertion message first:

java
assertEquals("unexpected greeting", expected, actual);

Jupiter places the message last:

java
assertEquals(expected, actual, "unexpected greeting");

Existing org.junit.Assert calls can remain while annotations and lifecycle code move to Jupiter. Converting assertions separately reduces the size of each migration change.

Rules need individual treatment. The junit-jupiter-migrationsupport artifact adapts ExternalResource, Verifier, and ExpectedException rule families; @EnableRuleMigrationSupport activates those adapters. @EnableJUnit4MigrationSupport also recognizes JUnit 4 @Ignore. Other custom rules require a Jupiter extension or a direct fixture rewrite.

A controlled migration therefore follows three stages:

  1. Add Vintage and establish one Platform-based test command.
  2. Convert classes to Jupiter, replacing runners and rules according to their behavior rather than their names.
  3. Remove Vintage, JUnit 4, and migration support after the build contains no JUnit 3 or JUnit 4 tests.

References #

Comments

Read or comment on GitHub (sign-in required to comment)