Skip to main content

JUnit 5 Migration Guide

📋 Tracking Issue: Issue #225 - Migrate from JUnit 4 to JUnit 5

Overview

The project is gradually migrating from JUnit 4 to JUnit 5 (Jupiter). Both frameworks can coexist during the transition, allowing us to:

  • ✅ Write new tests in JUnit 5
  • ✅ Keep existing JUnit 4 tests running
  • ✅ Migrate tests incrementally at our own pace
  • ✅ Avoid big-bang migration risks

Current Status

✅ Phase 1 Complete - JUnit 5 Support Enabled

See Issue #225 for detailed progress tracking.

  • Maven Surefire 2.22.2 configured to run both JUnit 4 and JUnit 5 tests
  • JUnit Vintage Engine included for backward compatibility
  • First JUnit 5 tests: ProtoComprehensiveTest and TextProtoLoaderTest

Pilot Tests

The following tests serve as examples for JUnit 5 migration:

  • ProtoComprehensiveTest.java - Comprehensive tests for Proto.java utility
  • TextProtoLoaderTest.java - Comprehensive tests for TextProtoLoader.java utility

These tests demonstrate JUnit 5 best practices and can be used as templates for migrating other tests.

JUnit 4 vs JUnit 5 Comparison

Imports

JUnit 4:

import org.junit.Test;
import org.junit.Before;
import org.junit.After;
import org.junit.BeforeClass;
import org.junit.AfterClass;
import static org.junit.Assert.*;

JUnit 5:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.AfterAll;
import static org.junit.jupiter.api.Assertions.*;

Annotations

JUnit 4JUnit 5Purpose
@Test@TestMark test method
@Before@BeforeEachRun before each test
@After@AfterEachRun after each test
@BeforeClass@BeforeAllRun once before all tests
@AfterClass@AfterAllRun once after all tests
@Ignore@DisabledSkip test
@Test(expected = Exception.class)assertThrows(Exception.class, () -> {...})Expect exception
@Test(timeout = 1000)@Timeout(1) or assertTimeout(...)Timeout

Assertions

JUnit 4:

import static org.junit.Assert.*;

assertEquals(expected, actual);
assertTrue(condition);
assertFalse(condition);
assertNull(object);
assertNotNull(object);
assertSame(expected, actual);
assertArrayEquals(expected, actual);

JUnit 5:

import static org.junit.jupiter.api.Assertions.*;

// Same methods, but with optional message as last parameter
assertEquals(expected, actual);
assertEquals(expected, actual, "Custom message");
assertTrue(condition);
assertFalse(condition);
assertNull(object);
assertNotNull(object);
assertSame(expected, actual);
assertArrayEquals(expected, actual);

// New assertions
assertAll(...); // Group multiple assertions
assertThrows(Exception.class, () -> {...}); // Expect exception
assertTimeout(Duration.ofSeconds(1), () -> {...}); // Timeout

Exception Testing

JUnit 4:

@Test(expected = IllegalArgumentException.class)
public void testInvalidInput() {
myMethod(invalidInput);
}

JUnit 5:

@Test
public void testInvalidInput() {
assertThrows(IllegalArgumentException.class, () -> {
myMethod(invalidInput);
});
}

// Can also verify exception message
@Test
public void testInvalidInputWithMessage() {
Exception exception = assertThrows(IllegalArgumentException.class, () -> {
myMethod(invalidInput);
});

assertEquals("Expected error message", exception.getMessage());
}

Migration Strategy

Phase 1: Enable JUnit 5 Support ✅ COMPLETE

  • Add JUnit 5 dependencies to pom.xml
  • Add JUnit Vintage Engine for JUnit 4 compatibility
  • Upgrade maven-surefire-plugin to 2.22.2
  • Remove JUnit 5 exclusions from tess4j dependency
  • Create pilot tests (ProtoComprehensiveTest, TextProtoLoaderTest)
  • Verify both JUnit 4 and JUnit 5 tests run successfully

Phase 2: Write New Tests in JUnit 5 (Current)

Guidelines for new tests:

  • ✅ Use JUnit 5 for all new test classes
  • ✅ Follow pilot test patterns
  • ✅ Use modern assertions and features
  • ✅ Document migration examples

