1. java
  2. /testing
  3. /junit

Master JUnit 5 for Modern Java Testing

JUnit 5: Modern Java Testing

JUnit 5 represents a fundamental evolution in Java testing, reimagining how developers approach test creation, organization, and execution. After years of serving the Java community with JUnit 4, the framework was completely redesigned to embrace modern Java features and address the growing complexity of contemporary software testing needs.

The Evolution of Java Testing

Testing has evolved significantly since the early days of Java development. What started as simple unit tests has expanded to include integration testing, behavior-driven development, parameterized testing, and complex test orchestration. JUnit 5 was designed to meet these evolving needs while maintaining the simplicity that made JUnit the de facto standard for Java testing.

Why JUnit 5 Matters: The transition from JUnit 4 to JUnit 5 isn't just about new annotations or features—it's about embracing a more flexible, extensible, and powerful testing paradigm. JUnit 5 introduces concepts like dynamic tests, nested test classes, and a powerful extension model that adapts to your testing strategy rather than constraining it.

Table of Contents

  1. Introduction to JUnit 5
  2. JUnit 5 Architecture
  3. Basic Test Structure
  4. Annotations
  5. Assertions
  6. Parameterized Tests
  7. Dynamic Tests
  8. Test Lifecycle
  9. Extensions
  10. Best Practices

Introduction to JUnit 5

JUnit 5 represents a complete architectural overhaul of the JUnit framework, built from the ground up to address the limitations of JUnit 4 while embracing modern Java development practices. This isn't simply an incremental update—it's a fundamental reimagining of what a testing framework should provide in today's development environment.

Understanding the Motivation Behind JUnit 5

The decision to completely rewrite JUnit stemmed from several challenges that became apparent as Java applications grew more complex:

Legacy Constraints: JUnit 4 was constrained by its monolithic architecture and backward compatibility requirements, making it difficult to add new features without breaking existing tests.

Modern Java Support: As Java introduced lambda expressions, streams, and other functional programming features, the testing framework needed to evolve to leverage these capabilities naturally.

Extension Limitations: JUnit 4's extension model was limited, forcing developers to work around the framework rather than extending it elegantly.

Multiple Testing Paradigms: Modern applications require various testing approaches—unit tests, integration tests, parameterized tests, and dynamic tests—that should work seamlessly together.

Key Improvements Over JUnit 4

JUnit 5 addresses these challenges through significant architectural and feature improvements:

  • Modular Architecture: Separated into multiple sub-projects (Platform, Jupiter, Vintage) allowing different parts to evolve independently and supporting multiple testing frameworks on the same platform
  • Java 8+ Features: Native support for lambda expressions, streams, and method references, making tests more concise and expressive
  • Multiple Test Engines: Support for different testing frameworks on the same platform, allowing migration and experimentation without complete rewrites
  • Powerful Extensions: A flexible extension model that allows deep customization of test behavior without framework modification
  • Better Parameterized Tests: More intuitive and powerful parameter sources, including method sources, CSV sources, and custom argument converters

Setting Up JUnit 5 Dependencies

JUnit 5's modular architecture means you need to understand which components to include based on your testing requirements. Unlike JUnit 4's single JAR approach, JUnit 5 allows you to include only the parts you need.

Understanding JUnit 5's Modular Structure

Before adding dependencies, it's important to understand JUnit 5's three-part architecture:

JUnit Platform: The foundation that launches testing frameworks on the JVM JUnit Jupiter: The new programming model and extension model for JUnit 5 JUnit Vintage: Provides backward compatibility for JUnit 3 and 4 tests

Maven Dependencies

Here are the essential dependencies for most JUnit 5 projects:

<dependencies>
    <!-- JUnit 5 API -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-api</artifactId>
        <version>5.9.2</version>
        <scope>test</scope>
    </dependency>
    
    <!-- JUnit 5 Engine -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-engine</artifactId>
        <version>5.9.2</version>
        <scope>test</scope>
    </dependency>
    
    <!-- Parameterized tests -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-params</artifactId>
        <version>5.9.2</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Dependency Breakdown:

JUnit Jupiter API: Contains all the annotations (@Test, @BeforeEach, etc.) and assertion methods you'll use when writing tests. This is what your test code directly depends on.

JUnit Jupiter Engine: The runtime engine that actually executes your tests. While your code doesn't directly reference this, it's required for test execution. Build tools like Maven and IDEs use this to run your tests.

JUnit Jupiter Params: Provides support for parameterized tests with @ParameterizedTest. This is optional but highly recommended for data-driven testing scenarios.

Why This Modular Approach Matters: This separation allows you to write tests against the API without being tied to a specific execution engine. It also enables the platform to support multiple testing frameworks simultaneously.

JUnit 5 Architecture

JUnit 5 consists of three sub-projects:

JUnit Platform

  • Foundation for launching testing frameworks on the JVM
  • Defines TestEngine API for developing testing frameworks
  • Provides Console Launcher and build tool integration

JUnit Jupiter

  • New programming model and extension model for writing tests
  • Includes new annotations, assertions, and assumptions
  • Provides the JUnit Jupiter TestEngine

JUnit Vintage

  • Provides backward compatibility with JUnit 3 and JUnit 4
  • Allows running legacy tests alongside JUnit 5 tests

Basic Test Structure

Understanding how to structure JUnit 5 tests effectively is crucial for creating maintainable and reliable test suites. JUnit 5 promotes clean, readable tests through well-defined lifecycle management and modern Java features.

Anatomy of a JUnit 5 Test Class

Let's examine a properly structured test class to understand JUnit 5's approach to test organization:

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

class CalculatorTest {
    
    private Calculator calculator;
    
    @BeforeEach
    void setUp() {
        calculator = new Calculator();
    }
    
    @Test
    @DisplayName("Addition of two positive numbers")
    void testAddition() {
        // Given
        int a = 5;
        int b = 3;
        
        // When
        int result = calculator.add(a, b);
        
        // Then
        assertEquals(8, result, "5 + 3 should equal 8");
    }
    
    @Test
    void testDivision() {
        assertThrows(ArithmeticException.class, () -> {
            calculator.divide(10, 0);
        }, "Division by zero should throw ArithmeticException");
    }
}

Key Structural Elements Explained:

Class Declaration: Notice that the test class doesn't need to be public in JUnit 5. Package-private visibility is sufficient and reduces boilerplate code.

Instance Variables: The calculator field stores the object under test. It's recreated for each test method, ensuring test isolation.

Setup Method (@BeforeEach): This method runs before each test, creating a fresh Calculator instance. This pattern ensures that tests don't interfere with each other through shared state.

Test Methods (@Test): Each method annotated with @Test represents an individual test case. JUnit 5 doesn't require specific naming patterns (like starting with "test"), but descriptive names improve readability.

Display Names (@DisplayName): This JUnit 5 feature allows you to provide more readable test names that appear in IDE test runners and reports. It's especially useful for complex test scenarios where method names might be limited.

Given-When-Then Structure: This pattern, popularized by Behavior-Driven Development (BDD), makes tests more readable:

  • Given: Sets up the test conditions and input data
  • When: Executes the specific behavior being tested
  • Then: Verifies the expected outcome using assertions

Assertion Methods: JUnit 5's assertion methods provide clear error messages and support lambda expressions for lazy evaluation of error messages.

Test Class Organization

@TestMethodOrder(OrderAnnotation.class)
class UserServiceTest {
    
    @Nested
    @DisplayName("User Creation Tests")
    class UserCreationTests {
        
        @Test
        @Order(1)
        @DisplayName("Should create user with valid data")
        void shouldCreateUserWithValidData() {
            // Test implementation
        }
        
        @Test
        @Order(2)
        @DisplayName("Should throw exception for invalid email")
        void shouldThrowExceptionForInvalidEmail() {
            // Test implementation
        }
    }
    
