Close

JUnit 5 - ExtensionContext - Test Instances & Lifecycle

[Last Updated: Jan 5, 2026]

The JUnit 5 extension model provides access to test instances and their lifecycle through the ExtensionContext interface. Understanding test instance management is crucial for extensions that need to interact with test objects, inject dependencies, or control instance initialization.

Need for Test Instance Management

Extensions often need to interact with the actual test object being executed—whether to inject dependencies, modify state, or implement custom initialization logic. Without access to test instances, extensions would be limited to static or context-level operations, missing opportunities for object-oriented test enhancements and dependency management.

Instance Access Methods

Optional<Object> getTestInstance()

Returns the current test instance if available. This provides access to the actual test object being executed, allowing extensions to inspect or modify its state.

Object getRequiredTestInstance()

Returns the current test instance, throwing IllegalStateException if no test instance is available. Use this when your extension absolutely requires a test instance to function.

Optional<TestInstances> getTestInstances()

Get all test instances, ordered from outermost to innermost (useful for @Nested tests).

TestInstances getRequiredTestInstances()

Get the required test instances associated with the current test or container.

Optional<TestInstance.Lifecycle> getTestInstanceLifecycle()

Returns the lifecycle mode for test instances (PER_METHOD or PER_CLASS). Essential for extensions that need to understand whether test instances are shared between test methods or recreated for each method.

Understanding Test Instance Lifecycle

JUnit 5 supports two test instance lifecycles that determine how test objects are created and managed:

  • TestInstance.Lifecycle.PER_METHOD (Default): A new test instance is created for each test method. Instance fields are not shared between tests.
  • TestInstance.Lifecycle.PER_CLASS: A single test instance is shared across all test methods in the class. Instance fields maintain state between tests.

Extensions must be aware of the lifecycle to properly manage state and avoid unintended side effects.

Typical Use Cases

  • Dependency Injection: Inject mock objects, database connections, or service instances into test objects
  • Stateful Extensions: Maintain extension state tied to specific test instances
  • Custom Initialization: Implement alternative object creation strategies (e.g., from DI container)
  • Post-Processing: Modify test instances after creation (e.g., apply proxies, enhance methods)
  • Lifecycle Adaptation: Adjust behavior based on whether instances are shared or per-method

Example

The following example shows a dependency injection extension that provides mock services to test instances. It adapts its behavior based on whether test instances are shared (PER_CLASS) or recreated (PER_METHOD) between tests.

The Interface to be implemented by Tests

package com.logicbig.example;

// Interface for test classes that can accept dependency injection
public interface InjectableTest {
    void setService(MockService service);
}

The Service to be injected

package com.logicbig.example;

 // Mock service class for dependency injection demonstration
public class MockService {
    private final String name;
    private final String id;
    private boolean active;

    public MockService(String name) {
        this.name = name;
        this.id = "SVC-" + System.currentTimeMillis();
        this.active = true;
        System.out.println("[SERVICE] Created: " + name + " (ID: " + id + ")");
    }

    public String getName() {return name;}

    public String getId() {return id;}

    public boolean isActive() {return active;}

    public void execute(String operation) {
        if (active) {
            System.out.println("[SERVICE] Executing: " + operation + " on " + name);
        }
    }

    public void cleanup() {
        active = false;
        System.out.println("[SERVICE] Cleaned up: " + name);
    }
}

Extension Implementation

package com.logicbig.example;

import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import java.util.Optional;

