Skip to main content
JUnit · lesson 7 of 7
JUnit advanced Lesson 7 of 7

Migrating from JUnit 4 to JUnit 6

Migrate JUnit 4 tests to JUnit 6 step by step: run both via the Vintage engine, convert rules and expected exceptions, and spot 5-to-6 breaking changes.

By SkillByExample · Published

junit java migration junit4 maven

Plenty of codebases still have JUnit 4 tests: org.junit.Test, @Before, @Rule. JUnit 6 can run them unchanged through the Vintage engine while you convert them one class at a time, and this lesson does exactly that. It then covers the smaller jump from JUnit 5 to 6, where the test code mostly stays the same and the build around it changes. It uses the project and mvn alias from Your First JUnit 6 Test with Maven:

alias mvn='docker run --rm -v "$PWD":/w -v m2:/root/.m2 -w /w maven:3.10.0-eclipse-temurin-21-noble mvn'

Both engines in one build

The JUnit 6 project from lesson 1 already has junit-jupiter. To run JUnit 4 tests as well, add JUnit 4 itself and the Vintage engine (versioned by the JUnit BOM):

<!-- pom.xml: inside <dependencies> -->
    <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>
      <scope>test</scope>
    </dependency>

The class under test totals an invoice and saves it to a file.

// File: src/main/java/Invoice.java
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class Invoice {
    public static int total(int... amounts) {
        int sum = 0;
        for (int a : amounts) {
            if (a < 0) throw new IllegalArgumentException("negative amount: " + a);
            sum += a;
        }
        return sum;
    }

    public static Path save(Path dir, int total) throws IOException {
        return Files.writeString(dir.resolve("invoice.txt"), "TOTAL " + total);
    }
}

A typical JUnit 4 test class: public everywhere, @Before, a TemporaryFolder rule, and @Test(expected = ...):

// File: src/test/java/InvoiceCalcTest.java
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertEquals;

public class InvoiceCalcTest {
    @Rule public TemporaryFolder tmp = new TemporaryFolder();
    private int[] amounts;

    @Before public void setUp() { amounts = new int[] {100, 250}; }

    @Test public void addsAmounts() { assertEquals("sum", 350, Invoice.total(amounts)); }

    @Test(expected = IllegalArgumentException.class)
    public void rejectsNegative() { Invoice.total(100, -5); }

    @Test public void savesFile() throws Exception {
        Path file = Invoice.save(tmp.getRoot().toPath(), 350);
        assertEquals("TOTAL 350", Files.readString(file));
    }
}

And a new test written for JUnit 6, in the same project:

// File: src/test/java/InvoiceTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class InvoiceTest {
    @Test
    void emptyInvoiceIsZero() {
        assertEquals(0, Invoice.total());
    }
}
$ mvn -B -ntp test | grep "Tests run:"
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.270 s -- in InvoiceTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.109 s -- in InvoiceCalcTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0

One mvn test ran both classes: 3 JUnit 4 tests and 1 Jupiter test. Surefire launched the JUnit Platform, and the Platform asked each engine on the class path which tests it recognises. The Jupiter engine took InvoiceTest; the Vintage engine took InvoiceCalcTest, because it uses org.junit.Test. This is what makes a gradual migration possible: nothing has to be converted before the build works on JUnit 6, and new tests can be written in the new style straight away.

The deprecation notice Surefire doesn’t show

Vintage is deprecated in JUnit 6, and it says so, but Surefire’s console output doesn’t show the notice. The console launcher from the parameterized tests lesson does. This time it needs the project’s dependencies too (JUnit 4 itself), so the alias also mounts the m2 volume, and dependency:build-classpath writes the class path to a file:

