Tag Archives: Java

JUnit 6 Extensions Model: Build Custom Extensions Step-by-Step

The JUnit 6 Extensions Model is one of the most powerful features in the framework. Instead of subclassing a test runner or using test rules (the old JUnit 4 approach), JUnit 6 lets you inject behaviour at precise points in the test lifecycle through clean, composable extension interfaces. This guide walks you through every major extension point with real, complete examples you can drop into your project today.

If you want to understand the architecture behind extensions, see JUnit 6 Architecture Deep Dive first. This guide focuses on practical implementation.

What Is a JUnit 6 Extension?

An extension is a class that implements one or more of JUnit 6’s callback interfaces. JUnit calls these callbacks at specific points in the test lifecycle β€” before the test runs, after it runs, when parameters need to be resolved, when exceptions need to be handled, and more.

Extensions replace everything that required @RunWith, @Rule, and @ClassRule in JUnit 4, but in a cleaner, more composable way.

Registering an Extension

There are three ways to register an extension:

// 1. Declarative: @ExtendWith on the test class or method
@ExtendWith(TimingExtension.class)
class OrderServiceTest { ... }

// 2. Programmatic: @RegisterExtension on a field (allows configuration)
class OrderServiceTest {
    @RegisterExtension
    static DatabaseExtension database = new DatabaseExtension("jdbc:h2:mem:test");
}

// 3. Automatic: via ServiceLoader in META-INF/services
// File: src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension
// Contents: com.example.extensions.TimingExtension

Extension 1: Lifecycle Callbacks β€” Test Timing Logger

import org.junit.jupiter.api.extension.*;
import java.lang.reflect.Method;

/**
 * TimingExtension: logs how long each test method takes to execute.
 * Implements BeforeTestExecutionCallback and AfterTestExecutionCallback
 * to bracket the actual test method execution.
 */
public class TimingExtension
    implements BeforeTestExecutionCallback, AfterTestExecutionCallback {

    // Key used to store start time in the extension context store
    private static final String START_TIME_KEY = "startTime";

    @Override
    public void beforeTestExecution(ExtensionContext context) {
        // Store start time in the extension's namespace store
        // The store is scoped to this extension + this test method
        getStore(context).put(START_TIME_KEY, System.currentTimeMillis());
    }

    @Override
    public void afterTestExecution(ExtensionContext context) {
        // Retrieve the stored start time
        long startTime = getStore(context).remove(START_TIME_KEY, long.class);
        long durationMs = System.currentTimeMillis() - startTime;

        // Get the test method name for the log message
        String testMethodName = context.getRequiredTestMethod().getName();

        System.out.printf("[TimingExtension] %-60s %4d ms%n",
            testMethodName, durationMs);
    }

    // Helper: creates a namespace-scoped store for this extension
    private ExtensionContext.Store getStore(ExtensionContext context) {
        return context.getStore(
            ExtensionContext.Namespace.create(getClass(), context.getRequiredTestMethod())
        );
    }
}

// Usage
@ExtendWith(TimingExtension.class)
class PaymentServiceTest {

    @Test
    void processingPaymentCompletesSuccessfully() throws Exception {
        Thread.sleep(42); // simulates work
        assertTrue(true);
    }
}
[TimingExtension] processingPaymentCompletesSuccessfully                42 ms
Tests run: 1, Failures: 0
Continue reading JUnit 6 Extensions Model: Build Custom Extensions Step-by-Step

Tags and Test Suites in JUnit 6: Organizing Large Test Bases

As a Java project grows, a flat test suite becomes unwieldy. You need to run a quick smoke test in 30 seconds before a commit, a full regression suite overnight, and a subset of integration tests on a staging branch. JUnit 6 Tags and Test Suites are the mechanism that makes all of this possible β€” without changing a single line of test logic.

This guide covers everything: the @Tag annotation, tag inheritance, filtering with Maven and Gradle, building explicit test suites with the Platform Suite Engine, and real-world tagging strategies used in production projects.

What Is a Tag in JUnit 6?

A tag is a string label attached to a test class or test method using the @Tag annotation. Tags are then used at build time to include or exclude groups of tests. They replace JUnit 4’s @Category annotation with a simpler, more flexible string-based system.

Applying @Tag to Tests

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.*;