public class DependencyInjectionExtension implements BeforeEachCallback,
        AfterEachCallback, BeforeAllCallback {

    @Override
    public void beforeAll(ExtensionContext context) {
        System.out.println("\n=== BEFORE ALL ===");

        // Check the test instance lifecycle
        Optional<TestInstance.Lifecycle> lifecycle = context.getTestInstanceLifecycle();
        lifecycle.ifPresent(life -> {
            System.out.println("Test Instance Lifecycle: " + life);
            if (life == TestInstance.Lifecycle.PER_CLASS) {
                System.out.println("[INFO] Single instance shared across all test methods");
            } else {
                System.out.println("[INFO] New instance for each test method");
            }
        });
    }

    @Override
    public void beforeEach(ExtensionContext context) {
        System.out.println("\n=== BEFORE EACH ===");

        // Get the test instance (will be present for test methods)
        context.getTestInstance().ifPresent(instance -> {
            System.out.println("Test Instance: " + instance.getClass().getSimpleName());
            System.out.println("Instance Hash: " + System.identityHashCode(instance));

            // Inject a service dependency into the test instance
            if (instance instanceof InjectableTest test) {
                // Create and inject a mock service based on test requirements
                String serviceName = "Service-" + context.getDisplayName();
                MockService service = new MockService(serviceName);
                test.setService(service);

                System.out.println("[INJECTED] Service: " + service.getName());
                System.out.println("[INJECTED] Service ID: " + service.getId());

                // Store service reference for cleanup
                context.getStore(ExtensionContext.Namespace.create(instance))
                       .put("injected-service", service);
            }
        });

        context.getTestInstances().ifPresent(instances -> {
            System.out.println("Test Instances:");
            instances.getAllInstances().forEach(instance ->
                                                        System.out.println(instance.getClass().getSimpleName())
            );
            System.out.println("");
        });
    }

    @Override
    public void afterEach(ExtensionContext context) {
        System.out.println("\n=== AFTER EACH ===");

        // Clean up resources associated with the test instance
        context.getTestInstance().ifPresent(instance -> {
            // Retrieve and clean up the injected service
            Object service = context.getStore(ExtensionContext.Namespace.create(instance))
                                    .remove("injected-service");

            if (service instanceof MockService) {
                MockService mockService = (MockService) service;
                System.out.println("[CLEANUP] Service cleaned up: " + mockService.getName());
                mockService.cleanup();
            }

            System.out.println("Test Instance Hash (cleanup): " + System.identityHashCode(instance));
        });

        // Demonstrate required instance access (fails fast if no instance)
        try {
            Object requiredInstance = context.getRequiredTestInstance();
            System.out.println("[VERIFIED] Required instance available");
        } catch (IllegalStateException e) {
            System.out.println("[WARNING] No test instance available: " + e.getMessage());
        }
    }

}

Test Class

package com.logicbig.example;

import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

@ExtendWith(DependencyInjectionExtension.class)
public class TestInstanceExtensionTest implements InjectableTest {

    private MockService service;

    @Override
    public void setService(MockService service) {
        this.service = service;
    }

    @Test
    void testWithInjectedService1() {
        System.out.println("Executing testWithInjectedService1");
        if (service != null) {
            service.execute("test operation 1");
            System.out.println("Service active: " + service.isActive());
        }
    }

    @Test
    void testWithInjectedService2() {
        System.out.println("Executing testWithInjectedService2");
        if (service != null) {
            service.execute("test operation 2");
            System.out.println("Service active: " + service.isActive());
        }
    }
}

Output

$ mvn test -Dtest=TestInstanceExtensionTest
[INFO] Scanning for projects...
[INFO]
[INFO] -------< com.logicbig.example:junit-5-extension-test-instances >--------
[INFO] Building junit-5-extension-test-instances 1.0-SNAPSHOT
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ junit-5-extension-test-instances ---
[INFO] skip non existing resourceDirectory D:\example-projects\junit-5\junit-5-extension-context\junit-5-extension-test-instances\src\main\resources
[INFO]
[INFO] --- compiler:3.14.1:compile (default-compile) @ junit-5-extension-test-instances ---
[INFO] No sources to compile
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ junit-5-extension-test-instances ---
[INFO] skip non existing resourceDirectory D:\example-projects\junit-5\junit-5-extension-context\junit-5-extension-test-instances\src\test\resources
[INFO]
[INFO] --- compiler:3.14.1:testCompile (default-testCompile) @ junit-5-extension-test-instances ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- surefire:3.5.4:test (default-test) @ junit-5-extension-test-instances ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------

=== BEFORE ALL ===
Test Instance Lifecycle: PER_METHOD
[INFO] New instance for each test method

=== BEFORE EACH ===
Test Instance: TestInstanceExtensionTest
Instance Hash: 1845623216
[SERVICE] Created: Service-testWithInjectedService1() (ID: SVC-1767573086744)
[INJECTED] Service: Service-testWithInjectedService1()
[INJECTED] Service ID: SVC-1767573086744
Test Instances:
TestInstanceExtensionTest

Executing testWithInjectedService1
[SERVICE] Executing: test operation 1 on Service-testWithInjectedService1()
Service active: true

=== AFTER EACH ===
[CLEANUP] Service cleaned up: Service-testWithInjectedService1()
[SERVICE] Cleaned up: Service-testWithInjectedService1()
Test Instance Hash (cleanup): 1845623216
[VERIFIED] Required instance available

=== BEFORE EACH ===
Test Instance: TestInstanceExtensionTest
Instance Hash: 1534755892
[SERVICE] Created: Service-testWithInjectedService2() (ID: SVC-1767573086783)
[INJECTED] Service: Service-testWithInjectedService2()
[INJECTED] Service ID: SVC-1767573086783
Test Instances:
TestInstanceExtensionTest