alias junit='docker run --rm -v "$PWD":/w -v m2:/root/.m2 -w /w maven:3.10.0-eclipse-temurin-21-noble java -jar target/junit-platform-console-standalone-6.1.3.jar'
$ mvn -B -ntp -q dependency:copy -Dartifact=org.junit.platform:junit-platform-console-standalone:6.1.3 -DoutputDirectory=target
$ mvn -B -ntp -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt
$ junit execute -cp "target/classes:target/test-classes:$(cat target/cp.txt)" --select-class=InvoiceCalcTest --disable-banner --disable-ansi-colors --details=tree 2>&1 | sed -e '/DiscoveryIssueNotifier/d' -e '/^Test run finished/,$d'
INFO: TestEngine with ID 'junit-vintage' encountered a non-critical issue during test discovery:

(1) [INFO] The JUnit Vintage engine is deprecated and should only be used temporarily while migrating tests to JUnit Jupiter or another testing framework with native JUnit Platform support.
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
└─ JUnit Vintage ✔
   └─ InvoiceCalcTest ✔
      ├─ addsAmounts ✔
      ├─ rejectsNegative ✔
      └─ savesFile ✔

The Vintage engine reports a discovery issue: deprecated, “should only be used temporarily while migrating”. The sed drops the timestamped logger line above it and the summary after the tree. Deprecated isn’t removed: Vintage still ships in 6.1.3 and ran all three tests under the JUnit Vintage node. But it is a signal that it will go in a future major version, so treat it as scaffolding for the migration, not a permanent setup.

Converting the class

The same class converted to Jupiter. Each change is mechanical:

// File: src/test/java/InvoiceCalcTest.java
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;

class InvoiceCalcTest {
    @TempDir Path tmp;
    private int[] amounts;

    @BeforeEach void setUp() { amounts = new int[] {100, 250}; }

    @Test void addsAmounts() { assertEquals(350, Invoice.total(amounts), "sum"); }

    @Test void rejectsNegative() {
        var e = assertThrows(IllegalArgumentException.class, () -> Invoice.total(100, -5));
        assertEquals("negative amount: -5", e.getMessage());
    }

    @Test void savesFile() throws Exception {
        Path file = Invoice.save(tmp, 350);
        assertEquals("TOTAL 350", Files.readString(file));
    }
}
$ mvn -B -ntp -q test-compile && junit execute -cp "target/classes:target/test-classes:$(cat target/cp.txt)" --select-class=InvoiceCalcTest --disable-banner --disable-ansi-colors --details=tree 2>&1 | sed -e '/DiscoveryIssueNotifier/d' -e '/^Test run finished/,$d'
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│  └─ InvoiceCalcTest ✔
│     ├─ addsAmounts() ✔
│     ├─ rejectsNegative() ✔
│     └─ savesFile() ✔
└─ JUnit Vintage ✔

The three tests moved from the JUnit Vintage node to JUnit Jupiter, and the deprecation notice is gone, since Vintage found nothing to run. What changed, line by line:

JUnit 4JUnit 6
org.junit.Test, org.junit.Assertorg.junit.jupiter.api.Test, Assertions
public class and methodspackage-private is enough
@Before / @After@BeforeEach / @AfterEach
@BeforeClass / @AfterClass@BeforeAll / @AfterAll
@Ignore@Disabled
@Rule TemporaryFolder@TempDir Path
@Test(expected = X.class)assertThrows(X.class, () -> ...)
@Test(timeout = 1000)@Timeout(1)
@RunWith(Parameterized.class)@ParameterizedTest or @ParameterizedClass
@Category@Tag
assertion message firstassertion message last

assertThrows is stricter than expected=: it pins the exception to one call, so an IllegalArgumentException thrown by setup code can’t make the test pass by accident, and it returns the exception so the message can be checked too. Once the last JUnit 4 class is converted, remove junit and junit-vintage-engine from the pom.xml.

The message-argument trap

The last row of that table is the one that bites. In JUnit 4, assertEquals("greeting", "Hello, Ann", greet("Ann")) means message "greeting", expected "Hello, Ann". Swap only the import, and the line still compiles:

// File: src/test/java/GreetingTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class GreetingTest {
    static String greet(String name) { return "Hello, " + name; }

    @Test
    void greetsByName() {
        assertEquals("greeting", "Hello, Ann", greet("Ann"));
    }
}
$ mvn -B -ntp test -Dtest=GreetingTest | grep -E "^\[ERROR\]   "
[ERROR]   GreetingTest.greetsByName:10 Hello, Ann ==> expected: <greeting> but was: <Hello, Ann>

greet("Ann") really does return Hello, Ann, yet the test fails. With three String arguments, Jupiter’s assertEquals(expected, actual, message) read "greeting" as the expected value, "Hello, Ann" as the actual value and the method’s result as the message: hence Hello, Ann ==> expected: <greeting> but was: <Hello, Ann>. When the arguments aren’t all strings the compiler usually catches the swap, so search a migrated codebase specifically for assertions with a string literal first. The fix is to move the message to the end: assertEquals("Hello, Ann", greet("Ann"), "greeting").

JUnit 5 to 6: what breaks around the tests

Code written for JUnit 5 mostly runs on JUnit 6 unchanged; the breaks are in the tooling around it. Two you can trigger in this project: an old Surefire, and the console launcher’s old command syntax. (Calling the plugin goal by its full coordinates runs Surefire 2.22.2 once without editing the pom.xml.)

$ mvn -B -ntp test-compile org.apache.maven.plugins:maven-surefire-plugin:2.22.2:test | grep -E -- "--- surefire|Tests run:|BUILD|NoClassDefFoundError" | sed -n 1,4p
[INFO] --- surefire:2.22.2:test (default-cli) @ junit-demo ---
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0
[ERROR] BUILD FAILURE
[ERROR] java.lang.NoClassDefFoundError: org/junit/platform/commons/util/PreconditionViolationException
$ junit --select-class=InvoiceTest 2>&1 | sed -n 1,3p
Unknown option: '--select-class=InvoiceTest'
Usage: junit [OPTIONS] COMMAND
Launches the JUnit Platform for test discovery and execution.

Surefire 2.22.2 found 0 tests, then its JUnit Platform provider crashed with a NoClassDefFoundError: it was compiled against Platform 1.x internals that JUnit 6 removed. JUnit 6 requires Surefire 3.0 or later, which is why lesson 1 pins 3.6.0 instead of relying on the version Maven picks by default. The same goes for Gradle: use a current version, with useJUnitPlatform().

The console launcher no longer accepts options without a subcommand: --select-class alone is an Unknown option, and the usage line asks for a COMMAND (execute, discover or engines). Any CI script that called it the JUnit 5 way needs execute added.

The rest of the 5 to 6 list, from the JUnit 6 release notes:

  • Java 17 is the minimum. JUnit 6 is compiled for Java 17, so it needs a Java 17 or newer JVM.
  • One version number. Platform, Jupiter and Vintage are all 6.x; JUnit 5 had Platform 1.x next to Jupiter 5.x. Import org.junit:junit-bom and let it set every version.
  • junit-platform-runner is removed, so @RunWith(JUnitPlatform.class) suites no longer compile. Use @Suite from junit-platform-suite, shown in Tags, Filtering, Ordering and Suites.
  • Deprecated APIs are gone, such as the old MethodOrderer.Alphanumeric (use MethodOrderer.MethodName).

Practice

  1. Fix GreetingTest so it passes and still carries the "greeting" message.
Solution
// File: src/test/java/GreetingTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class GreetingTest {
    static String greet(String name) { return "Hello, " + name; }

    @Test
    void greetsByName() {
        assertEquals("Hello, Ann", greet("Ann"), "greeting");
    }
}
$ mvn -B -ntp test -Dtest=GreetingTest | grep -E "Tests run:|BUILD"
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.247 s -- in GreetingTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
  1. Convert this JUnit 4 class to JUnit 6. Run it before and after, and check that the ignored test is still reported as skipped.
// File: src/test/java/ReportLegacyTest.java
import org.junit.BeforeClass;
import org.junit.Ignore;
import org.junit.Test;
import static org.junit.Assert.assertTrue;