// @Tag on the class applies to ALL tests in the class
@Tag("unit")
@Tag("order-service")
@DisplayName("OrderService β€” business logic tests")
class OrderServiceTest {

    // Inherits class-level tags: "unit" and "order-service"
    @Test
    @DisplayName("Creating an order sets status to PENDING")
    void creatingOrderSetsPendingStatus() {
        OrderService service = new OrderService();
        Order order = service.createOrder("[email protected]", 49.99);
        assertEquals(OrderStatus.PENDING, order.getStatus());
    }

    // Additional method-level tag: this test has "unit", "order-service", AND "fast"
    @Test
    @Tag("fast")
    @DisplayName("Order total calculation is accurate")
    void orderTotalCalculationIsAccurate() {
        OrderService service = new OrderService();
        Order order = service.createOrder("[email protected]", 99.99);
        assertEquals(99.99, order.getTotal(), 0.001);
    }

    // Tag a slow test so it can be excluded from quick builds
    @Test
    @Tag("slow")
    @DisplayName("Processing 10000 orders completes within time limit")
    void processingLargeVolumeOfOrdersCompletesInTime() {
        // Simulate heavy processing
        long startTime = System.currentTimeMillis();
        for (int orderIndex = 0; orderIndex < 10_000; orderIndex++) {
            new OrderService().createOrder("c" + orderIndex + "@test.com", 1.0);
        }
        long duration = System.currentTimeMillis() - startTime;
        assertTrue(duration < 5000, "10,000 orders should process in under 5 seconds");
    }
}

// Integration test class β€” tagged separately from unit tests
@Tag("integration")
@Tag("database")
@Tag("slow")
class OrderRepositoryIT {

    @Test
    @DisplayName("Saved order can be retrieved by ID")
    void savedOrderCanBeRetrievedById() {
        // hits a real database
    }
}
Continue reading Tags and Test Suites in JUnit 6: Organizing Large Test Bases

Dynamic Tests in JUnit 6 using @TestFactory (Advanced Use Cases)

Dynamic tests in JUnit 6 take parameterized testing a step further: instead of declaring inputs at compile time with annotations, you generate entire test cases β€” with their own names, logic, and assertions β€” at runtime. This is the domain of @TestFactory. If you have ever needed to test against a live API response, a database result set, or a list of files on disk, dynamic tests are the right tool.

@TestFactory vs @ParameterizedTest vs @Test

@Test@ParameterizedTest@TestFactory
Test count known atCompile timeCompile time (annotations)Runtime
Names known atCompile timeCompile timeRuntime (generated dynamically)
Data sourceHardcodedAnnotation-declaredAny Java expression
ReturnsvoidvoidStream / Iterable / Collection of DynamicTest
Lifecycle hooksFullFullNo @BeforeEach/@AfterEach per dynamic test

Basic @TestFactory Example

import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;

class CalculatorDynamicTest {

    // @TestFactory returns a Stream of DynamicTest
    // JUnit 6 runs each DynamicTest as a separate test case
    @TestFactory
    Stream additionDynamicTests() {
        // Each record represents: displayName, addend1, addend2, expectedSum
        record AdditionCase(String name, int a, int b, int expected) {}

        return Stream.of(
            new AdditionCase("positive numbers: 1 + 2 = 3",    1,   2,   3),
            new AdditionCase("negative + positive: -5 + 5 = 0", -5,  5,   0),
            new AdditionCase("zeros: 0 + 0 = 0",               0,   0,   0),
            new AdditionCase("large numbers: 1000 + 2000 = 3000", 1000, 2000, 3000)
        ).map(testCase ->
            // dynamicTest(displayName, executable)
            dynamicTest(testCase.name(), () -> {
                Calculator calc = new Calculator();
                assertEquals(testCase.expected(),
                    calc.add(testCase.a(), testCase.b()),
                    testCase.name());
            })
        );
    }
}
Output:
  βœ” positive numbers: 1 + 2 = 3
  βœ” negative + positive: -5 + 5 = 0
  βœ” zeros: 0 + 0 = 0
  βœ” large numbers: 1000 + 2000 = 3000

Tests run: 4, Failures: 0
Continue reading Dynamic Tests in JUnit 6 using @TestFactory (Advanced Use Cases)