Executing testWithInjectedService2
[SERVICE] Executing: test operation 2 on Service-testWithInjectedService2()
Service active: true

=== AFTER EACH ===
[CLEANUP] Service cleaned up: Service-testWithInjectedService2()
[SERVICE] Cleaned up: Service-testWithInjectedService2()
Test Instance Hash (cleanup): 1534755892
[VERIFIED] Required instance available
[INFO] +--com.logicbig.example.TestInstanceExtensionTest - 0.069 ss
[INFO] | +-- [OK] testWithInjectedService1 - 0.036 ss
[INFO] | '-- [OK] testWithInjectedService2 - 0.005 ss
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 2.584 s
[INFO] Finished at: 2026-01-05T08:31:26+08:00
[INFO] ------------------------------------------------------------------------

PER_CLASS Lifecycle Test

package com.logicbig.example;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.extension.ExtendWith;

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@ExtendWith(DependencyInjectionExtension.class)
public class PerClassLifecycleTest implements InjectableTest {

    private MockService service;
    private int executionCount = 0;

    @Override
    public void setService(MockService service) {
        this.service = service;
    }

    @Test
    void firstTest() {
        executionCount++;
        System.out.println("Executing firstTest (Execution #" + executionCount + ")");
        if (service != null) {
            service.execute("first operation");
            System.out.println("Service ID retained: " + service.getId());
        }
    }

    @Test
    void secondTest() {
        executionCount++;
        System.out.println("Executing secondTest (Execution #" + executionCount + ")");
        if (service != null) {
            service.execute("second operation");
            System.out.println("Same service instance: " + service.getId());
            System.out.println("State maintained: executionCount = " + executionCount);
        }
    }
}

Output

$ mvn test -Dtest=PerClassLifecycleTest
[INFO] Scanning for projects...
[INFO]
[INFO] -------< com.logicbig.example:junit-5-extension-test-instances >--------
[INFO] Building junit-5-extension-test-instances 1.0-SNAPSHOT
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ junit-5-extension-test-instances ---
[INFO] skip non existing resourceDirectory D:\example-projects\junit-5\junit-5-extension-context\junit-5-extension-test-instances\src\main\resources
[INFO]
[INFO] --- compiler:3.14.1:compile (default-compile) @ junit-5-extension-test-instances ---
[INFO] No sources to compile
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ junit-5-extension-test-instances ---
[INFO] skip non existing resourceDirectory D:\example-projects\junit-5\junit-5-extension-context\junit-5-extension-test-instances\src\test\resources
[INFO]
[INFO] --- compiler:3.14.1:testCompile (default-testCompile) @ junit-5-extension-test-instances ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- surefire:3.5.4:test (default-test) @ junit-5-extension-test-instances ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------

=== BEFORE ALL ===
Test Instance Lifecycle: PER_CLASS
[INFO] Single instance shared across all test methods

=== BEFORE EACH ===
Test Instance: PerClassLifecycleTest
Instance Hash: 170949260
[SERVICE] Created: Service-firstTest() (ID: SVC-1767573077513)
[INJECTED] Service: Service-firstTest()
[INJECTED] Service ID: SVC-1767573077513
Test Instances:
PerClassLifecycleTest