public class ReportLegacyTest {
    private static int[] amounts;

    @BeforeClass public static void load() { amounts = new int[] {100, 250}; }

    @Test(timeout = 1000) public void totalIsPositive() { assertTrue(Invoice.total(amounts) > 0); }

    @Ignore("PDF export not written yet")
    @Test public void exportsPdf() { }
}
$ mvn -B -ntp test -Dtest=ReportLegacyTest | grep "Tests run:"
[WARNING] Tests run: 2, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.173 s -- in ReportLegacyTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 1
Solution
// File: src/test/java/ReportLegacyTest.java
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import static org.junit.jupiter.api.Assertions.assertTrue;

class ReportLegacyTest {
    private static int[] amounts;

    @BeforeAll static void load() { amounts = new int[] {100, 250}; }

    @Test @Timeout(1) void totalIsPositive() { assertTrue(Invoice.total(amounts) > 0); }

    @Disabled("PDF export not written yet")
    @Test void exportsPdf() { }
}
$ mvn -B -ntp test -Dtest=ReportLegacyTest | grep "Tests run:"
[WARNING] Tests run: 2, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.205 s -- in ReportLegacyTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 1

The counts are the same before and after (2 run, 1 skipped), which is the check that matters in a migration: the converted class runs exactly the tests the old one did. @BeforeClass became @BeforeAll (still static), timeout = 1000 milliseconds became @Timeout(1) second, and @Ignore became @Disabled with the same reason.

  1. List the test engines the console launcher can see, to check which ones a build would use.
Solution
$ junit engines --disable-banner
junit-jupiter (org.junit.jupiter:junit-jupiter-engine:6.1.3)
junit-platform-suite (org.junit.platform:junit-platform-suite-engine:6.1.3)
junit-vintage (org.junit.vintage:junit-vintage-engine:6.1.3)

The standalone jar bundles three engines: Jupiter, Vintage and the Suite engine. In a Maven build the engines are whatever the test class path holds, so removing junit-vintage-engine from the pom.xml is what finally switches JUnit 4 support off.

Frequently Asked Questions

Can JUnit 4 and JUnit 6 tests run in the same Maven build?
Yes. Add junit:junit 4.13.2 and org.junit.vintage:junit-vintage-engine next to junit-jupiter, and Surefire 3 runs both through the JUnit Platform in one mvn test. The Vintage engine runs the JUnit 4 classes and the Jupiter engine runs the new ones, so tests can be migrated one class at a time.
Is the JUnit Vintage engine deprecated?
Yes. In JUnit 6 the Vintage engine still works but reports a discovery issue saying it is deprecated and should only be used temporarily while migrating to JUnit Jupiter. Surefire's console output doesn't show this notice, but the console launcher does. Treat Vintage as migration scaffolding and remove it once no JUnit 4 tests remain.
What replaces @Rule TemporaryFolder and @Test(expected) in JUnit 6?
A field or parameter annotated @TempDir of type Path replaces the TemporaryFolder rule, and JUnit creates and deletes the directory for each test. @Test(expected = X.class) becomes assertThrows(X.class, () -> code), which pins the exception to one call and returns it so its message can be checked. @Before, @BeforeClass and @Ignore become @BeforeEach, @BeforeAll and @Disabled.
Why does my migrated assertEquals fail with the message as the actual value?
JUnit 4 takes the assertion message as the first argument, but JUnit Jupiter takes it as the last. When all three arguments are strings, the old call still compiles after the import changes, so the message is treated as the expected value. Move the message to the end, for example assertEquals(expected, actual, "message").
What breaks when upgrading from JUnit 5 to JUnit 6?
Most test code runs unchanged. JUnit 6 needs Java 17 or later and Maven Surefire 3.0 or later; Surefire 2.22 finds no tests and fails. Platform and Jupiter now share one version number, junit-platform-runner and deprecated APIs such as MethodOrderer.Alphanumeric are removed, and the console launcher requires a subcommand such as execute.