Building your own JUnit 6 TestEngine is the ultimate act of framework mastery. It lets you run tests written in any format β YAML, XML, a custom DSL, plain text specifications β on the same JUnit Platform that runs your regular @Test methods. This guide builds a complete, working custom engine from scratch, step by step.
Before reading this, make sure you understand how the engine works internally β see JUnit 6 Internals: How the Test Engine Works first.
What We Will Build
We will build a CSV Test Engine that discovers .csv test files on the classpath, treats each row as a test case (input values + expected output), and runs them through a target method. This demonstrates every part of the TestEngine contract in a realistic, runnable example.
Step 1: Add the Platform Engine Dependency
<!-- JUnit Platform Engine SPI β provides TestEngine, TestDescriptor, etc. -->
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-engine</artifactId>
<version>1.11.0</version>
<scope>test</scope>
</dependency>
<!-- Commons CSV for parsing CSV test files -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-csv</artifactId>
<version>1.10.0</version>
<scope>test</scope>
</dependency>
Step 2: Create the Test Data β CSV File
# src/test/resources/csv-tests/addition-tests.csv
# Format: testName, addend1, addend2, expectedSum
Adding positive numbers,1,2,3
Adding negative and positive,-5,5,0
Adding zeros,0,0,0
Large numbers,1000,2000,3000
Identity property,42,0,42
Step 3: Implement the TestDescriptor Classes
import org.junit.platform.engine.*;
import org.junit.platform.engine.support.descriptor.AbstractTestDescriptor;
import java.util.List;
/**
* Describes one CSV test file as a container node in the descriptor tree.
* Container = has children (the individual row tests).
*/
public class CsvFileDescriptor extends AbstractTestDescriptor {
private final String fileName;
public CsvFileDescriptor(UniqueId parentId, String fileName) {
super(parentId.append("csv-file", fileName), fileName);
this.fileName = fileName;
}
@Override
public Type getType() {
// CONTAINER: this node has children (the individual CSV row tests)
return Type.CONTAINER;
}
public String getFileName() { return fileName; }
}
/**
* Describes one CSV row as a leaf test node.
* Test = no children, contains the actual test logic.
*/
public class CsvRowDescriptor extends AbstractTestDescriptor {
private final int addend1;
private final int addend2;
private final int expectedSum;
public CsvRowDescriptor(UniqueId parentId, String testName,
int addend1, int addend2, int expectedSum) {
super(parentId.append("csv-row", testName), testName);
this.addend1 = addend1;
this.addend2 = addend2;
this.expectedSum = expectedSum;
}
@Override
public Type getType() {
// TEST: this is a leaf node β an actual test case
return Type.TEST;
}
public int getAddend1() { return addend1; }
public int getAddend2() { return addend2; }
public int getExpectedSum(){ return expectedSum; }
}
Step 4: Implement the TestEngine
import org.junit.platform.engine.*;
import org.junit.platform.engine.discovery.ClasspathRootSelector;
import org.junit.platform.engine.support.descriptor.EngineDescriptor;
import org.apache.commons.csv.*;
import java.io.*;
import java.net.*;
import java.nio.file.*;
import java.util.stream.Stream;
/**
* CsvTestEngine: discovers .csv test files and runs each row as a test.
* Registered via ServiceLoader: META-INF/services/org.junit.platform.engine.TestEngine
*/
public class CsvTestEngine implements TestEngine {
// Unique identifier for this engine
@Override
public String getId() {
return "csv-test-engine";
}
// ----------------------------------------------------------------
// PHASE 1: DISCOVERY
// Scan for CSV files and build the TestDescriptor tree
// ----------------------------------------------------------------
@Override
public TestDescriptor discover(EngineDiscoveryRequest discoveryRequest,
UniqueId uniqueId) {
// Root node for this engine
EngineDescriptor engineDescriptor =
new EngineDescriptor(uniqueId, "CSV Test Engine");
// Find all classpath roots in the discovery request
discoveryRequest.getSelectorsByType(ClasspathRootSelector.class)
.forEach(selector -> {
try {
discoverCsvFiles(selector.getClasspathRoot(), engineDescriptor);
} catch (IOException e) {
throw new RuntimeException("Failed to discover CSV test files", e);
}
});
return engineDescriptor;
}
private void discoverCsvFiles(URI classpathRoot,
EngineDescriptor engineDescriptor) throws IOException {
Path root = Path.of(classpathRoot);
// Locate the csv-tests directory under the classpath root
Path csvDir = root.resolve("csv-tests");
if (!Files.isDirectory(csvDir)) return;
// Each .csv file becomes a CsvFileDescriptor (container)
Files.list(csvDir)
.filter(path -> path.toString().endsWith(".csv"))
.forEach(csvFile -> {
String fileName = csvFile.getFileName().toString();
CsvFileDescriptor fileDescriptor =
new CsvFileDescriptor(engineDescriptor.getUniqueId(), fileName);
try {
// Each CSV row becomes a CsvRowDescriptor (test)
parseRowsFromFile(csvFile, fileDescriptor);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
engineDescriptor.addChild(fileDescriptor);
});
}
private void parseRowsFromFile(Path csvFile,
CsvFileDescriptor fileDescriptor) throws IOException {
try (Reader reader = Files.newBufferedReader(csvFile);
CSVParser csvParser = CSVFormat.DEFAULT
.withCommentMarker('#')
.parse(reader)) {
for (CSVRecord record : csvParser) {
String testName = record.get(0).trim();
int addend1 = Integer.parseInt(record.get(1).trim());
int addend2 = Integer.parseInt(record.get(2).trim());
int expectedSum = Integer.parseInt(record.get(3).trim());
CsvRowDescriptor rowDescriptor = new CsvRowDescriptor(
fileDescriptor.getUniqueId(),
testName, addend1, addend2, expectedSum
);
fileDescriptor.addChild(rowDescriptor);
}
}
}
// ----------------------------------------------------------------
// PHASE 2: EXECUTION
// Walk the descriptor tree and run each test row
// ----------------------------------------------------------------
@Override
public void execute(ExecutionRequest executionRequest) {
EngineDescriptor engineDescriptor =
(EngineDescriptor) executionRequest.getRootTestDescriptor();
EngineExecutionListener listener = executionRequest.getEngineExecutionListener();
// Notify: engine started
listener.executionStarted(engineDescriptor);
// Walk children (one per CSV file)
engineDescriptor.getChildren().forEach(fileNode -> {
CsvFileDescriptor fileDescriptor = (CsvFileDescriptor) fileNode;
listener.executionStarted(fileDescriptor);
// Walk rows (one per CSV row)
fileDescriptor.getChildren().forEach(rowNode -> {
CsvRowDescriptor rowDescriptor = (CsvRowDescriptor) rowNode;
listener.executionStarted(rowDescriptor);
// Run the actual test: add two numbers and compare to expected
TestExecutionResult result = runCsvTest(rowDescriptor);
listener.executionFinished(rowDescriptor, result);
});
listener.executionFinished(fileDescriptor,
TestExecutionResult.successful());
});
// Notify: engine finished
listener.executionFinished(engineDescriptor,
TestExecutionResult.successful());
}
private TestExecutionResult runCsvTest(CsvRowDescriptor descriptor) {
try {
int actualSum = descriptor.getAddend1() + descriptor.getAddend2();
if (actualSum == descriptor.getExpectedSum()) {
return TestExecutionResult.successful();
} else {
return TestExecutionResult.failed(
new AssertionError(
String.format("Expected %d + %d = %d, but got %d",
descriptor.getAddend1(),
descriptor.getAddend2(),
descriptor.getExpectedSum(),
actualSum
)
)
);
}
} catch (Exception ex) {
return TestExecutionResult.failed(ex);
}
}
}
Step 5: Register the Engine via ServiceLoader
Create the file src/test/resources/META-INF/services/org.junit.platform.engine.TestEngine with one line:
com.example.engine.CsvTestEngine
Step 6: Run and See the Output
-------------------------------------------------------
T E S T S
-------------------------------------------------------
Running CSV Test Engine β addition-tests.csv
β Adding positive numbers
β Adding negative and positive
β Adding zeros
β Large numbers
β Identity property
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Frequently Asked Questions (FAQs)
Q1: Do I need to implement both discover() and execute() in my TestEngine?
Yes, both are required by the TestEngine interface. However, discover() can return a minimal tree (just the engine root with no children) if your engine determines at discovery time that there are no tests. Returning an empty root is valid β the execution phase will simply find no children to execute.
Q2: Can my custom engine run alongside the JupiterTestEngine?
Yes. The JUnit Platform supports multiple engines simultaneously. As long as your engine is registered via ServiceLoader and has a unique getId() value, it will be discovered and run alongside Jupiter and Vintage engines in the same build. Each engineβs results are reported together in the same Surefire XML report.
Q3: How do I support tag filtering in my custom engine?
In your discover() method, retrieve PostDiscoveryFilter instances from the EngineDiscoveryRequest and apply them to each descriptor you create. If a descriptor does not pass the filter, exclude it from the tree. JUnit 6 also calls PostDiscoveryFilter.apply(TestDescriptor) after discovery as a second pass, so many filters are handled automatically for you.
Q4: What happens if execute() throws an uncaught exception?
The JUnit Platform catches exceptions thrown from execute() and marks the engine descriptor as failed. To handle errors gracefully, wrap your execution logic in try-catch and call listener.executionFinished(descriptor, TestExecutionResult.failed(exception)) for the failing node rather than letting exceptions propagate out of execute().
Q5: Can I use the AbstractTestDescriptor base class instead of implementing TestDescriptor directly?
Yes, and it is strongly recommended. AbstractTestDescriptor (from junit-platform-engine) provides default implementations for parent/child management, UniqueId handling, and the removeFromHierarchy() method. You only need to implement getType() (returning CONTAINER or TEST) and optionally override getDisplayName(). This is what the CSV engine above uses.
See Also
- JUnit 6 Internals: How the Test Engine Works
- JUnit 6 Architecture Deep Dive: Platform, Engines, and Launchers
- Advanced Extensions in JUnit 6: Creating Custom Testing Frameworks
- JUnit 6 Extensions Model: Build Custom Extensions
- JUnit 6 Tutorial: Complete Series Index
Conclusion
Building a custom TestEngine is a small amount of code for an enormous amount of capability. The TestEngine interface has two methods. Implement them, register your engine via ServiceLoader, and any format you can parse β CSV, YAML, XML, a fluent DSL β can run as first-class tests on the JUnit Platform, visible in IDEs, counted by Surefire, and reported in CI dashboards.
Next: Advanced Extensions in JUnit 6 β take the extension model to the next level by building a complete mini-testing framework using composed extensions and custom annotations.