    @Nested
    @DisplayName("User Retrieval Tests")
    class UserRetrievalTests {
        
        @Test
        void shouldFindUserById() {
            // Test implementation
        }
        
        @Test
        void shouldReturnEmptyForNonExistentUser() {
            // Test implementation
        }
    }
}

Annotations

Core Annotations

class AnnotationExamplesTest {
    
    @BeforeAll
    static void setUpClass() {
        // Executed once before all test methods
        System.out.println("Setting up test class");
    }
    
    @AfterAll
    static void tearDownClass() {
        // Executed once after all test methods
        System.out.println("Tearing down test class");
    }
    
    @BeforeEach
    void setUp() {
        // Executed before each test method
        System.out.println("Setting up test");
    }
    
    @AfterEach
    void tearDown() {
        // Executed after each test method
        System.out.println("Tearing down test");
    }
    
    @Test
    @DisplayName("Test with custom display name")
    @Tag("fast")
    void testWithDisplayName() {
        assertTrue(true);
    }
    
    @Test
    @Disabled("Temporarily disabled")
    void disabledTest() {
        // This test will be skipped
    }
    
    @RepeatedTest(5)
    @DisplayName("Repeated test")
    void repeatedTest(RepetitionInfo repetitionInfo) {
        System.out.println("Execution #" + repetitionInfo.getCurrentRepetition());
    }
    
    @Test
    @Timeout(value = 2, unit = TimeUnit.SECONDS)
    void timeoutTest() throws InterruptedException {
        Thread.sleep(1000); // Should complete within 2 seconds
    }
}

Conditional Test Execution

class ConditionalTestsExample {
    
    @Test
    @EnabledOnOs(OS.LINUX)
    void onlyOnLinux() {
        // Test runs only on Linux
    }
    
    @Test
    @EnabledOnJre(JRE.JAVA_17)
    void onlyOnJava17() {
        // Test runs only on Java 17
    }
    
    @Test
    @EnabledIfSystemProperty(named = "os.arch", matches = ".*64.*")
    void only64BitArchitecture() {
        // Test runs only on 64-bit architecture
    }
    
    @Test
    @EnabledIfEnvironmentVariable(named = "ENV", matches = "staging")
    void onlyInStagingEnvironment() {
        // Test runs only when ENV=staging
    }
    
    @Test
    @EnabledIf("customCondition")
    void enabledIfCustomCondition() {
        // Test runs only if custom condition returns true
    }
    
    boolean customCondition() {
        return System.getProperty("custom.property") != null;
    }
}

Assertions

Basic Assertions

class AssertionExamplesTest {
    
    @Test
    void basicAssertions() {
        // Simple assertions
        assertEquals(2, 1 + 1);
        assertNotEquals(3, 1 + 1);
        assertTrue(1 + 1 == 2);
        assertFalse(1 + 1 == 3);
        assertNull(null);
        assertNotNull("not null");
        
        // String assertions
        String actual = "Hello World";
        assertEquals("Hello World", actual);
        assertTrue(actual.startsWith("Hello"));
        assertTrue(actual.endsWith("World"));
        assertTrue(actual.contains("World"));
    }
    
    @Test
    void arrayAndCollectionAssertions() {
        int[] expected = {1, 2, 3};
        int[] actual = {1, 2, 3};
        
        assertArrayEquals(expected, actual);
        
        List<String> expectedList = Arrays.asList("a", "b", "c");
        List<String> actualList = Arrays.asList("a", "b", "c");
        
        assertEquals(expectedList, actualList);
        assertTrue(actualList.containsAll(expectedList));
    }
    
    @Test
    void exceptionAssertions() {
        // Assert that exception is thrown
        Exception exception = assertThrows(IllegalArgumentException.class, () -> {
            throw new IllegalArgumentException("Invalid argument");
        });
        
        assertEquals("Invalid argument", exception.getMessage());
        
        // Assert that no exception is thrown
        assertDoesNotThrow(() -> {
            // Code that should not throw exception
            Math.sqrt(4);
        });
    }
    
