The JUnit 5 extension model provides a powerful mechanism for customizing test behavior. One key aspect is state persistence through the Store interface, which allows extensions to store and retrieve data across different phases of test execution.
Need for State Persistence
Extensions often need to maintain state between different lifecycle phases (before/after test execution) or share data across multiple test methods. Without proper state management, extensions would be limited to stateless operations. Since an extension instance is typically a singleton (created once per test class and reused for all test methods), using instance fields for per-test state would cause data to leak between tests. The Store mechanism addresses this need by providing a namespaced, scoped storage system that allows clean separation of state between different test methods while maintaining persistence within each test's lifecycle.
ExtensionContext Store Methods
Store getStore(Namespace namespace)
Retrieves a store associated with the given namespace within the current extension context's scope. This is the most commonly used method for extensions.
Store getStore(StoreScope scope,
Namespace namespace)
Retrieves a store from a specific scope with the given namespace. This allows accessing stores from different levels of the test hierarchy.
Store Interface Key Methods
<V> V get(Object key,
Class<V> requiredType)
Retrieves a value from the store by key with type safety.
<V> Object put(Object key,
V value)
Stores a value associated with the given key.
<V> V remove(Object key,
Class<V> requiredType)
Removes and returns a value from the store.
Understanding StoreScope
JUnit 5 provides different store scopes at various levels of the test hierarchy. The StoreScope enum defines these scopes:
StoreScope.LAUNCHER_SESSION: Store is available throughout the entire launcher session, allowing data sharing across multiple test executions. A Launcher Session is the highest level of test execution scope that spans multiple test classes. It's a entire JVM run.
StoreScope.EXECUTION_REQUEST: Store is available for the duration of the current execution request. An execution request can run one test or multiple tests. A Launcher session can run multiple execution requests.
StoreScope.EXTENSION_CONTEXT: Store is bound to the current extension context lifecycle (default scope when using getStore(Namespace))
These scopes can be accessed using getStore(StoreScope, Namespace) method introduced in JUnit 5.13.0.
Namespace Best Practices
Namespace provides isolation between different extensions. Best practices include:
- Use unique namespace objects for each extension class
- Namespace can be created using
Namespace.create(Object...) with meaningful identifiers
- Include extension class name and purpose in namespace to avoid collisions
- Consider test context when creating namespaces for proper isolation
Cross-Test Data Sharing
Using getStore(StoreScope, Namespace) with appropriate scope allows extensions to share data between different levels of the test hierarchy. For example, using StoreScope.LAUNCHER_SESSION enables data persistence across multiple test classes or even multiple test runs within the same session.
Example
Extension Implementation
package com.logicbig.example;
import org.junit.jupiter.api.extension.*;
import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
import org.junit.jupiter.api.extension.ExtensionContext.Store;
public class CounterExtension implements BeforeEachCallback, AfterEachCallback {
private static final String COUNTER_KEY = "test-counter";
private static final Namespace NAMESPACE = Namespace.create(
CounterExtension.class.getName(),
"counter-state"
);
@Override
public void beforeEach(ExtensionContext context) {
// Using default EXTENSION_CONTEXT scope
Store store = context.getStore(NAMESPACE);
Integer counter = store.get(COUNTER_KEY, Integer.class);
if (counter == null) {
// First test in this context
counter = 100;
System.out.println("Initializing counter: " + counter);
} else {
System.out.println("Retrieved counter: " + counter);
}
// Increment and store
counter++;
store.put(COUNTER_KEY, counter);
System.out.println("Stored counter: " + counter);
}
@Override
public void afterEach(ExtensionContext context) {
Store store = context.getStore(NAMESPACE);
Integer counter = store.get(COUNTER_KEY, Integer.class);
System.out.println("Test completed. Final counter: " + counter);
}
}
Test Class
package com.logicbig.example;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ExtendWith(CounterExtension.class)
public class ExtensionContextStoreTest {
@Test
void testMethod1() {
System.out.println("Executing testMethod1");
// CounterExtension will manage the counter
}
@Test
void testMethod2() {
System.out.println("Executing testMethod2");
// CounterExtension will continue from previous test's counter
}
}
Output$ mvn test -Dtest=ExtensionContextStoreTest [INFO] Scanning for projects... [INFO] [INFO] --------< com.logicbig.example:junit-5-extension-context-store >-------- [INFO] Building junit-5-extension-context-store 1.0-SNAPSHOT [INFO] from pom.xml [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- resources:3.3.1:resources (default-resources) @ junit-5-extension-context-store --- [WARNING] Using platform encoding (Cp1252 actually) to copy filtered resources, i.e. build is platform dependent! [INFO] skip non existing resourceDirectory D:\example-projects\junit-5\junit-5-extension-context\junit-5-extension-context-store\src\main\resources [INFO] [INFO] --- compiler:3.11.0:compile (default-compile) @ junit-5-extension-context-store --- [INFO] No sources to compile [INFO] [INFO] --- resources:3.3.1:testResources (default-testResources) @ junit-5-extension-context-store --- [WARNING] Using platform encoding (Cp1252 actually) to copy filtered resources, i.e. build is platform dependent! [INFO] skip non existing resourceDirectory D:\example-projects\junit-5\junit-5-extension-context\junit-5-extension-context-store\src\test\resources [INFO] [INFO] --- compiler:3.11.0:testCompile (default-testCompile) @ junit-5-extension-context-store --- [INFO] Nothing to compile - all classes are up to date [INFO] [INFO] --- surefire:3.5.0:test (default-test) @ junit-5-extension-context-store --- [INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider [INFO] [INFO] ------------------------------------------------------- [INFO] T E S T S [INFO] ------------------------------------------------------- Initializing counter: 100 Stored counter: 101 Executing testMethod1 Test completed. Final counter: 101 Initializing counter: 100 Stored counter: 101 Executing testMethod2 Test completed. Final counter: 101 [INFO] +--com.logicbig.example.ExtensionContextStoreTest - 0.084 ss [INFO] | +-- [OK] testMethod1 - 0.047 ss [INFO] | '-- [OK] testMethod2 - 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: 3.488 s [INFO] Finished at: 2026-01-02T15:03:57+08:00 [INFO] ------------------------------------------------------------------------
Enhanced Example with StoreScope
package com.logicbig.example;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
import static org.junit.jupiter.api.extension.ExtensionContext.Store;
import static org.junit.jupiter.api.extension.ExtensionContext.StoreScope;
public class SessionCounterExtension implements BeforeEachCallback {
private static final Namespace SESSION_NAMESPACE = Namespace.create(
SessionCounterExtension.class.getName(),
"session-state"
);
@Override
public void beforeEach(ExtensionContext context) {
// Access LAUNCHER_SESSION scope for cross-session persistence
Store sessionStore = context.getStore(StoreScope.LAUNCHER_SESSION, SESSION_NAMESPACE);
Integer sessionCounter = sessionStore.get("session-counter", Integer.class);
if (sessionCounter == null) {
sessionCounter = 1000; // Higher starting value for session scope
System.out.println("Initializing session counter: " + sessionCounter);
} else {
System.out.println("Retrieved session counter: " + sessionCounter);
}
// Increment session counter
sessionCounter++;
sessionStore.put("session-counter", sessionCounter);
System.out.println("Updated session counter: " + sessionCounter);
// Also demonstrate EXECUTION_REQUEST scope
Store requestStore = context.getStore(StoreScope.EXECUTION_REQUEST, SESSION_NAMESPACE);
Integer requestCounter = requestStore.get("request-counter", Integer.class);
if (requestCounter == null) {
requestCounter = 2000; // Different starting value for request scope
System.out.println("initialized request counter: " + requestCounter);
}else{
System.out.println("Retrieved request counter "+requestCounter);
}
requestCounter++;
requestStore.put("request-counter", requestCounter);
System.out.println("Updated request counter: " + requestCounter);
}
}
Test Class
package com.logicbig.example;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ExtendWith(SessionCounterExtension.class)
public class SessionCounterExtensionTest {
@Test
void sessionTest1() {
System.out.println("Executing sessionTest1");
// SessionCounterExtension will use LAUNCHER_SESSION scope
}
@Test
void sessionTest2() {
System.out.println("Executing sessionTest2");
// SessionCounterExtension will continue from session store
}
}
Output$ mvn test -Dtest=SessionCounterExtensionTest [INFO] Scanning for projects... [INFO] [INFO] --------< com.logicbig.example:junit-5-extension-context-store >-------- [INFO] Building junit-5-extension-context-store 1.0-SNAPSHOT [INFO] from pom.xml [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- resources:3.3.1:resources (default-resources) @ junit-5-extension-context-store --- [WARNING] Using platform encoding (Cp1252 actually) to copy filtered resources, i.e. build is platform dependent! [INFO] skip non existing resourceDirectory D:\example-projects\junit-5\junit-5-extension-context\junit-5-extension-context-store\src\main\resources [INFO] [INFO] --- compiler:3.11.0:compile (default-compile) @ junit-5-extension-context-store --- [INFO] No sources to compile [INFO] [INFO] --- resources:3.3.1:testResources (default-testResources) @ junit-5-extension-context-store --- [WARNING] Using platform encoding (Cp1252 actually) to copy filtered resources, i.e. build is platform dependent! [INFO] skip non existing resourceDirectory D:\example-projects\junit-5\junit-5-extension-context\junit-5-extension-context-store\src\test\resources [INFO] [INFO] --- compiler:3.11.0:testCompile (default-testCompile) @ junit-5-extension-context-store --- [INFO] Nothing to compile - all classes are up to date [INFO] [INFO] --- surefire:3.5.0:test (default-test) @ junit-5-extension-context-store --- [INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider [INFO] [INFO] ------------------------------------------------------- [INFO] T E S T S [INFO] ------------------------------------------------------- Initializing session counter: 1000 Updated session counter: 1001 initialized request counter: 2000 Updated request counter: 2001 Executing sessionTest1 Retrieved session counter: 1001 Updated session counter: 1002 Retrieved request counter 2001 Updated request counter: 2002 Executing sessionTest2 [INFO] +--com.logicbig.example.SessionCounterExtensionTest - 0.131 ss [INFO] | +-- [OK] sessionTest1 - 0.068 ss [INFO] | '-- [OK] sessionTest2 - 0.020 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.884 s [INFO] Finished at: 2026-01-02T16:20:19+08:00 [INFO] ------------------------------------------------------------------------
The output demonstrates how extensions can persist state using the ExtensionContext Store. The CounterExtension shows per-test-context state management, while the SessionCounterExtension demonstrates cross-session data sharing using StoreScope. The successful incrementing of counters across different test methods confirms that the Store mechanism effectively maintains state at various scope levels, enabling extensions to share data and maintain persistence throughout different phases of test execution.
Example ProjectDependencies and Technologies Used: - junit-jupiter-engine 6.0.1 (Module "junit-jupiter-engine" of JUnit)
Version Compatibility: 5.13.0 - 6.0.1 Version compatibilities of junit-jupiter-engine with this example:
- 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
|