New tests to write in JUnit 5:

  • Tests for Issue #224 enhancements (Proto utility methods)
  • Tests for Project Metadata feature
  • Tests for any new features going forward

Phase 3: Gradual Migration of Existing Tests (Future)

Priority for migration:

  1. High: Tests that are actively being modified
  2. Medium: Tests for core utilities and frequently changed code
  3. Low: Tests that rarely change and work fine

Migration approach:

  • Migrate tests opportunistically when touching related code
  • No rush to migrate working JUnit 4 tests
  • Focus on new development using JUnit 5

Phase 4: Complete Migration (Long-term)

Once all tests are migrated:

  • Remove JUnit 4 dependency
  • Remove JUnit Vintage Engine
  • Update documentation

Writing New Tests in JUnit 5

Template

package org.codetricks.construction.code.assistant.mypackage;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;

import static org.junit.jupiter.api.Assertions.*;

/**
* JUnit 5 test class for MyClass.
*/
@DisplayName("MyClass Tests")
public class MyClassTest {

private MyClass myClass;

@BeforeEach
public void setUp() {
myClass = new MyClass();
}

@AfterEach
public void tearDown() {
// Cleanup if needed
}

@Test
@DisplayName("Should do something when condition is met")
public void testSomething() {
// Arrange
String input = "test input";

// Act
String result = myClass.doSomething(input);

// Assert
assertNotNull(result);
assertEquals("expected output", result);
}

@Test
@DisplayName("Should throw exception for invalid input")
public void testInvalidInput() {
assertThrows(IllegalArgumentException.class, () -> {
myClass.doSomething(null);
});
}
}

Best Practices

  1. Use @DisplayName for readable test descriptions
  2. Use assertThrows instead of @Test(expected = ...)
  3. Use assertAll to group related assertions
  4. Use parameterized tests for testing multiple inputs
  5. Use nested test classes for better organization

Parameterized Tests Example

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.params.provider.CsvSource;

@ParameterizedTest
@ValueSource(strings = {"A-1", "R-2", "B", "M"})
@DisplayName("Should parse valid IBC codes")
public void testParseValidIbcCodes(String ibcCode) {
IbcOccupancyGroup occupancy = IbcOccupancyGroupUtils.fromIbcCode(ibcCode);
assertNotNull(occupancy);
assertNotEquals(IbcOccupancyGroup.UNKNOWN, occupancy);
}

@ParameterizedTest
@CsvSource({
"R-2, Residential",
"A-1, Assembly",
"B, Business"
})
@DisplayName("Should return correct display names")
public void testDisplayNames(String ibcCode, String expectedCategory) {
IbcOccupancyGroup occupancy = IbcOccupancyGroupUtils.fromIbcCode(ibcCode);
String displayName = IbcOccupancyGroupUtils.getDisplayName(occupancy);
assertTrue(displayName.contains(expectedCategory));
}

Nested Tests Example

import org.junit.jupiter.api.Nested;

@DisplayName("ProjectMetadata Tests")
public class ProjectMetadataTest {

@Nested
@DisplayName("Creation Tests")
class CreationTests {
@Test
@DisplayName("Should create metadata with all fields")
public void testCreateComplete() {
// Test creation
}
}

@Nested
@DisplayName("Update Tests")
class UpdateTests {
@Test
@DisplayName("Should update project name")
public void testUpdateName() {
// Test update
}
}
}

Running Tests

Run All Tests (JUnit 4 + JUnit 5)

export JAVA_HOME=/usr/lib/jvm/temurin-23-jdk-arm64 && mvn test

Run Specific Test Class

# JUnit 5 test
export JAVA_HOME=/usr/lib/jvm/temurin-23-jdk-arm64 && mvn test -Dtest=ProtoComprehensiveTest

# JUnit 4 test (still works!)
export JAVA_HOME=/usr/lib/jvm/temurin-23-jdk-arm64 && mvn test -Dtest=ProtoTest

Run Tests by Pattern

# Run all Proto-related tests
export JAVA_HOME=/usr/lib/jvm/temurin-23-jdk-arm64 && mvn test -Dtest=Proto*Test

# Run all tests in a package
export JAVA_HOME=/usr/lib/jvm/temurin-23-jdk-arm64 && mvn test -Dtest=org.codetricks.construction.code.assistant.io.*Test