    @Test
    void timeoutAssertions() {
        // Assert that execution completes within timeout
        assertTimeout(Duration.ofSeconds(2), () -> {
            Thread.sleep(1000);
            return "Completed";
        });
        
        // Assert and get result
        String result = assertTimeout(Duration.ofSeconds(2), () -> {
            return "Fast operation";
        });
        
        assertEquals("Fast operation", result);
    }
}

Advanced Assertions

class AdvancedAssertionsTest {
    
    @Test
    void groupedAssertions() {
        Person person = new Person("John", "Doe", 30);
        
        // All assertions are executed, even if some fail
        assertAll("person",
            () -> assertEquals("John", person.getFirstName()),
            () -> assertEquals("Doe", person.getLastName()),
            () -> assertEquals(30, person.getAge())
        );
    }
    
    @Test
    void customAssertions() {
        Person person = new Person("Jane", "Smith", 25);
        
        // Custom assertion method
        assertValidPerson(person);
    }
    
    private void assertValidPerson(Person person) {
        assertAll("Valid person",
            () -> assertNotNull(person.getFirstName(), "First name should not be null"),
            () -> assertNotNull(person.getLastName(), "Last name should not be null"),
            () -> assertTrue(person.getAge() > 0, "Age should be positive"),
            () -> assertTrue(person.getAge() < 150, "Age should be realistic")
        );
    }
    
    @Test
    void assertionsWithSuppliers() {
        // Lazy evaluation of error messages
        assertTrue(isValid(), () -> "Validation failed: " + getExpensiveErrorMessage());
    }
    
    private boolean isValid() {
        return true;
    }
    
    private String getExpensiveErrorMessage() {
        // Expensive operation only executed if assertion fails
        return "Detailed error information";
    }
}

Parameterized Tests

Value Source Parameters

class ParameterizedTestExamples {
    
    @ParameterizedTest
    @ValueSource(strings = {"racecar", "radar", "able was I ere I saw elba"})
    void palindromes(String candidate) {
        assertTrue(isPalindrome(candidate));
    }
    
    @ParameterizedTest
    @ValueSource(ints = {1, 2, 3, 5, 8, 13})
    void fibonacciNumbers(int number) {
        assertTrue(isFibonacci(number));
    }
    
    @ParameterizedTest
    @NullSource
    @EmptySource
    @ValueSource(strings = {" ", "   ", "\t", "\n"})
    void nullEmptyAndBlankStrings(String text) {
        assertTrue(text == null || text.trim().isEmpty());
    }
}

Complex Parameter Sources

class ComplexParameterizedTests {
    
    @ParameterizedTest
    @EnumSource(TimeUnit.class)
    void testWithEnumSource(TimeUnit timeUnit) {
        assertNotNull(timeUnit);
    }
    
    @ParameterizedTest
    @EnumSource(value = TimeUnit.class, names = {"DAYS", "HOURS"})
    void testWithEnumSourceInclude(TimeUnit timeUnit) {
        assertTrue(EnumSet.of(TimeUnit.DAYS, TimeUnit.HOURS).contains(timeUnit));
    }
    
    @ParameterizedTest
    @CsvSource({
        "apple,         1",
        "banana,        2",
        "'lemon, lime', 0xF1"
    })
    void testWithCsvSource(String fruit, int rank) {
        assertNotNull(fruit);
        assertNotEquals(0, rank);
    }
    
    @ParameterizedTest
    @CsvFileSource(resources = "/two-column.csv", numLinesToSkip = 1)
    void testWithCsvFileSource(String country, int reference) {
        assertNotNull(country);
        assertTrue(reference > 0);
    }
    
    @ParameterizedTest
    @MethodSource("stringProvider")
    void testWithMethodSource(String argument) {
        assertNotNull(argument);
    }
    
