The JUnit Platform is designed to support multiple test engines simultaneously. While most projects rely on the default Jupiter engine, custom engines can be integrated and selectively enabled or disabled using suite-level engine filters. This tutorial demonstrates a minimal custom TestEngine and shows how engine inclusion is resolved before test discovery begins.
@IncludeEngines and @ExcludeEngines
Definition of IncludeEnginesVersion: 6.0.0 package org.junit.platform.suite.api;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Inherited
@Documented
@API(status = MAINTAINED, since = "1.0")
public @interface IncludeEngines {
String[] value(); 1
}
Definition of ExcludeEnginesVersion: 6.0.0 package org.junit.platform.suite.api;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Inherited
@Documented
@API(status = MAINTAINED, since = "1.0")
public @interface ExcludeEngines {
String[] value(); 1
}
What This Example Demonstrates
- A minimal custom
MyCustomTestEngine
- The custom TestEngine will scan for the classes implementing interface
CustomTest.
- The engine will execute the method
CustomTest#performTest which contains the client side test code.
- Engine discovery will be done via the ServiceLoader mechanism
- Selective execution of engines will be done by using
@IncludeEngines and @ExcludeEngines
Example
Our custom interface for performing tests
Instead of using default @Test annotation, client side will define the test via implementing following interface.
package com.logicbig.example.engine;
public interface CustomTest {
boolean performTest();
}
Creating our custom TestEngine
package com.logicbig.example.engine;
import org.junit.platform.engine.*;
import org.junit.platform.engine.discovery.ClassSelector;
import org.junit.platform.engine.discovery.PackageSelector;
import org.junit.platform.engine.support.descriptor.EngineDescriptor;
import org.junit.platform.suite.api.SelectPackages;
import org.junit.platform.suite.api.Suite;
import java.lang.reflect.Modifier;
import java.util.Set;
public class MyCustomTestEngine implements TestEngine {
public static final String ENGINE_ID = "my-simple-test-engine";
@Override
public String getId() {
return ENGINE_ID;
}
@Override
public TestDescriptor discover(
EngineDiscoveryRequest discoveryRequest,
UniqueId uniqueId
) {
EngineDescriptor engineDescriptor =
new EngineDescriptor(uniqueId, "My Custom Test Engine");
discoveryRequest
.getSelectorsByType(ClassSelector.class)
.forEach(selector -> handleSuiteClass(selector, engineDescriptor));
return engineDescriptor;
}
private void handleSuiteClass(
ClassSelector selector,
EngineDescriptor engineDescriptor
) {
Class<?> suiteClass = selector.getJavaClass();
SelectPackages selectPackages =
suiteClass.getAnnotation(SelectPackages.class);
if (selectPackages == null) {
return;
}
for (String pkg : selectPackages.value()) {
discoverPackage(pkg, engineDescriptor);
}
}
private void discoverPackage(
String packageName,
EngineDescriptor engineDescriptor
) {
if (!packageName.startsWith("com.logicbig.example.tests")) {
return;
}
Set<Class<?>> candidates =
ClasspathScanner.findImplementations(
packageName,
CustomTest.class
);
for (Class<?> testClass : candidates) {
try {
Object instance =
testClass.getDeclaredConstructor().newInstance();
UniqueId id = engineDescriptor.getUniqueId()
.append("custom-test", testClass.getName());
engineDescriptor.addChild(
new CustomTestDescriptor(
id,
testClass.getSimpleName(),
instance
)
);
} catch (Exception ignored) {
}
}
}
@Override
public void execute(ExecutionRequest request) {
TestDescriptor root = request.getRootTestDescriptor();
EngineExecutionListener listener =
request.getEngineExecutionListener();
listener.executionStarted(root);
for (TestDescriptor descriptor : root.getChildren()) {
CustomTestDescriptor testDescriptor =
(CustomTestDescriptor) descriptor;
listener.executionStarted(testDescriptor);
try {
CustomTest test =
(CustomTest) testDescriptor.getTestInstance();
boolean passed = test.performTest();
if (!passed) {
throw new AssertionError(
"CustomTest.performTest() returned false"
);
}
listener.executionFinished(
testDescriptor,
TestExecutionResult.successful()
);
} catch (Throwable ex) {
listener.executionFinished(
testDescriptor,
TestExecutionResult.failed(ex)
);
}
}
listener.executionFinished(
root,
TestExecutionResult.successful()
);
}
}
We are skipping some helper classes here, please see the project browser below for complete code.
Implementing Custom TestDescriptor
TestDescriptor describes a test. It represents a test that has been discovered by a TestEngine.
package com.logicbig.example.engine;
import org.junit.platform.engine.UniqueId;
import org.junit.platform.engine.support.descriptor.AbstractTestDescriptor;
public class CustomTestDescriptor extends AbstractTestDescriptor {
private final Object testInstance;
public CustomTestDescriptor(
UniqueId uniqueId,
String displayName,
Object testInstance
) {
super(uniqueId, displayName);
this.testInstance = testInstance;
}
public Object getTestInstance() {
return testInstance;
}
@Override
public Type getType() {
return Type.TEST;
}
}
Registering our custom engine
src/test/resources/META-INF/services/org.junit.platform.engine.TestEnginecom.logicbig.example.engine.MyCustomTestEngine
Our custom test class
package com.logicbig.example.tests;
import com.logicbig.example.engine.CustomTest;
public class AdditionTest implements CustomTest {
@Override
public boolean performTest() {
return 2 + 2 == 4;
}
}
Jupiter test class
package com.logicbig.example.tests;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JupiterTest {
@Test
void jupiterTestRuns() {
assertTrue(true);
}
}
Suite class selecting our custom engine
package com.logicbig.example.suites;
import com.logicbig.example.engine.MyCustomTestEngine;
import org.junit.platform.suite.api.*;
@Suite(failIfNoTests = false)
@SelectPackages("com.logicbig.example.tests")
@IncludeEngines(MyCustomTestEngine.ENGINE_ID)
public class CustomEngineOnlySuite {
}
Output$ mvn test -Dtest=CustomEngineOnlySuite [INFO] Scanning for projects... [INFO] [INFO] ----------< com.logicbig.example:junit-5-custom-test-engine >----------- [INFO] Building junit-5-custom-test-engine 1.0-SNAPSHOT [INFO] from pom.xml [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- resources:3.3.1:resources (default-resources) @ junit-5-custom-test-engine --- [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-suite-engine\junit-5-custom-test-engine\src\main\resources [INFO] [INFO] --- compiler:3.11.0:compile (default-compile) @ junit-5-custom-test-engine --- [INFO] No sources to compile [INFO] [INFO] --- resources:3.3.1:testResources (default-testResources) @ junit-5-custom-test-engine --- [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-custom-test-engine --- [INFO] Nothing to compile - all classes are up to date [INFO] [INFO] --- surefire:3.5.0:test (default-test) @ junit-5-custom-test-engine --- [INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider [INFO] [INFO] ------------------------------------------------------- [INFO] T E S T S [INFO] ------------------------------------------------------- [INFO] +--My Custom Test Engine - 0.011 ss [INFO] | '-- [OK] AdditionTest - 0.007 ss [INFO] [INFO] Results: [INFO] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 [INFO] [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 3.200 s [INFO] Finished at: 2025-12-21T10:37:16+08:00 [INFO] ------------------------------------------------------------------------
The output confirms that only the custom engine participated in execution. Although the Jupiter engine is present on the classpath, the suite explicitly includes the custom engine by its identifier. As a result, no Jupiter-based tests are discovered or executed.
Selecting Jupiter and custom Engines
package com.logicbig.example.suites;
import com.logicbig.example.engine.MyCustomTestEngine;
import org.junit.platform.suite.api.IncludeEngines;
import org.junit.platform.suite.api.SelectPackages;
import org.junit.platform.suite.api.Suite;
@Suite(failIfNoTests = false)
@SelectPackages("com.logicbig.example.tests")
@IncludeEngines({MyCustomTestEngine.ENGINE_ID, "junit-jupiter"})
public class BothEngineSuite {
}
Output$ mvn test -Dtest=BothEngineSuite [INFO] Scanning for projects... [INFO] [INFO] ----------< com.logicbig.example:junit-5-custom-test-engine >----------- [INFO] Building junit-5-custom-test-engine 1.0-SNAPSHOT [INFO] from pom.xml [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- resources:3.3.1:resources (default-resources) @ junit-5-custom-test-engine --- [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-suite-engine\junit-5-custom-test-engine\src\main\resources [INFO] [INFO] --- compiler:3.11.0:compile (default-compile) @ junit-5-custom-test-engine --- [INFO] No sources to compile [INFO] [INFO] --- resources:3.3.1:testResources (default-testResources) @ junit-5-custom-test-engine --- [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-custom-test-engine --- [INFO] Nothing to compile - all classes are up to date [INFO] [INFO] --- surefire:3.5.0:test (default-test) @ junit-5-custom-test-engine --- [INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider [INFO] [INFO] ------------------------------------------------------- [INFO] T E S T S [INFO] ------------------------------------------------------- [INFO] +--My Custom Test Engine - 0.085 ss [INFO] | '-- [OK] AdditionTest - 0.011 ss [INFO] +--BothEngineSuite JupiterTest - 0.085 ss [INFO] | '-- [OK] jupiterTestRuns - 0.052 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.434 s [INFO] Finished at: 2025-12-21T10:37:06+08:00 [INFO] ------------------------------------------------------------------------
As seen above, both engines were invoked and they executed their tests.
@ExcludeEngine example
In this example, we are going to extend our BothEngineSuite (which selects both engines) and exclude Jupiter engine.
package com.logicbig.example.suites;
import org.junit.platform.suite.api.ExcludeEngines;
@ExcludeEngines("junit-jupiter")
public class ExcludeEngineSuite extends BothEngineSuite {
}
Output$ mvn test -Dtest=ExcludeEngineSuite [INFO] Scanning for projects... [INFO] [INFO] ----------< com.logicbig.example:junit-5-custom-test-engine >----------- [INFO] Building junit-5-custom-test-engine 1.0-SNAPSHOT [INFO] from pom.xml [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- resources:3.3.1:resources (default-resources) @ junit-5-custom-test-engine --- [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-suite-engine\junit-5-custom-test-engine\src\main\resources [INFO] [INFO] --- compiler:3.11.0:compile (default-compile) @ junit-5-custom-test-engine --- [INFO] No sources to compile [INFO] [INFO] --- resources:3.3.1:testResources (default-testResources) @ junit-5-custom-test-engine --- [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-custom-test-engine --- [INFO] Nothing to compile - all classes are up to date [INFO] [INFO] --- surefire:3.5.0:test (default-test) @ junit-5-custom-test-engine --- [INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider [INFO] [INFO] ------------------------------------------------------- [INFO] T E S T S [INFO] ------------------------------------------------------- [INFO] +--My Custom Test Engine - 0.012 ss [INFO] | '-- [OK] AdditionTest - 0.008 ss [INFO] [INFO] Results: [INFO] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 [INFO] [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 3.096 s [INFO] Finished at: 2025-12-21T10:36:52+08:00 [INFO] ------------------------------------------------------------------------
This demonstrates that @IncludeEngines and @ExcludeEngines operate at the engine resolution phase of the JUnit Platform lifecycle. Engines that are excluded are never invoked for discovery, ensuring strict control over which testing technologies participate in a given suite execution.
Example ProjectDependencies and Technologies Used: - junit-platform-suite-engine 6.0.1 (Module "junit-platform-suite-engine" of JUnit)
- junit-jupiter-engine 6.0.1 (Module "junit-jupiter-engine" of JUnit)
Version Compatibility: 5.9.0 - 6.0.1 Version compatibilities of junit-jupiter-engine with this example:
- 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
|