Refactoring Legacy Tests to JUnit 6 (Migration Playbook)

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 4JUnit 6Notes
@Test@TestSame name, different import: org.junit.jupiter.api.Test
@Before@BeforeEachRuns before each test method
@After@AfterEachRuns after each test method
@BeforeClass@BeforeAllMust still be static (unless PER_CLASS lifecycle)
@AfterClass@AfterAllSame as above
@Ignore@DisabledAccepts an optional reason string
@Category@TagString-based, no interface needed
@RunWith(X.class)@ExtendWith(X.class)Multiple extensions allowed
@RuleExtension SPIEach Rule has a JUnit 6 extension equivalent
@ClassRule@RegisterExtension staticStatic field with extension instance
Assert.assertEqualsAssertions.assertEqualsNew package; expected/actual order same
Assert.assertThatassertThat (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 public modifiers from test classes and methods
  • βœ… Replace Assert.assertEquals with Assertions.assertEquals (static import)
  • βœ… Replace @Test(expected = X.class) with assertThrows(X.class, ...)
  • βœ… Replace @Test(timeout = n) with @Timeout(value = n, unit = MILLISECONDS)
  • βœ… Replace Assert.assertThat with AssertJ’s assertThat(...).isEqualTo(...)
  • βœ… Add @DisplayName to every test with a non-descriptive method name
  • βœ… Replace test classes with only one @Test method per class with grouped @Nested tests
  • βœ… 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

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.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.