    static Stream<String> stringProvider() {
        return Stream.of("apple", "banana", "orange");
    }
    
    @ParameterizedTest
    @MethodSource("argumentProvider")
    void testWithMultipleArguments(int number, String text, boolean flag) {
        assertTrue(number > 0);
        assertNotNull(text);
    }
    
    static Stream<Arguments> argumentProvider() {
        return Stream.of(
            Arguments.of(1, "apple", true),
            Arguments.of(2, "banana", false),
            Arguments.of(3, "orange", true)
        );
    }
}

Custom Parameter Converters

class CustomParameterTests {
    
    @ParameterizedTest
    @ValueSource(strings = {"01.01.2020", "31.12.2021", "15.06.2022"})
    void testWithCustomConverter(@ConvertWith(DateConverter.class) LocalDate date) {
        assertNotNull(date);
        assertTrue(date.getYear() >= 2020);
    }
    
    static class DateConverter implements ArgumentConverter {
        @Override
        public Object convert(Object source, ParameterContext context) {
            if (source instanceof String) {
                DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy");
                return LocalDate.parse((String) source, formatter);
            }
            throw new IllegalArgumentException("Cannot convert " + source);
        }
    }
    
    @ParameterizedTest
    @CsvSource({"1,2,3", "4,5,9", "10,20,30"})
    void testWithArgumentsAccessor(ArgumentsAccessor arguments) {
        int first = arguments.getInteger(0);
        int second = arguments.getInteger(1);
        int expected = arguments.getInteger(2);
        
        assertEquals(expected, first + second);
    }
    
    @ParameterizedTest
    @CsvSource({"John,Doe,30", "Jane,Smith,25"})
    void testWithArgumentsAggregator(@AggregateWith(PersonAggregator.class) Person person) {
        assertNotNull(person.getFirstName());
        assertNotNull(person.getLastName());
        assertTrue(person.getAge() > 0);
    }
    
    static class PersonAggregator implements ArgumentsAggregator {
        @Override
        public Object aggregateArguments(ArgumentsAccessor arguments, ParameterContext context) {
            return new Person(
                arguments.getString(0),
                arguments.getString(1),
                arguments.getInteger(2)
            );
        }
    }
}

Dynamic Tests

Creating Dynamic Tests

class DynamicTestsExample {
    
    @TestFactory
    Collection<DynamicTest> dynamicTestsFromCollection() {
        return Arrays.asList(
            DynamicTest.dynamicTest("1st dynamic test", () -> assertTrue(isPrime(2))),
            DynamicTest.dynamicTest("2nd dynamic test", () -> assertTrue(isPrime(3))),
            DynamicTest.dynamicTest("3rd dynamic test", () -> assertTrue(isPrime(5)))
        );
    }
    
    @TestFactory
    Stream<DynamicTest> dynamicTestsFromStream() {
        return Stream.of("apple", "banana", "orange")
            .map(fruit -> DynamicTest.dynamicTest(
                "Test for " + fruit,
                () -> assertNotNull(fruit)
            ));
    }
    
    @TestFactory
    Stream<DynamicTest> dynamicTestsFromGenerator() {
        Iterator<Integer> inputGenerator = Arrays.asList(1, 2, 3, 5, 8, 13, 21).iterator();
        
        return DynamicTest.stream(
            inputGenerator,
            n -> "Fibonacci " + n,
            n -> assertTrue(isFibonacci(n))
        );
    }
    
    @TestFactory
    Stream<DynamicNode> dynamicTestsWithContainers() {
        return Stream.of("A", "B", "C")
            .map(input -> DynamicContainer.dynamicContainer("Container " + input,
                Stream.of(
                    DynamicTest.dynamicTest("not null", () -> assertNotNull(input)),
                    DynamicTest.dynamicTest("not empty", () -> assertFalse(input.isEmpty()))
                )
            ));
    }
    
    private boolean isPrime(int number) {
        if (number < 2) return false;
        for (int i = 2; i <= Math.sqrt(number); i++) {
            if (number % i == 0) return false;
        }
        return true;
    }
    
