The JUnit 5 extension model provides publishing capabilities through the ExtensionContext interface, allowing extensions to publish reports, files, and directories that can be consumed by build tools, IDEs, and CI systems. This enables rich test reporting and artifact generation directly from extensions.
Need for Publishing Capabilities
Extensions often need to share test execution data beyond simple pass/fail results—whether to generate custom reports, export test artifacts, or provide diagnostic information. Without publishing capabilities, extension data would be limited to console output or custom files, missing integration with the broader testing ecosystem.
How Publishing Works in JUnit Ecosystem
The JUnit Platform collects published entries and exposes them to registered listeners. Build tools like Maven Surefire/Gradle convert entries into XML reports and console output. IDEs may display entries in test run details, while CI systems and reporting tools like Allure consume them for enhanced reporting.
ExtensionContext Publishing Methods
Publishing Report Entries
default void publishReportEntry(String key,
String value)
Publishes a key-value pair as a report entry. Useful for structured test metadata that build tools and reporting systems can process.
default void publishReportEntry(String value)
Publishes a value-only report entry. Suitable for simple status messages or unstructured information.
default void publishReportEntry(Map<String,
String> entries)
Publishes multiple key-value pairs as report entries. Efficient for publishing related metadata in a single operation.
Publishing Files
default void publishFile(String name,
MediaType mediaType,
ThrowingConsumer<Path> fileWriter)
Publishes a file with specified name and media type. The fileWriter consumer writes content to the provided path. Build tools may attach these files as build artifacts.
Note that ThrowingConsumer is a functional interface in JUnit Jupiter that mirrors the standard Java Consumer but allows the accept method to throw checked exceptions,
Publishing Directories
default void publishDirectory(String name,
ThrowingConsumer<Path> directoryWriter)
Publishes a directory with specified name. The directoryWriter consumer creates files within the provided directory path. Useful for publishing multiple related artifacts.
MediaType Constants
JUnit provides standard media type constants in the MediaType class, including TEXT_PLAIN, TEXT_PLAIN_UTF_8, APPLICATION_JSON, APPLICATION_OCTET_STREAM, IMAGE_JPEG, and IMAGE_PNG for common file types.
Example: Test Reporting Extension
This example demonstrates a comprehensive reporting extension that publishes various types of test execution data. The extension generates structured report entries, creates log files with test details, and produces artifact directories with test outputs, showing how extensions can enhance test reporting beyond basic JUnit functionality.
Extension Implementation
package com.logicbig.example;
import org.junit.jupiter.api.MediaType;
import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
public class TestReportingExtension implements BeforeEachCallback, AfterTestExecutionCallback {
private static final DateTimeFormatter TIMESTAMP_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
private static final String START_TIME_KEY = "testStartTime";
private static final Namespace NAMESPACE =
Namespace.create(TestReportingExtension.class, "timing");
@Override
public void beforeEach(ExtensionContext context) {
long startTime = System.currentTimeMillis();
context.getStore(NAMESPACE).put(START_TIME_KEY, startTime);
// Publish test start information
context.publishReportEntry("Test Start", LocalDateTime.now().format(TIMESTAMP_FORMATTER));
Map<String, String> testMetadata = new HashMap<>();
testMetadata.put("testClass", context.getRequiredTestClass().getSimpleName());
testMetadata.put("testMethod", context.getRequiredTestMethod().getName());
testMetadata.put("displayName", context.getDisplayName());
context.publishReportEntry(testMetadata);
}
@Override
public void afterTestExecution(ExtensionContext context) {
// Determine test outcome
String status = context.getExecutionException().isPresent() ? "FAILED" : "PASSED";
// 1. Publish key-value report entries
context.publishReportEntry("status", status);
long testDuration;
Long startTime = context.getStore(NAMESPACE).get(START_TIME_KEY, Long.class);
if (startTime != null) {
testDuration = System.currentTimeMillis() - startTime;
context.publishReportEntry("duration_ms", String.valueOf(testDuration));
} else {testDuration = -1;}
// Clean up
context.getStore(NAMESPACE).remove(START_TIME_KEY);
// 2. Publish value-only entry for simple status
context.publishReportEntry("Test execution " + status.toLowerCase());
// 3. Publish multiple entries at once
Map<String, String> executionDetails = new HashMap<>();
executionDetails.put("timestamp", LocalDateTime.now().format(TIMESTAMP_FORMATTER));
if (testDuration != -1) {
executionDetails.put("executionTime", testDuration + " ms");
}
executionDetails.put("thread", Thread.currentThread().getName());
context.getExecutionException().ifPresent(exception -> {
executionDetails.put("exception", exception.getClass().getSimpleName());
executionDetails.put("exceptionMessage", exception.getMessage());
});
context.publishReportEntry(executionDetails);
// 4. Publish a detailed log file
String logFileName = context.getRequiredTestMethod().getName() + "-report.log";
context.publishFile(
logFileName,
MediaType.TEXT_PLAIN,
path -> Files.writeString(path, "test logs content")
);
// 5. Publish an artifact directory with test outputs
String artifactDirName = context.getRequiredTestMethod().getName() + "-artifacts";
context.publishDirectory(
artifactDirName,
dir -> createArtifacts(dir, context, testDuration, status)
);
}
private void createArtifacts(Path directory,
ExtensionContext context,
long duration,
String status)
throws IOException {
Files.createDirectories(directory);
// Create summary file
Path summaryFile = directory.resolve("summary.txt");
Files.writeString(summaryFile,
"Test: " + context.getDisplayName() + "\n" +
"Status: " + status + "\n" +
"Generated: " + LocalDateTime.now().format(TIMESTAMP_FORMATTER) + "\n" +
"Artifact count: 3"
);
System.out.println("[EXTENSION] Artifact directory created: " + directory.getFileName());
System.out.println("[EXTENSION] Files created: summary.txt");
}
}
Report Consumer
Since Maven Surefire does not surface JUnit’s published report entries by default and artifacts in an easily consumable format, we are going to use a TestExecutionListener to capture these events directly from the JUnit Platform. This allows us to observe report metadata and published files without relying on build-tool-specific reporting behavior.
package com.logicbig.example;
import org.junit.platform.engine.reporting.FileEntry;
import org.junit.platform.engine.reporting.ReportEntry;
import org.junit.platform.launcher.TestExecutionListener;
import org.junit.platform.launcher.TestIdentifier;
public class MyReportListener implements TestExecutionListener {
@Override
public void reportingEntryPublished(TestIdentifier testIdentifier,
ReportEntry entry) {
System.out.println(
"[REPORT-ENTRY-LISTENER] " +
testIdentifier.getDisplayName() +
" -> " +
entry.getKeyValuePairs()
);
}
@Override
public void fileEntryPublished(TestIdentifier testIdentifier,
FileEntry fileEntry) {
System.out.println(
"[REPORT-FILE-ENTRY-LISTENER] " +
testIdentifier.getDisplayName() +
" -> " +
fileEntry.toString()
);
}
}
Registering the listener
src/test/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListenercom.logicbig.example.MyReportListener
Test Class Using the Extension
package com.logicbig.example;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.junit.jupiter.api.Assertions.assertTrue;
@ExtendWith(TestReportingExtension.class)
public class PerformanceReportingTest {
@Test
void performanceTest() {
System.out.println("[TEST] Executing performance test");
assertTrue(true);
}
@Test
void memoryUsageTest() {
System.out.println("[TEST] Executing memory usage test");
assertTrue(true);
}
}
Output$ mvn test -Dtest=PerformanceReportingTest [INFO] Scanning for projects... [INFO] [INFO] ---------< com.logicbig.example:junit-5-extension-publishing >---------- [INFO] Building junit-5-extension-publishing 1.0-SNAPSHOT [INFO] from pom.xml [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- resources:3.3.1:resources (default-resources) @ junit-5-extension-publishing --- [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-publishing\src\main\resources [INFO] [INFO] --- compiler:3.11.0:compile (default-compile) @ junit-5-extension-publishing --- [INFO] No sources to compile [INFO] [INFO] --- resources:3.3.1:testResources (default-testResources) @ junit-5-extension-publishing --- [WARNING] Using platform encoding (Cp1252 actually) to copy filtered resources, i.e. build is platform dependent! [INFO] Copying 1 resource from src\test\resources to target\test-classes [INFO] [INFO] --- compiler:3.11.0:testCompile (default-testCompile) @ junit-5-extension-publishing --- [INFO] Changes detected - recompiling the module! :source [INFO] Compiling 3 source files with javac [debug target 17] to target\test-classes [INFO] [INFO] --- surefire:3.5.0:test (default-test) @ junit-5-extension-publishing --- [INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider [INFO] [INFO] ------------------------------------------------------- [INFO] T E S T S [INFO] ------------------------------------------------------- [REPORT-ENTRY-LISTENER] memoryUsageTest() -> {Test Start=2026-01-04 14:29:46.273} [REPORT-ENTRY-LISTENER] memoryUsageTest() -> {displayName=memoryUsageTest(), testMethod=memoryUsageTest, testClass=PerformanceReportingTest} [TEST] Executing memory usage test [REPORT-ENTRY-LISTENER] memoryUsageTest() -> {status=PASSED} [REPORT-ENTRY-LISTENER] memoryUsageTest() -> {duration_ms=43} [REPORT-ENTRY-LISTENER] memoryUsageTest() -> {value=Test execution passed} [REPORT-ENTRY-LISTENER] memoryUsageTest() -> {executionTime=43 ms, thread=main, timestamp=2026-01-04 14:29:46.311} [REPORT-FILE-ENTRY-LISTENER] memoryUsageTest() -> FileEntry [timestamp = 2026-01-04T14:29:46.354214600, path = D:\example-projects\junit-5\junit-5-extension-context\junit-5-extension-publishing\target\junit-jupiter\com.logicbig.example.PerformanceReportingTest\memoryUsageTest()\memoryUsageTest-report.log, mediaType = 'text/plain'] [EXTENSION] Artifact directory created: memoryUsageTest-artifacts [EXTENSION] Files created: summary.txt [REPORT-FILE-ENTRY-LISTENER] memoryUsageTest() -> FileEntry [timestamp = 2026-01-04T14:29:46.368595800, path = D:\example-projects\junit-5\junit-5-extension-context\junit-5-extension-publishing\target\junit-jupiter\com.logicbig.example.PerformanceReportingTest\memoryUsageTest()\memoryUsageTest-artifacts] [REPORT-ENTRY-LISTENER] performanceTest() -> {Test Start=2026-01-04 14:29:46.384} [REPORT-ENTRY-LISTENER] performanceTest() -> {displayName=performanceTest(), testMethod=performanceTest, testClass=PerformanceReportingTest} [TEST] Executing performance test [REPORT-ENTRY-LISTENER] performanceTest() -> {status=PASSED} [REPORT-ENTRY-LISTENER] performanceTest() -> {duration_ms=1} [REPORT-ENTRY-LISTENER] performanceTest() -> {value=Test execution passed} [REPORT-ENTRY-LISTENER] performanceTest() -> {executionTime=1 ms, thread=main, timestamp=2026-01-04 14:29:46.386} [REPORT-FILE-ENTRY-LISTENER] performanceTest() -> FileEntry [timestamp = 2026-01-04T14:29:46.387732600, path = D:\example-projects\junit-5\junit-5-extension-context\junit-5-extension-publishing\target\junit-jupiter\com.logicbig.example.PerformanceReportingTest\performanceTest()\performanceTest-report.log, mediaType = 'text/plain'] [EXTENSION] Artifact directory created: performanceTest-artifacts [EXTENSION] Files created: summary.txt [REPORT-FILE-ENTRY-LISTENER] performanceTest() -> FileEntry [timestamp = 2026-01-04T14:29:46.388729100, path = D:\example-projects\junit-5\junit-5-extension-context\junit-5-extension-publishing\target\junit-jupiter\com.logicbig.example.PerformanceReportingTest\performanceTest()\performanceTest-artifacts] [INFO] +--com.logicbig.example.PerformanceReportingTest - 0.174 ss [INFO] | +-- [OK] memoryUsageTest - 0.136 ss [INFO] | '-- [OK] performanceTest - 0.008 ss [INFO] [INFO] Results: [INFO] [INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 [INFO] [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 4.343 s [INFO] Finished at: 2026-01-04T14:29:46+08:00 [INFO] ------------------------------------------------------------------------
The output demonstrates how the TestReportingExtension successfully publishes various types of test execution data. The extension generates structured report entries with test metadata, creates log file and produces artifact directories containing test-specific outputs. These published elements are captured by the JUnit Platform and can be consumed by build tools like Maven Surefire (which converts them to XML reports), IDEs (which display them in test run views), and CI systems (which can attach the files as build artifacts). This shows how extensions can leverage JUnit's publishing API to create rich, integrated test reporting experiences that go beyond simple console output, providing valuable artifacts for debugging, monitoring, and test analysis.
Example ProjectDependencies and Technologies Used: - junit-jupiter-engine 6.0.1 (Module "junit-jupiter-engine" of JUnit)
Version Compatibility: 5.14.0 - 6.0.1 Version compatibilities of junit-jupiter-engine with this example:
- 5.14.0
- 5.14.1
- 6.0.0
- 6.0.1
Versions in green have been tested.
- junit-platform-launcher 6.0.1 (Module "junit-platform-launcher" of JUnit)
- JDK 25
- Maven 3.9.11
|