Running JUnit 4 Tests with the JUnit Vintage Engine

JUnit 5 is a collection of components rather than one runner. JUnit Jupiter provides the modern annotation model, while the JUnit Vintage Engine acts as an adapter that lets the JUnit Platform execute JUnit 3 and JUnit 4 tests. That distinction matters when a Java codebase still contains hundreds of valuable regression tests but has already adopted JUnit Platform elsewhere.

Many Sydney and Melbourne engineering teams have exactly this mixture. A service might use JUnit Jupiter for new integration tests while older checkout, pricing, or authentication checks remain in JUnit 4. During an afternoon catch-up, often called an “arvo” check-in in Australian workplaces, the team still wants one dependable view of regression coverage rather than two disconnected test commands.

The purpose is not to pretend the legacy tests have been migrated. It is to keep that coverage executable, visible in continuous integration and establish a controlled route towards newer tests. For an ASX-listed Australian business, reliable regression evidence can support release governance as well as developer feedback, while distributed teams in Brisbane and Perth need reports with unambiguous timestamps.

What the Vintage Engine Does and Does Not Run

The JUnit Platform uses a launcher to discover tests and delegates execution to engines. Jupiter handles tests written with org.junit.jupiter.api.Test and related Jupiter annotations. Vintage handles tests written with the JUnit 4 API, including org.junit.Test, org.junit.Before, org.junit.After, and the usual JUnit 4 runners.

This separation is important when diagnosing a build. Adding a Jupiter dependency does not automatically execute JUnit 4 tests, and adding Vintage does not convert Jupiter annotations into JUnit 4 behaviour. Each engine recognises its own programming model, while the platform presents the results through a common reporting mechanism.

Vintage supports familiar JUnit 4 features such as rules, categories, parameterised runners and test classes extending TestCase. It does not support every custom runner or third-party extension without checking their compatibility. The optional junit-platform-runner serves the opposite direction, allowing Jupiter tests to run through a JUnit 4 runner, and is unnecessary for ordinary Vintage execution.

For small Australian quality teams, this can offer substantial value without buying another testing product. Licence and maintenance costs are commonly considered in Australian dollars, so preserving an existing suite may be more practical than funding a large rewrite. The engine provides compatibility while the team decides which legacy areas deserve migration, replacement or retirement.

Adding the Vintage Engine to Maven

Maven needs the Vintage engine on the test classpath and a Surefire version that supports the JUnit Platform. Using the JUnit BOM keeps the Jupiter, Vintage and platform components aligned. The following is a known-compatible configuration rather than a requirement to remain on these exact versions.

<properties>
    <junit.version>5.11.4</junit.version>
</properties>

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

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.junit.vintage</groupId>
        <artifactId>junit-vintage-engine</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>junit</groupId>
        <artifactId>junit</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.5.2</version>
        </plugin>
    </plugins>
</build>

The junit:junit dependency supplies the JUnit 4 API required to compile existing test sources. The Jupiter dependency can remain when the project uses both styles, or it can be omitted from a Vintage-only migration experiment. After running ./mvnw test, Surefire should select its JUnit Platform provider and execute the JUnit 4 classes through Vintage.

A BOM coordinates dependency versions, but it cannot repair a build that explicitly pins a conflicting platform artifact elsewhere. If the project already manages JUnit dependencies, inspect mvn dependency:tree before adding duplicates. The Vintage engine, platform components and launcher must be compatible, even when most of those versions are managed indirectly.

Enabling the Engine in Gradle

Gradle uses the same dependency arrangement, with useJUnitPlatform() replacing the older JUnit 4 execution model. The JUnit BOM can also provide dependency constraints through Gradle’s platform notation.

def junitVersion = '5.11.4'

dependencies {
    testImplementation platform("org.junit:junit-bom:${junitVersion}")
    testImplementation "org.junit.jupiter:junit-jupiter"
    testImplementation "org.junit.vintage:junit-vintage-engine"
    testImplementation "junit:junit"
}

test {
    useJUnitPlatform()
}

The engine must be available during test execution. Using testImplementation for Vintage keeps it on both the compilation and runtime test classpaths, while junit:junit makes the older annotations visible to the existing sources. A project that uses only JUnit 4 sources can leave out Jupiter entirely.

Running ./gradlew test then gives Gradle one platform configuration for both styles. Existing JUnit 4 rules, runners and lifecycle annotations continue to work under Vintage, and Jupiter tests continue to run under the Jupiter engine. No custom Gradle task is required unless the build needs separate reporting, tags or test selection policies.

Checking Dependencies, Discovery, and Results

Before changing test code, inspect what the build actually resolved. Dependency mistakes account for many first-run failures, while others come from assumptions about how a legacy test should be discovered.

Dependencies worth checking

A missing engine commonly produces an empty report rather than a compilation error. A version mismatch may produce linkage errors, while a scope error can make the test classes unavailable to the runner. These are build configuration failures, not evidence that the assertions are broken.

Symptoms worth tracing

A JUnit 4 test method must still follow the framework’s discovery rules, including being public, returning no value and accepting no arguments. Parameterised tests rely on their JUnit 4 runner, so removing or renaming that runner can silently change what is executed. Do not mechanically replace @BeforeClass with @BeforeAll or swap assertEquals imports while diagnosing engine problems.

Mixed reports also need careful interpretation. Surefire XML, console output and CI dashboards can aggregate Jupiter and Vintage results, but the source of each test still matters when investigating failures. Parallel execution requires particular caution: two engines may target the same database, file or mock service. Start with sequential execution if shared fixtures are involved, then enable concurrency only after the isolation guarantees are clear.

Moving Beyond the Compatibility Layer

Vintage should make migration safer, not make it optional forever. Start by restoring the complete legacy suite to a reliable state, then move one coherent slice at a time, such as validation rules or a single service boundary. Behavioural differences between JUnit 4 and Jupiter deserve explicit attention, particularly around lifecycle timing, exception assertions and extension ordering.

Measure more than the number of migrated tests. Track execution time, flaky retries and defect detection, because a smaller suite can still be valuable if it runs faster and fails more accurately. Australian teams with distributed release schedules can also record CI timestamps in UTC while retaining Australian local time in human-readable reports, avoiding confusion when engineers in different states compare results.

Retirement should have clear technical criteria. The Vintage dependency can be removed when no JUnit 4 sources, runners or annotations remain, and the platform no longer needs a second engine. Until then, keep the compatibility layer explicit, aligned and visible.

The practical takeaway is to treat the JUnit Vintage Engine as a controlled bridge: keep valuable legacy coverage running, make its dependencies predictable, and remove it only when the old tests and their tooling have genuinely disappeared.