    private boolean isFibonacci(int number) {
        // Simplified fibonacci check
        return Arrays.asList(1, 2, 3, 5, 8, 13, 21).contains(number);
    }
}

Test Lifecycle

Lifecycle Methods

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TestLifecycleExample {
    
    private static int classCounter = 0;
    private int instanceCounter = 0;
    
    @BeforeAll
    void setUpAll() {
        classCounter++;
        System.out.println("@BeforeAll - executed once per class: " + classCounter);
    }
    
    @BeforeEach
    void setUp() {
        instanceCounter++;
        System.out.println("@BeforeEach - executed before each test: " + instanceCounter);
    }
    
    @Test
    void firstTest() {
        System.out.println("First test executed");
        assertEquals(1, instanceCounter);
    }
    
    @Test
    void secondTest() {
        System.out.println("Second test executed");
        assertEquals(2, instanceCounter);
    }
    
    @AfterEach
    void tearDown() {
        System.out.println("@AfterEach - executed after each test");
    }
    
    @AfterAll
    void tearDownAll() {
        System.out.println("@AfterAll - executed once per class");
    }
}

Test Instance Lifecycle

// Default: PER_METHOD (new instance for each test)
class PerMethodLifecycleTest {
    private int counter = 0;
    
    @Test
    void firstTest() {
        counter++;
        assertEquals(1, counter); // Always 1 because new instance
    }
    
    @Test
    void secondTest() {
        counter++;
        assertEquals(1, counter); // Always 1 because new instance
    }
}

// PER_CLASS: Single instance for all tests
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PerClassLifecycleTest {
    private int counter = 0;
    
    @Test
    void firstTest() {
        counter++;
        assertEquals(1, counter);
    }
    
    @Test
    void secondTest() {
        counter++;
        assertEquals(2, counter); // Shared instance
    }
}

Extensions

Built-in Extensions

class ExtensionExamples {
    
    @Test
    @ExtendWith(TempDirectoryExtension.class)
    void testWithTempDirectory(@TempDir Path tempDir) {
        Path file = tempDir.resolve("test.txt");
        assertFalse(Files.exists(file));
        
        // Create and write to file
        assertDoesNotThrow(() -> {
            Files.write(file, "Hello World".getBytes());
        });
        
        assertTrue(Files.exists(file));
    }
    
    @RegisterExtension
    static MockitoExtension mockitoExtension = new MockitoExtension();
    
    @Mock
    private UserRepository userRepository;
    
    @Test
    void testWithMockito() {
        when(userRepository.findById(1L)).thenReturn(Optional.of(new User("John")));
        
        Optional<User> user = userRepository.findById(1L);
        assertTrue(user.isPresent());
        assertEquals("John", user.get().getName());
    }
}

Custom Extensions

// Custom extension for timing tests
class TimingExtension implements BeforeEachCallback, AfterEachCallback {
    
    private static final Logger logger = LoggerFactory.getLogger(TimingExtension.class);
    private static final String START_TIME = "start time";
    
    @Override
    public void beforeEach(ExtensionContext context) {
        getStore(context).put(START_TIME, System.currentTimeMillis());
    }
    
    @Override
    public void afterEach(ExtensionContext context) {
        Method testMethod = context.getRequiredTestMethod();
        long startTime = getStore(context).remove(START_TIME, long.class);
        long duration = System.currentTimeMillis() - startTime;
        
        logger.info("Method [{}] took {} ms.", testMethod.getName(), duration);
    }
    
    private ExtensionContext.Store getStore(ExtensionContext context) {
        return context.getStore(ExtensionContext.Namespace.create(getClass(), context.getRequiredTestMethod()));
    }
}

// Using custom extension
@ExtendWith(TimingExtension.class)
class TimedTests {
    
    @Test
    void fastTest() throws InterruptedException {
        Thread.sleep(100);
    }
    
