Your test suite is probably the oldest code in your repository. Production code gets refactored. Tests get appended to.
That’s why JUnit 4 suites survive a decade after JUnit 5 shipped. Nobody budgets time to migrate tests that already pass.
Then a framework upgrade forces the issue. Spring Boot 2.4 dropped the JUnit 4 Vintage engine from spring-boot-starter-test, and suddenly thousands of tests silently stop running. Green build, zero tests executed. We’ve seen that exact failure mode more than once on modernization projects.
The good news: this is one of the most automatable migrations in the Java ecosystem. Most of it is mechanical renames, and OpenRewrite can automate the bulk of it, with caveats we cover below.
This guide covers the architecture change, the full annotation mapping, the runner and rule replacements, the transitional strategy for mixed suites, and where automation runs out.
Everything here is backed by a companion repo: junit4-to-junit5-migration-demo. Its main branch is a small JUnit 4 suite that uses every pattern in the mapping table below, junit5 is the same suite hand-migrated, and openrewrite is the raw, unedited output of the automated recipe. Diff the branches to see the whole migration at once. This is what the entire migration looks like as one diff:

Why This Matters on Modernization Projects
JUnit 4’s last release was 4.13.2 in early 2021. It’s in maintenance mode. Every modern testing tool targets JUnit 5: Testcontainers’ @Testcontainers support, Mockito’s extension model, Spring’s SpringExtension.
The practical forcing functions:
- Spring Boot 2.4+ defaults to JUnit 5 and no longer ships the Vintage engine in
spring-boot-starter-test. If your tests are JUnit 4 and you upgrade past 2.4 without noticing, the build stays green while running nothing. - Spring Boot 3 requires Java 17 and Jakarta namespaces. Teams doing that migration almost always find the test suite is the long pole, and a JUnit 4 suite makes it longer.
- New features only land in Jupiter. Parameterized tests that don’t require a separate class, nested test classes, display names, parallel execution, conditional execution. None of it back-ported.
On the Java 8 to 17 migrations we work on, the test migration usually gets bundled in. Doing it standalone first is often smarter: it de-risks the framework upgrade and it’s independently automatable.
The Architecture Change
JUnit 4 was one jar. junit:junit contained the API you wrote tests against, the runner that executed them, and the launcher your build tool called. That coupling is why extending JUnit 4 meant fighting over the single @RunWith slot.
JUnit 5 split this into three parts:
- JUnit Platform — the foundation. Defines the
TestEngineAPI and the launcher that build tools and IDEs talk to. - JUnit Jupiter — the new programming model. The annotations and assertions you write tests with, plus the engine that runs them.
- JUnit Vintage — a
TestEnginethat runs JUnit 3 and 4 tests on the Platform.
The Platform doesn’t care which engine executes a test. That’s the design that makes gradual migration possible: Jupiter and Vintage tests run side by side in one build.
The Maven dependencies:
<dependencies>
<!-- JUnit 5 (Jupiter): API + engine + params -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.13.4</version>
<scope>test</scope>
</dependency>
<!-- Vintage engine: runs existing JUnit 4 tests
on the JUnit Platform. Remove when migration is done. -->
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<version>5.13.4</version>
<scope>test</scope>
</dependency>
</dependencies>
A note on version numbers: 5.13.x is the final JUnit 5 feature line. JUnit 6 is the current major release (junit-jupiter 6.1.2 at the time of writing); it unifies the Platform, Jupiter, and Vintage version numbers and raises the minimum runtime to Java 17. Nothing in this guide changes either way, because JUnit 4 code migrates to the same Jupiter API in both. If your project already runs on Java 17 or newer, land on 6.x directly; if the JDK upgrade is still ahead of you, land on 5.13.x and treat the 5-to-6 bump as a routine version change later.
If you’re on Spring Boot, spring-boot-starter-test already brings in Jupiter. You only add junit-vintage-engine yourself, and only for the transition.
One build-tool gotcha: Maven Surefire supports the JUnit Platform natively from version 2.22.0. If your project pins an ancient Surefire version, upgrade it first or your Jupiter tests won’t run at all.
The Annotation Mapping
Most of the migration is this table:
| JUnit 4 | JUnit 5 |
|---|---|
@Before | @BeforeEach |
@After | @AfterEach |
@BeforeClass | @BeforeAll |
@AfterClass | @AfterAll |
@Ignore | @Disabled |
@Category | @Tag |
@RunWith | @ExtendWith |
@Rule / @ClassRule | Extensions (case by case) |
@Test(expected = ...) | assertThrows() |
@Test(timeout = ...) | assertTimeout() / assertTimeoutPreemptively() |
Package names change too: org.junit.Test becomes org.junit.jupiter.api.Test, and assertions move from org.junit.Assert to org.junit.jupiter.api.Assertions.
A couple of behavioral notes that trip people up:
@BeforeAlland@AfterAllmethods must bestatic(unless you switch the test instance lifecycle to per-class).- Jupiter test classes and test methods don’t need to be
public. Package-private is idiomatic now. @Test(expected = ...)verified the exception anywhere in the method.assertThrowsscopes it to one lambda, which is stricter and catches bugs the old style hid.
Before and After: A Real Test Class
Here’s a typical JUnit 4 test class:
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Ignore;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
public class OrderServiceTest {
private static PricingTable pricing;
private OrderService service;
@BeforeClass
public static void loadPricing() {
pricing = PricingTable.load();
}
@Before
public void setUp() {
service = new OrderService(pricing);
}
@Test
public void calculatesTotalWithTax() {
assertEquals("total should include 10% tax",
110.0, service.total(100.0), 0.001);
}
@Test(expected = IllegalArgumentException.class)
public void rejectsNegativeAmount() {
service.total(-1.0);
}
@Ignore("flaky until PRICE-142 is fixed")
@Test
public void appliesBulkDiscount() {
// ...
}
}
The same class in JUnit 5:
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class OrderServiceTest {
private static PricingTable pricing;
private OrderService service;
@BeforeAll
static void loadPricing() {
pricing = PricingTable.load();
}
@BeforeEach
void setUp() {
service = new OrderService(pricing);
}
@Test
void calculatesTotalWithTax() {
assertEquals(110.0, service.total(100.0), 0.001,
"total should include 10% tax");
}
@Test
void rejectsNegativeAmount() {
assertThrows(IllegalArgumentException.class,
() -> service.total(-1.0));
}
@Disabled("flaky until PRICE-142 is fixed")
@Test
void appliesBulkDiscount() {
// ...
}
}
Notice the assertion message. In JUnit 4 the message came first; in Jupiter it moves to the last parameter. This is the single most dangerous part of a manual migration: assertEquals("expected", actual) in JUnit 4 style compiles fine in Jupiter but means something different. Automated tooling reorders these correctly. Hand-edits get them wrong.
Jupiter also adds assertAll for grouping related assertions so one failure doesn’t hide the rest:
assertAll("order totals",
() -> assertEquals(110.0, order.total()),
() -> assertEquals(10.0, order.tax()),
() -> assertEquals(100.0, order.subtotal())
);
All three run, and the failure report shows every mismatch, not just the first.
For timeouts, @Test(timeout = 500) becomes assertTimeout(Duration.ofMillis(500), () -> ...), or assertTimeoutPreemptively if you want the old behavior of killing the test thread when time runs out.
Mixed Suites: The Vintage Bridge
You don’t migrate 3,000 tests in one pull request. The realistic strategy:
- Add
junit-jupiterandjunit-vintage-engineto the build. - Write all new tests in Jupiter, starting today.
- Migrate old tests in batches, package by package or module by module, ideally with automation doing the bulk.
- Remove the Vintage engine.
Step 4 is the one teams skip, and it matters more than it looks. As long as Vintage is on the classpath, both APIs are importable, and IDE auto-import will happily pull org.junit.Test into a new test class. You end up with a permanently mixed suite and a migration that never finishes.
So set a deadline for Vintage removal and enforce it. Removing the dependency is the enforcement: anything still on JUnit 4 stops compiling, and the list of compile errors is your remaining work. Some teams add an architecture test or a Checkstyle rule banning org.junit.Assert imports in the meantime. Either way, treat Vintage as scaffolding with a demolition date, not as infrastructure.
Migrating Runners
@RunWith allowed exactly one runner per class, which forced awkward workarounds when you needed both Spring and Mockito. The extension model removes that limit. The common mappings:
MockitoJUnitRunner to MockitoExtension
// JUnit 4
@RunWith(MockitoJUnitRunner.class)
public class PaymentServiceTest {
@Mock
private PaymentGateway gateway;
@InjectMocks
private PaymentService service;
}
// JUnit 5
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {
@Mock
private PaymentGateway gateway;
@InjectMocks
private PaymentService service;
}
@Mock and @InjectMocks work unchanged. One behavior difference: MockitoExtension enables strict stubbing by default, so unused stubs fail the test. That’s a feature. It flushes out dead setup code, though it can produce a wave of UnnecessaryStubbingException failures on first run. Fix the stubs rather than downgrading to lenient mode globally.
SpringRunner to SpringExtension
// JUnit 4
@RunWith(SpringRunner.class)
@SpringBootTest
// JUnit 5
@ExtendWith(SpringExtension.class)
@SpringBootTest
Better yet: in Spring Boot 2.1+, @SpringBootTest, @DataJpaTest, @WebMvcTest and the other test slices already include @ExtendWith(SpringExtension.class) as a meta-annotation. Delete @RunWith(SpringRunner.class) and add nothing. If you’re pairing Spring with Testcontainers, the @Testcontainers extension stacks right alongside, which the runner model never allowed.
Parameterized to @ParameterizedTest
JUnit 4’s Parameterized runner needed a static @Parameters method, constructor injection, and fields. Jupiter collapses all of it into the test method:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
class DiscountCalculatorTest {
@ParameterizedTest
@ValueSource(ints = {1, 5, 9})
void noDiscountBelowTenItems(int quantity) {
assertEquals(0.0, calculator.discount(quantity));
}
@ParameterizedTest
@CsvSource({
"10, 0.05",
"50, 0.10",
"100, 0.15"
})
void tieredDiscount(int quantity, double expectedRate) {
assertEquals(expectedRate, calculator.discount(quantity));
}
@ParameterizedTest
@MethodSource("orderScenarios")
void handlesComplexOrders(Order order, double expected) {
assertEquals(expected, calculator.discountFor(order));
}
static Stream<Arguments> orderScenarios() {
return Stream.of(
Arguments.of(Order.bulk(100), 15.0),
Arguments.of(Order.single(), 0.0)
);
}
}
@ValueSource for one simple argument, @CsvSource for small inline tables, @MethodSource for anything needing real objects. This migration isn’t a rename; it’s a rewrite of each parameterized class. Budget accordingly. The payoff is real, though. In JUnit 4, every parameter set ran every test in the class. In Jupiter, parameterization is per method, so the tests you actually write are the tests that run.
Replacing Common Rules
Rules were JUnit 4’s extension mechanism, and they don’t exist in Jupiter. The two you’ll hit most:
TemporaryFolder becomes @TempDir:
// JUnit 4
@Rule
public TemporaryFolder tempFolder = new TemporaryFolder();
@Test
public void writesReport() throws IOException {
File output = tempFolder.newFile("report.csv");
// ...
}
// JUnit 5
@Test
void writesReport(@TempDir Path tempDir) throws IOException {
Path output = tempDir.resolve("report.csv");
// ...
}
@TempDir injects a fresh directory per test and cleans it up automatically. It also works as a field if multiple tests share setup.
ExpectedException becomes assertThrows:
// JUnit 4
@Rule
public ExpectedException thrown = ExpectedException.none();
@Test
public void rejectsUnknownCurrency() {
thrown.expect(CurrencyException.class);
thrown.expectMessage("unknown currency");
converter.convert("???", 100);
}
// JUnit 5
@Test
void rejectsUnknownCurrency() {
CurrencyException ex = assertThrows(CurrencyException.class,
() -> converter.convert("???", 100));
assertTrue(ex.getMessage().contains("unknown currency"));
}
assertThrows returns the exception, so message and cause checks become plain assertions instead of rule configuration. It reads better and it’s more precise about where the exception must come from.
For custom rules, the answer is a rewrite as a Jupiter extension (BeforeEachCallback, AfterEachCallback, and friends). As a stopgap, the junit-jupiter-migrationsupport module can run some ExternalResource and Verifier based rules via @EnableRuleMigrationSupport, but treat that like Vintage: a bridge, not a destination.
Automating It with OpenRewrite
Everything above is what the changes are. Here’s how to avoid making them by hand.
OpenRewrite ships a JUnit5BestPractices recipe that performs the migration as AST transformations, not regex. It renames annotations, reorders assertion messages correctly, converts expected = to assertThrows, swaps runners for extensions, and replaces TemporaryFolder and ExpectedException.
The command needs no build file changes, but do not use RELEASE as the recipe version, which is the standard advice in most tutorials. When we ran the recipe against the companion repo, every rewrite-testing-frameworks release from 3.3.0 through the current 3.42.1 crashed with an internal AssertThrowsOnLastStatement error while converting an ordinary @Test(expected = ...) test, writing no changes at all. The newest release that completes on that codebase is 3.2.0, so pin it:
mvn org.openrewrite.maven:rewrite-maven-plugin:run \
-Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-testing-frameworks:3.2.0 \
-Drewrite.activeRecipes=org.openrewrite.java.testing.junit5.JUnit5BestPractices
Then review the diff, compile, and run the suite, and expect a manual pass. On our demo suite, 3.2.0 migrated the lifecycle annotations, both runners, and both rules well, but it removed junit:junit without adding any JUnit Jupiter dependency (so its own output could not compile until we added junit-jupiter to the pom by hand), and its parameterized-runner conversion produced a test class with a type error. The raw, unedited output and the crash log are published on the openrewrite branch of the companion repo, with a full account in its RECIPE-RESULTS.md.
The recipe is still worth running. The assertion-message reordering alone justifies it: that’s exactly the change humans get wrong at scale. Just treat the output as a starting diff, not a finished migration: check the pom, compile, and read every hunk.
Gradle users add the org.openrewrite.rewrite plugin with the same recipe and run gradle rewriteRun, with the same version caveat.
What OpenRewrite Can’t Fix
The recipe has known limits, and they define your manual work list:
- Custom runners. OpenRewrite knows the mappings for Mockito, Spring, and Parameterized. Your in-house
@RunWith(DatabaseTestRunner.class)has no mapping. That’s a hand-written Jupiter extension. - Exotic or third-party rules.
TemporaryFolderandExpectedExceptionconvert cleanly. AWireMockRuleor a customExternalResourcesubclass needs its extension-based equivalent, which usually exists (WireMock has one) but requires you to find and wire it. - Reflection-heavy test utilities. Base classes that scan for JUnit 4 annotations, test factories that instantiate runners programmatically, tooling that hooks
RunListener. Recipes transform source patterns; they can’t follow reflective indirection. - Semantic differences. Strict stubbing failures from
MockitoExtension,@Test(expected = ...)tests that were accidentally passing because the exception came from setup code, timing differences inassertTimeoutPreemptively. The migration surfaces these; a human decides what’s a bug.
The realistic shape of the project: one automated pass converts most of the suite, then a manual cleanup of the leftovers, then delete the Vintage engine. For a suite in the low thousands of tests with ordinary Spring and Mockito usage, that’s days of work, not weeks. Custom test infrastructure is what moves the needle toward weeks.
When to Get Help
If your suite is a few hundred tests with standard runners and rules, run the OpenRewrite recipe, fix the stragglers, and you’re done inside a sprint.
The hard cases are the ones where the test suite has its own architecture: custom runners baked into every test class, shared base classes doing reflective setup, thousands of tests across modules that upgrade at different speeds, or a JUnit 5 migration tangled together with a Spring Boot 3 and Java 17 upgrade. That’s where sequencing and automation strategy matter, because the test suite is the safety net for every other change in the modernization.
We do this work regularly. If your test suite is blocking a framework upgrade, reach out and we can walk through your specific situation.
Related Articles
- Simplify Spring Boot Version Migration with OpenRewrite — The same automation approach applied to framework upgrades.
- Integration Testing with Testcontainers — What a modern JUnit 5 integration test setup looks like.
- The Complete Guide to Migrating from Java 8 to Java 17 — The JDK upgrade this migration usually accompanies.
- Java Modernization Services — How we run migrations like this end to end.
Frequently Asked Questions
Can JUnit 4 and JUnit 5 tests run in the same project?
Yes. The junit-vintage-engine runs your existing JUnit 4 tests on the JUnit 5 Platform alongside new Jupiter tests. Both show up in the same test run and the same reports. It is meant as a transitional bridge, not a permanent setup, so plan to remove it once the old tests are migrated.
What replaces @RunWith in JUnit 5?
The @ExtendWith annotation. JUnit 5 replaced the single-runner model with an extension model, so a test class can register multiple extensions instead of being limited to one runner. MockitoJUnitRunner becomes @ExtendWith(MockitoExtension.class) and SpringRunner becomes @ExtendWith(SpringExtension.class), which Spring Boot’s @SpringBootTest already includes.
Does OpenRewrite automate the JUnit 4 to JUnit 5 migration?
OpenRewrite’s JUnit5BestPractices recipe handles most of the mechanical work: annotation renames, assertion argument reordering, ExpectedException and TemporaryFolder replacements, and runner-to-extension swaps. It cannot migrate custom runners, exotic third-party rules, or reflection-heavy test utilities, and recent recipe releases have crashed on common patterns, so pin a known-good version and verify the build file afterward. Expect it to convert the bulk of a suite and leave a manual remainder.
Why did my JUnit 4 tests stop running after a Spring Boot upgrade?
Spring Boot 2.4 removed the junit-vintage-engine from spring-boot-starter-test. Without the Vintage engine on the classpath, the JUnit Platform silently skips JUnit 4 tests, so builds pass with zero tests executed. Add junit-vintage-engine explicitly as a test dependency to run the old tests until they are migrated.
Java Modernization Readiness Assessment
15 questions your team should answer before starting a migration. Takes 10 minutes. Could save you months.