Parameterized Tests in JUnit 6: All Sources Explained with Examples

Parameterized tests are one of the most powerful features in JUnit 6. Instead of writing ten near-identical test methods with different inputs, you write one test method and supply the inputs as a data source. JUnit 6 runs the method once per input row, reports each run individually, and gives you full control over how arguments are provided and displayed. This guide covers every argument source with complete code examples and expected output.

Setup: Adding the Dependency

Parameterized test support is included in junit-jupiter (the aggregator). If you added the aggregator, nothing extra is needed. If you added only junit-jupiter-api, add the params module separately:

<!-- Already included if you use the junit-jupiter aggregator -->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-params</artifactId>
    <version>6.0.0</version>
    <scope>test</scope>
</dependency>

1. @ValueSource β€” Single Values

The simplest source. Supplies a single argument of a primitive or String type per test invocation:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;

class StringValidatorTest {

    // Runs 4 times β€” once for each string in the array
    @ParameterizedTest(name = "''{0}'' should not be blank")
    @ValueSource(strings = {"Hello", "JUnit 6", "Java", "  a  "})
    void nonBlankStringsAreValid(String input) {
        assertFalse(input.isBlank(),
            "'" + input + "' should not be blank");
    }

    // Works with int, long, double, boolean, char, byte, short, float, Class, URI, URL
    @ParameterizedTest(name = "{0} is a positive number")
    @ValueSource(ints = {1, 10, 100, Integer.MAX_VALUE})
    void positiveIntegersAreGreaterThanZero(int number) {
        assertTrue(number > 0);
    }
}
Output:
  βœ” 'Hello' should not be blank
  βœ” 'JUnit 6' should not be blank
  βœ” 'Java' should not be blank
  βœ” '  a  ' should not be blank

2. @NullSource, @EmptySource, @NullAndEmptySource

import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.EmptySource;
import org.junit.jupiter.params.provider.NullAndEmptySource;

class EmailValidatorTest {

    // @NullSource: supplies null as the argument
    @ParameterizedTest
    @NullSource
    void nullEmailIsRejected(String email) {
        assertThrows(IllegalArgumentException.class,
            () -> emailValidator.validate(email));
    }

    // @EmptySource: supplies "" (empty string), empty list, empty array, etc.
    @ParameterizedTest
    @EmptySource
    void emptyEmailIsRejected(String email) {
        assertFalse(emailValidator.isValid(email));
    }

    // @NullAndEmptySource: combines both null AND empty in two invocations
    @ParameterizedTest(name = "blank email ''{0}'' should be invalid")
    @NullAndEmptySource
    void blankEmailIsInvalid(String email) {
        assertFalse(emailValidator.isValid(email));
    }

    // Combine @NullAndEmptySource with @ValueSource for comprehensive edge-case coverage
    @ParameterizedTest
    @NullAndEmptySource
    @ValueSource(strings = {"   ", "t", "n"})
    void blankOrWhitespaceEmailIsInvalid(String email) {
        assertFalse(emailValidator.isValid(email));
    }
}
Continue reading Parameterized Tests in JUnit 6: All Sources Explained with Examples

Structuring Tests with JUnit 6 Nested Tests (Real-World Use Cases)

As your test suite grows, flat test classes become hard to navigate. JUnit 6 Nested Tests β€” using the @Nested annotation β€” let you organise related tests into inner class groups that produce a hierarchical, readable test plan. This guide covers every aspect of nested tests with real-world examples, output, and patterns used in production codebases.

What Are Nested Tests?

A @Nested test class is a non-static inner class inside a JUnit 6 test class. JUnit treats it as a group of tests that share a common context. The result is a tree-shaped test report that reads like a specification:

ShoppingCartTest
  β”œβ”€ WhenCartIsEmpty
  β”‚   β”œβ”€ βœ” checkout throws EmptyCartException
  β”‚   └─ βœ” total is zero
  β”œβ”€ WhenCartHasOneItem
  β”‚   β”œβ”€ βœ” total equals item price
  β”‚   └─ βœ” removing the item empties the cart
  └─ WhenDiscountIsApplied
      β”œβ”€ βœ” valid coupon reduces total by coupon percentage
      └─ βœ” expired coupon throws CouponExpiredException

Basic @Nested Example

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