    @Test
    void slowTest() throws InterruptedException {
        Thread.sleep(500);
    }
}

Best Practices

Test Organization

// Good: Descriptive test names and organization
class UserServiceTest {
    
    @Nested
    @DisplayName("When creating a new user")
    class WhenCreatingNewUser {
        
        @Test
        @DisplayName("Should create user with valid data")
        void shouldCreateUserWithValidData() {
            // Given
            UserCreateRequest request = new UserCreateRequest("[email protected]", "John", "Doe");
            
            // When
            User user = userService.createUser(request);
            
            // Then
            assertAll(
                () -> assertNotNull(user.getId()),
                () -> assertEquals("[email protected]", user.getEmail()),
                () -> assertEquals("John", user.getFirstName()),
                () -> assertEquals("Doe", user.getLastName())
            );
        }
        
        @Test
        @DisplayName("Should throw exception when email is invalid")
        void shouldThrowExceptionWhenEmailIsInvalid() {
            // Given
            UserCreateRequest request = new UserCreateRequest("invalid-email", "John", "Doe");
            
            // When & Then
            InvalidEmailException exception = assertThrows(
                InvalidEmailException.class,
                () -> userService.createUser(request)
            );
            
            assertEquals("Invalid email format: invalid-email", exception.getMessage());
        }
    }
}

Test Data Management

class TestDataExamples {
    
    // Use test data builders
    @Test
    void testWithBuilder() {
        User user = UserTestDataBuilder.aUser()
            .withEmail("[email protected]")
            .withFirstName("John")
            .withLastName("Doe")
            .build();
            
        assertNotNull(user);
    }
    
    // Use factory methods
    @Test
    void testWithFactory() {
        User user = TestDataFactory.createValidUser();
        Order order = TestDataFactory.createOrderForUser(user);
        
        assertEquals(user.getId(), order.getUserId());
    }
    
    // Use @ParameterizedTest for multiple scenarios
    @ParameterizedTest
    @MethodSource("invalidEmailProvider")
    void shouldRejectInvalidEmails(String invalidEmail) {
        assertThrows(InvalidEmailException.class, () -> {
            new User(invalidEmail, "John", "Doe");
        });
    }
    
    static Stream<String> invalidEmailProvider() {
        return Stream.of(
            "",
            "   ",
            "invalid",
            "@example.com",
            "user@",
            "[email protected]"
        );
    }
}

Performance Testing

class PerformanceTests {
    
    @Test
    @Timeout(value = 2, unit = TimeUnit.SECONDS)
    void shouldCompleteWithinTimeout() {
        // Test that should complete within 2 seconds
        performOperation();
    }
    
    @RepeatedTest(100)
    void shouldBeConsistentAcrossMultipleRuns() {
        // Test that should pass consistently
        assertTrue(isOperationReliable());
    }
    
    @Test
    void shouldHandleLargeDataSet() {
        // Given
        List<Integer> largeDataSet = IntStream.range(0, 100_000)
            .boxed()
            .collect(Collectors.toList());
        
        // When
        long startTime = System.currentTimeMillis();
        List<Integer> result = processLargeDataSet(largeDataSet);
        long duration = System.currentTimeMillis() - startTime;
        
        // Then
        assertEquals(largeDataSet.size(), result.size());
        assertTrue(duration < 1000, "Processing should complete within 1 second");
    }
}

Key Takeaways

  • JUnit 5 provides a modern, flexible testing framework with powerful features
  • Use descriptive test names and organize tests with @Nested classes
  • Leverage parameterized tests to test multiple scenarios efficiently
  • Dynamic tests enable runtime test generation for flexible testing scenarios
  • Extensions provide powerful ways to enhance and customize test behavior
  • Proper test organization and data management improve maintainability
  • Combine JUnit 5 with other testing tools for comprehensive test coverage

JUnit 5 represents the future of Java testing, offering developers the tools needed to write comprehensive, maintainable, and expressive tests that ensure code quality and reliability.