Every large Java codebase has a graveyard of old JUnit 4 tests β cluttered with @RunWith, @Rule, and Assert.assertEquals imports, written in a style that made sense in 2012 but feels dated today. Migrating them to JUnit 6 is not just a mechanical annotation swap β it is an opportunity to dramatically improve readability, reliability, and maintainability. This guide gives you a systematic migration playbook with exact before/after code for every common JUnit 4 pattern.
Phase 1: Automated Migration β Update Dependencies First
<!-- BEFORE: JUnit 4 -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
<!-- AFTER: JUnit 6 aggregator + Vintage engine for gradual migration -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.1</version>
<scope>test</scope>
</dependency>
<!-- Vintage engine: allows JUnit 4 tests to run on JUnit 6 Platform during migration -->
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<version>6.1.1</version>
<scope>test</scope>
</dependency>
With the Vintage engine in place, your existing JUnit 4 tests continue to run while you migrate class by class. This gradual approach eliminates the risk of a big-bang migration.
Phase 2: Annotation Migration Reference
| JUnit 4 | JUnit 6 | Notes |
|---|---|---|
@Test | @Test | Same name, different import: org.junit.jupiter.api.Test |
@Before | @BeforeEach | Runs before each test method |
@After | @AfterEach | Runs after each test method |
@BeforeClass | @BeforeAll | Must still be static (unless PER_CLASS lifecycle) |
@AfterClass | @AfterAll | Same as above |
@Ignore | @Disabled | Accepts an optional reason string |
@Category | @Tag | String-based, no interface needed |
@RunWith(X.class) | @ExtendWith(X.class) | Multiple extensions allowed |
@Rule | Extension SPI | Each Rule has a JUnit 6 extension equivalent |
@ClassRule | @RegisterExtension static | Static field with extension instance |
Assert.assertEquals | Assertions.assertEquals | New package; expected/actual order same |
Assert.assertThat | assertThat (AssertJ) | Hamcrest still works; AssertJ is preferred |
Phase 3: Code Migration β Complete Before/After Examples
Basic Test Class
// ===== BEFORE: JUnit 4 =====
import org.junit.*;
import static org.junit.Assert.*;
public class OrderServiceTest { // JUnit 4: must be public
private OrderService orderService;
@Before // JUnit 4 annotation
public void setUp() { // must be public
orderService = new OrderService();
}
@Test
public void testOrderCreation() { // must be public, weak name
Order order = orderService.create("[email protected]", 49.99);
assertNotNull(order.getId());
assertEquals("[email protected]", order.getEmail());
}
@Test(expected = IllegalArgumentException.class)
public void testNegativeTotalThrows() {
orderService.create("[email protected]", -1.0);
}
@Ignore("Pending payment gateway setup")
@Test
public void testPaymentIntegration() { }
}
// ===== AFTER: JUnit 6 =====
import org.junit.jupiter.api.*; // New import
import static org.junit.jupiter.api.Assertions.*;
// No 'public' required β JUnit 6 discovers package-private classes
class OrderServiceTest {
private OrderService orderService;
@BeforeEach // JUnit 6 annotation
void setUp() { // no 'public' required
orderService = new OrderService();
}
@Test
@DisplayName("Creating an order with valid data assigns an ID and email")
void creatingOrderWithValidDataAssignsIdAndEmail() {
Order order = orderService.create("[email protected]", 49.99);
assertAll("new order properties",
() -> assertNotNull(order.getId(), "Order must have an assigned ID"),
() -> assertEquals("[email protected]", order.getEmail(), "Email must match")
);
}
@Test
@DisplayName("Creating order with negative total throws IllegalArgumentException")
void creatingOrderWithNegativeTotalThrowsException() {
// assertThrows: cleaner than @Test(expected=...)
assertThrows(IllegalArgumentException.class,
() -> orderService.create("[email protected]", -1.0),
"Negative total must be rejected");
}
@Disabled("Pending payment gateway setup")
@Test
void paymentGatewayIntegrationTest() { }
}
Migrating @Rule: TemporaryFolder
// ===== BEFORE: JUnit 4 TemporaryFolder Rule =====
import org.junit.Rule;
import org.junit.rules.TemporaryFolder;
public class FileProcessorTest {
@Rule
public TemporaryFolder tempFolder = new TemporaryFolder();
@Test
public void processingCsvFileSucceeds() throws IOException {
File csvFile = tempFolder.newFile("data.csv");
// write to csvFile, then test...
}
}
// ===== AFTER: JUnit 6 @TempDir =====
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
class FileProcessorTest {
// @TempDir: JUnit 6 built-in β injected as method parameter or field
@TempDir
Path tempDir; // automatically cleaned up after each test
@Test
@DisplayName("Processing a valid CSV file produces correct output")
void processingValidCsvFileProducesCorrectOutput() throws IOException {
// Create a temp file in the temp directory
Path csvFile = tempDir.resolve("data.csv");
Files.writeString(csvFile, "name,price\nLaptop,999.00\n");
ProcessingResult result = fileProcessor.process(csvFile);
assertEquals(1, result.getRecordCount(),
"One data row in CSV should produce one processed record");
}
}
Migrating @RunWith(MockitoJUnitRunner) to @ExtendWith
// ===== BEFORE: JUnit 4 with Mockito =====
import org.junit.runner.RunWith;
import org.mockito.junit.MockitoJUnitRunner;
@RunWith(MockitoJUnitRunner.class)
public class PaymentServiceTest {
@Mock private PaymentGateway gateway;
@InjectMocks private PaymentService paymentService;
@Test
public void testSuccessfulPayment() {
when(gateway.charge(anyDouble())).thenReturn("AUTH-001");
String result = paymentService.processPayment(99.99);
assertEquals("AUTH-001", result);
}
}
// ===== AFTER: JUnit 6 with Mockito =====
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class) // replaces @RunWith(MockitoJUnitRunner.class)
class PaymentServiceTest {
@Mock private PaymentGateway gateway;
@InjectMocks private PaymentService paymentService;
@Test
@DisplayName("Successful payment returns gateway authorisation code")
void successfulPaymentReturnsAuthorisationCode() {
when(gateway.charge(anyDouble())).thenReturn("AUTH-001");
String authorisationCode = paymentService.processPayment(99.99);
assertEquals("AUTH-001", authorisationCode,
"Payment service must return the gateway's authorisation code");
}
}
Migrating Parameterized Tests
// ===== BEFORE: JUnit 4 Parameterized (verbose, ugly) =====
@RunWith(Parameterized.class)
public class CalculatorParameterizedTest {
@Parameterized.Parameters
public static Collection<Object[]> data() {
return Arrays.asList(new Object[][]{{1, 2, 3}, {10, 20, 30}, {-5, 5, 0}});
}
private int a, b, expected;
public CalculatorParameterizedTest(int a, int b, int expected) {
this.a = a; this.b = b; this.expected = expected;
}
@Test
public void testAddition() {
assertEquals(expected, new Calculator().add(a, b));
}
}
// ===== AFTER: JUnit 6 @ParameterizedTest (clean, readable) =====
class CalculatorParameterizedTest {
@ParameterizedTest(name = "{0} + {1} = {2}")
@CsvSource({"1, 2, 3", "10, 20, 30", "-5, 5, 0"})
@DisplayName("Addition produces correct results")
void additionProducesCorrectResults(int addend1, int addend2, int expected) {
assertEquals(expected, new Calculator().add(addend1, addend2),
addend1 + " + " + addend2 + " should equal " + expected);
}
}
Phase 4: Clean Up Legacy Test Debt
Once you have migrated annotations, use this checklist to clean up accumulated test debt:
- β
Remove
publicmodifiers from test classes and methods - β
Replace
Assert.assertEqualswithAssertions.assertEquals(static import) - β
Replace
@Test(expected = X.class)withassertThrows(X.class, ...) - β
Replace
@Test(timeout = n)with@Timeout(value = n, unit = MILLISECONDS) - β
Replace
Assert.assertThatwith AssertJβsassertThat(...).isEqualTo(...) - β
Add
@DisplayNameto every test with a non-descriptive method name - β
Replace test classes with only one
@Testmethod per class with grouped@Nestedtests - β Remove the Vintage engine dependency once all tests are migrated
Frequently Asked Questions (FAQs)
Q1: Can I migrate tests incrementally or do I need to do it all at once?
Incrementally is the only sane approach. The Vintage engine allows JUnit 4 and JUnit 6 tests to coexist in the same project. Migrate one class or one feature area at a time, typically alongside related production code changes. Remove the Vintage engine dependency only when you have confirmed all test classes have been migrated, which you can verify with a simple package scan for org.junit.Test imports.
Q2: What is the quickest way to find all JUnit 4 tests in a large codebase?
Search for the import: grep -r "import org.junit.Test" src/test/. Every file with this import has at least one JUnit 4 test. For IDEs, use Find Usages on org.junit.Test. IntelliJ IDEA also has a migration refactoring tool under Refactor β Migrate β JUnit that can automatically convert many common patterns in bulk.
Q3: Will Spring Boot integration tests also need changes?
Mostly no. Spring Bootβs @SpringBootTest, @WebMvcTest, @DataJpaTest, and related annotations are JUnit-version-agnostic. The spring-boot-starter-test dependency already handles the JUnit Platform integration. You only need to update the test method annotations (@Test import, @Before β @BeforeEach) within the Spring test classes themselves.
Q4: How do I migrate custom JUnit 4 Rules to JUnit 6?
JUnit 4 Rules map to JUnit 6 extensions. Common built-in equivalents: TemporaryFolder β @TempDir, ExpectedException β assertThrows(), Timeout β @Timeout. For custom Rules, implement the equivalent JUnit 6 extension interfaces (BeforeEachCallback, AfterEachCallback, etc.) as described in JUnit 6 Extensions Model.
Q5: Should I rewrite test logic while migrating, or just change annotations?
Start with annotation-only changes to get tests running on JUnit 6 as quickly as possible. Once migrated and passing, then improve: add @DisplayName, replace magic numbers with named constants, extract Object Mothers, convert @Test(expected=) to assertThrows with message inspection. Mixing migration and cleanup in one pass increases risk and makes code review harder.
See Also
- JUnit 6 vs JUnit 5: Key Differences and Migration Guide
- JUnit 6 Extensions Model: Build Custom Extensions Step-by-Step
- Writing Maintainable Tests in JUnit 6 (Clean Code Principles)
- Parameterized Tests in JUnit 6: All Sources Explained
- JUnit 6 Tutorial: Complete Series Index
Conclusion
Migrating legacy tests to JUnit 6 is a phased investment, not a one-time task. Add the Vintage engine to run old tests immediately. Migrate annotation by annotation, class by class. Use each migration as an opportunity to improve: add display names, replace magic numbers, convert exception tests to assertThrows. Remove the Vintage engine only when the migration is complete. The result is a test suite that is not just modernised but genuinely improved.
Next: Debugging JUnit 6 Tests: Fix Failures Like a Pro β systematic techniques for diagnosing and resolving test failures quickly.