Executing firstTest (Execution #1)
[SERVICE] Executing: first operation on Service-firstTest()
Service ID retained: SVC-1767573077513

=== AFTER EACH ===
[CLEANUP] Service cleaned up: Service-firstTest()
[SERVICE] Cleaned up: Service-firstTest()
Test Instance Hash (cleanup): 170949260
[VERIFIED] Required instance available

=== BEFORE EACH ===
Test Instance: PerClassLifecycleTest
Instance Hash: 170949260
[SERVICE] Created: Service-secondTest() (ID: SVC-1767573077540)
[INJECTED] Service: Service-secondTest()
[INJECTED] Service ID: SVC-1767573077540
Test Instances:
PerClassLifecycleTest

Executing secondTest (Execution #2)
[SERVICE] Executing: second operation on Service-secondTest()
Same service instance: SVC-1767573077540
State maintained: executionCount = 2

=== AFTER EACH ===
[CLEANUP] Service cleaned up: Service-secondTest()
[SERVICE] Cleaned up: Service-secondTest()
Test Instance Hash (cleanup): 170949260
[VERIFIED] Required instance available
[INFO] +--com.logicbig.example.PerClassLifecycleTest - 0.071 ss
[INFO] | +-- [OK] firstTest - 0.025 ss
[INFO] | '-- [OK] secondTest - 0 ss
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 2.972 s
[INFO] Finished at: 2026-01-05T08:31:17+08:00
[INFO] ------------------------------------------------------------------------

Test for getInstances

package com.logicbig.example;

import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

public class TestInstancesTest {

    @ExtendWith(DependencyInjectionExtension.class)
    @Nested
    class NestedTest {
        @Test
        void nestedTest() {
            System.out.println("Executing nestedTest");
        }
    }
}

Output

$ mvn test -Dtest=TestInstancesTest
[INFO] Scanning for projects...
[INFO]
[INFO] -------< com.logicbig.example:junit-5-extension-test-instances >--------
[INFO] Building junit-5-extension-test-instances 1.0-SNAPSHOT
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ junit-5-extension-test-instances ---
[INFO] skip non existing resourceDirectory D:\example-projects\junit-5\junit-5-extension-context\junit-5-extension-test-instances\src\main\resources
[INFO]
[INFO] --- compiler:3.14.1:compile (default-compile) @ junit-5-extension-test-instances ---
[INFO] No sources to compile
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ junit-5-extension-test-instances ---
[INFO] skip non existing resourceDirectory D:\example-projects\junit-5\junit-5-extension-context\junit-5-extension-test-instances\src\test\resources
[INFO]
[INFO] --- compiler:3.14.1:testCompile (default-testCompile) @ junit-5-extension-test-instances ---
[INFO] Recompiling the module because of added or removed source files.
[INFO] Compiling 6 source files with javac [debug target 25] to target\test-classes
[INFO]
[INFO] --- surefire:3.5.4:test (default-test) @ junit-5-extension-test-instances ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------

=== BEFORE ALL ===
Test Instance Lifecycle: PER_METHOD
[INFO] New instance for each test method

=== BEFORE EACH ===
Test Instance: NestedTest
Instance Hash: 1845623216
Test Instances:
TestInstancesTest
NestedTest

Executing nestedTest

=== AFTER EACH ===
Test Instance Hash (cleanup): 1845623216
[VERIFIED] Required instance available
[INFO] +--com.logicbig.example.TestInstancesTest$NestedTest - 0.049 ss
[INFO] | '-- [OK] nestedTest - 0.034 ss
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 4.106 s
[INFO] Finished at: 2026-01-05T08:29:57+08:00
[INFO] ------------------------------------------------------------------------

The outputs demonstrates how the DependencyInjectionExtension successfully manages test instances across different lifecycle modes. In the PER_METHOD tests, each test method receives a newly injected service instance with method-specific configuration, while in the PER_CLASS tests, the same test instance and service are shared across all methods, maintaining state between tests. The extension correctly identifies the lifecycle mode and adapts its behavior accordingly—injecting fresh dependencies for PER_METHOD and reusing initialized ones for PER_CLASS. This shows how extensions can leverage getTestInstance(), getTestInstances() and getTestInstanceLifecycle() to implement sophisticated dependency injection and state management patterns that respect JUnit's instance management policies.

Example Project

Dependencies and Technologies Used:

  • junit-jupiter-engine 6.0.1 (Module "junit-jupiter-engine" of JUnit)
     Version Compatibility: 5.1.0 - 6.0.1Version List
    ×

    Version compatibilities of junit-jupiter-engine with this example:

    • 5.1.0
    • 5.1.1
    • 5.2.0
    • 5.3.0
    • 5.3.1
    • 5.3.2
    • 5.4.0
    • 5.4.1
    • 5.4.2
    • 5.5.0
    • 5.5.1
    • 5.5.2
    • 5.6.0
    • 5.6.1
    • 5.6.2
    • 5.6.3
    • 5.7.0
    • 5.7.1
    • 5.7.2
    • 5.8.0
    • 5.8.1
    • 5.8.2
    • 5.9.0
    • 5.9.1
    • 5.9.2
    • 5.9.3
    • 5.10.0
    • 5.10.1
    • 5.10.2
    • 5.10.3
    • 5.10.4
    • 5.10.5
    • 5.11.0
    • 5.11.1
    • 5.11.2
    • 5.11.3
    • 5.11.4
    • 5.12.0
    • 5.12.1
    • 5.12.2
    • 5.13.0
    • 5.13.1
    • 5.13.2
    • 5.13.3
    • 5.13.4
    • 5.14.0
    • 5.14.1
    • 6.0.0
    • 6.0.1

    Versions in green have been tested.

  • JDK 25
  • Maven 3.9.11

JUnit 5 - Extension Context Test Instances & Lifecycle Select All Download
  • junit-5-extension-test-instances
    • src
      • test
        • java
          • com
            • logicbig
              • example
                • TestInstanceExtensionTest.java

    See Also