JUnit · 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
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 4 | JUnit 6 |
|---|---|
org.junit.Test, org.junit.Assert | org.junit.jupiter.api.Test, Assertions |
public class and methods | package-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 first | assertion 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-bomand let it set every version. junit-platform-runneris removed, so@RunWith(JUnitPlatform.class)suites no longer compile. Use@Suitefromjunit-platform-suite, shown in Tags, Filtering, Ordering and Suites.- Deprecated APIs are gone, such as the old
MethodOrderer.Alphanumeric(useMethodOrderer.MethodName).
Practice
- Fix
GreetingTestso 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
- 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.
- 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.