@DisplayName("ShoppingCart β€” item management and checkout")
class ShoppingCartTest {

    // Shared object under test β€” each test in any nested class gets a fresh instance
    private ShoppingCart cart;

    @BeforeEach
    void createEmptyCart() {
        cart = new ShoppingCart();
    }

    // ----------------------------------------------------------------
    // Nested class: groups tests for the "empty cart" scenario
    // ----------------------------------------------------------------
    @Nested
    @DisplayName("When the cart is empty")
    class WhenCartIsEmpty {

        // Note: outer @BeforeEach runs first, creating an empty cart
        // No additional setup needed here β€” the cart is already empty

        @Test
        @DisplayName("total is zero")
        void totalIsZero() {
            assertEquals(0.0, cart.getTotal(), 0.001);
        }

        @Test
        @DisplayName("checkout throws EmptyCartException")
        void checkoutThrowsEmptyCartException() {
            assertThrows(EmptyCartException.class, () -> cart.checkout(),
                "Checking out an empty cart must throw EmptyCartException");
        }

        @Test
        @DisplayName("item count is zero")
        void itemCountIsZero() {
            assertEquals(0, cart.getItemCount());
        }
    }

    // ----------------------------------------------------------------
    // Nested class: groups tests for the "cart has items" scenario
    // ----------------------------------------------------------------
    @Nested
    @DisplayName("When the cart has items")
    class WhenCartHasItems {

        @BeforeEach
        void addItemsToCart() {
            // Outer @BeforeEach already ran (cart is empty)
            // This inner @BeforeEach adds items for this scenario
            cart.addItem(new CartItem("Java Book",   49.99));
            cart.addItem(new CartItem("JUnit Guide", 29.99));
        }

        @Test
        @DisplayName("total equals sum of item prices")
        void totalEqualsSumOfItemPrices() {
            assertEquals(79.98, cart.getTotal(), 0.001);
        }

        @Test
        @DisplayName("item count reflects number of added items")
        void itemCountReflectsAddedItems() {
            assertEquals(2, cart.getItemCount());
        }

        @Test
        @DisplayName("removing an item decreases total correctly")
        void removingItemDecreasesTotal() {
            cart.removeItem("Java Book");
            assertEquals(29.99, cart.getTotal(), 0.001);
        }

        // ----------------------------------------------------------------
        // Deeply nested class: sub-scenario within "cart has items"
        // ----------------------------------------------------------------
        @Nested
        @DisplayName("And a valid discount coupon is applied")
        class AndValidDiscountCouponApplied {

            @BeforeEach
            void applyValidCoupon() {
                // Outer @BeforeEach (empty cart) ran first
                // Inner @BeforeEach (add items) ran second
                // This @BeforeEach applies a coupon on top
                cart.applyCoupon(new Coupon("SAVE10", 10)); // 10% off
            }

            @Test
            @DisplayName("total is reduced by the coupon percentage")
            void totalIsReducedByCouponPercentage() {
                // 79.98 - 10% = 71.982
                assertEquals(71.98, cart.getTotal(), 0.01);
            }

            @Test
            @DisplayName("coupon is marked as used after checkout")
            void couponIsMarkedAsUsedAfterCheckout() {
                cart.checkout();
                assertTrue(cart.getAppliedCoupon().isUsed());
            }
        }
    }
}
Continue reading Structuring Tests with JUnit 6 Nested Tests (Real-World Use Cases)

JUnit 6 Test Naming Conventions for Readable and Maintainable Tests

Good test names are one of the highest-value investments you can make in a test suite. A test named test1() tells you nothing when it fails at 2am. A test named shouldThrowIllegalArgumentExceptionWhenEmailIsNull tells you exactly what broke, why it matters, and where to look. This guide covers every JUnit 6 test naming convention and strategy β€” from method names and @DisplayName to display name generators and custom naming strategies.

Why Test Naming Matters

Test names serve as living documentation. They communicate:

  • What is being tested (the method or feature)
  • When β€” under what conditions
  • What is expected to happen

A well-named test suite is searchable, self-documenting, and produces build reports that any developer β€” or product owner β€” can read and understand without opening the source code.

Strategy 1: The Should-When Pattern

The most widely adopted Java test naming convention. The method name reads as a sentence: "should [expected behaviour] when [condition]."