Benefits of JUnit 5

1. Better Assertions

// Group multiple assertions - all run even if one fails
assertAll("user properties",
() -> assertEquals("John", user.getFirstName()),
() -> assertEquals("Doe", user.getLastName()),
() -> assertEquals("john.doe@example.com", user.getEmail())
);

2. Improved Exception Testing

// Can verify exception message and cause
Exception exception = assertThrows(IllegalArgumentException.class, () -> {
myMethod(invalidInput);
});

assertTrue(exception.getMessage().contains("expected text"));
assertNotNull(exception.getCause());

3. Parameterized Tests

// Test multiple inputs without code duplication
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 5, 8, 13})
public void testFibonacci(int input) {
assertTrue(isFibonacci(input));
}

4. Dynamic Tests

// Generate tests at runtime
@TestFactory
Stream<DynamicTest> dynamicTests() {
return Stream.of("A-1", "R-2", "B")
.map(code -> dynamicTest("Test " + code, () -> {
assertNotNull(IbcOccupancyGroupUtils.fromIbcCode(code));
}));
}

5. Better Display Names

@DisplayName("ProjectMetadata Service Tests")
public class ProjectMetadataServiceTest {

@Test
@DisplayName("Should create metadata with valid project name")
public void testCreateWithValidName() {
// Test reports show: "Should create metadata with valid project name"
// Instead of: "testCreateWithValidName"
}
}

Troubleshooting

Tests Not Running

If tests don't run, check:

# Verify JUnit 5 dependencies
mvn dependency:tree | grep junit

# Should see:
# - junit:junit:4.13.2 (JUnit 4)
# - org.junit.jupiter:junit-jupiter-api:5.11.3 (JUnit 5)
# - org.junit.vintage:junit-vintage-engine:5.11.3 (JUnit 4 compatibility)

Surefire Version Issues

Ensure maven-surefire-plugin is 2.22.0 or higher:

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
</plugin>

Mixed Test Results

If you see unexpected behavior:

# Clean and rebuild
export JAVA_HOME=/usr/lib/jvm/temurin-23-jdk-arm64 && mvn clean test

# Run with debug output
export JAVA_HOME=/usr/lib/jvm/temurin-23-jdk-arm64 && mvn test -X

Migration Checklist

When migrating a JUnit 4 test to JUnit 5:

  • Update imports (org.junitorg.junit.jupiter.api)
  • Update annotations (@Before@BeforeEach, etc.)
  • Update assertions (Assert.assertEqualsAssertions.assertEquals)
  • Convert @Test(expected = ...) to assertThrows(...)
  • Convert @Test(timeout = ...) to assertTimeout(...) or @Timeout
  • Add @DisplayName for better test reports
  • Consider using parameterized tests for multiple inputs
  • Run tests to verify migration
  • Update any test documentation

Progress Tracking

Track migration progress in Issue #225.

Current Stats:

  • JUnit 5 Tests: 30 tests (ProtoComprehensiveTest: 16, TextProtoLoaderTest: 14)
  • JUnit 4 Tests: ~84 tests (legacy)
  • 📊 Migration Progress: ~26% (30/114 tests)

Technical Background

Why Did We Need to Upgrade Maven Surefire?

Maven Surefire Plugin is the test execution engine for Maven. Our old version (2.12.4) only supported JUnit 4.

VersionJUnit 4JUnit 5Both Together
2.12.4 (old)
2.22.2 (new)

Upgrading to 2.22.2 allows us to run both JUnit 4 and JUnit 5 tests in the same build.

Why Did We Remove JUnit 5 Exclusions from tess4j?

tess4j (Tesseract OCR wrapper) has a transitive dependency that brought in JUnit 5:

tess4j:5.9.0 → pdfbox-tools:3.0.1 → junit-jupiter:5.11.3

Before: We excluded JUnit 5 to avoid conflicts with our JUnit 4-only setup.

Now: We removed the exclusions because:

  • We want JUnit 5 for modern tests
  • Surefire 2.22.2 handles both frameworks
  • JUnit Vintage Engine keeps JUnit 4 tests working

See Issue #225 Technical Details for complete explanation.

See Also