class PasswordValidatorTest {

    // Pattern: should[ExpectedBehaviour]When[Condition]
    @Test
    void shouldReturnTrueWhenPasswordMeetsAllRequirements() { }

    @Test
    void shouldReturnFalseWhenPasswordIsShorterThanEightCharacters() { }

    @Test
    void shouldThrowIllegalArgumentExceptionWhenPasswordIsNull() { }

    @Test
    void shouldRequireAtLeastOneUppercaseLetter() { }

    @Test
    void shouldRequireAtLeastOneSpecialCharacter() { }
}

Strategy 2: The Given-When-Then Pattern (BDD Style)

Derived from Behaviour-Driven Development (BDD). Works well in teams that also write Gherkin-style specifications:

class ShoppingCartTest {

    // Pattern: given[Context]_when[Action]_then[ExpectedOutcome]
    // Underscores are allowed in test method names β€” JUnit 6 accepts them
    @Test
    void givenEmptyCart_whenAddingFirstItem_thenCartContainsOneItem() { }

    @Test
    void givenCartWithItems_whenApplyingValidCoupon_thenTotalIsReduced() { }

    @Test
    void givenCartWithItems_whenApplyingExpiredCoupon_thenExceptionIsThrown() { }

    @Test
    void givenCartAtMaxCapacity_whenAddingAnotherItem_thenCartFullExceptionIsThrown() { }
}
Continue reading JUnit 6 Test Naming Conventions for Readable and Maintainable Tests

JUnit 6 Assumptions and Conditional Test Execution Guide

Sometimes a test should not run at all β€” not because it is broken, but because the environment or context makes it irrelevant. JUnit 6 Assumptions let you express these conditions explicitly: if an assumption fails, the test is skipped (aborted), not failed. This is a crucial distinction that keeps your test reports clean and meaningful.

This guide covers all assumption methods, conditional test annotations, and real-world scenarios where each approach shines.

Assumptions vs Assertions: The Key Difference

AssertionAssumption
ClassAssertionsAssumptions
On failureTest FAILSTest is SKIPPED/ABORTED
Use whenVerifying correct behaviourPrecondition for test relevance
Report statusRed ❌ FAILEDYellow ⚠️ ABORTED / SKIPPED

Think of assumptions as: β€œThis test only makes sense if this condition is true. If it isn’t, skip it β€” it’s not a code bug, it’s just irrelevant right now.”

The Assumptions Class

Import all assumption methods with a static import:

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

assumeTrue

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

class DatabaseIntegrationTest {

    @Test
    @DisplayName("Fetch user from live database")
    void fetchUserFromDatabase() {
        // assumeTrue: if the condition is FALSE, this test is SKIPPED (not failed)
        // Use when the test is only relevant in certain environments
        assumeTrue(
            System.getenv("CI_ENV") != null,
            "Skipping: this test only runs in the CI environment"
        );

        // If we reach here, CI_ENV is set β€” proceed with the real test
        User user = userRepository.findById(1L);
        assertNotNull(user, "User with ID 1 should exist in the CI database");
    }

    @Test
    @DisplayName("Windows-only file locking test")
    void windowsFileLockingBehaviour() {
        // Only run this test on Windows
        assumeTrue(
            System.getProperty("os.name").toLowerCase().contains("win"),
            "Skipping: Windows-specific file locking test"
        );

        // Windows-specific assertion
        assertTrue(fileLockingService.isFileLocked("test.dat"));
    }
}

assumeFalse

@Test
@DisplayName("Skip test if running under memory profiler")
void performanceTest() {
    // assumeFalse: test is SKIPPED if the condition is TRUE
    boolean isRunningUnderProfiler = ManagementFactory
        .getRuntimeMXBean().getInputArguments().toString()
        .contains("javaagent");

    assumeFalse(isRunningUnderProfiler,
        "Skipping performance test: a profiler agent is attached");

    // If we get here, no profiler β€” timing is reliable
    long startTime = System.nanoTime();
    dataProcessor.processLargeDataset();
    long durationMs = (System.nanoTime() - startTime) / 1_000_000;

    assertTrue(durationMs < 1000,
        "Processing should complete within 1000 ms, took: " + durationMs + " ms");
}
Continue reading JUnit 6 Assumptions and Conditional Test Execution Guide