STF Milestone 7: Safe cancellation

This post is part of the series on my work on JUnit supported by the Sovereign Tech Fund (STF). Please refer to the initial post for context and a list of all posts.

Prior to this milestone, the JUnit Platform provided no safe way to cancel test execution early, e.g. after the first test failed. The only option was to forcibly terminate the JVM running the tests. However, that also caused cleanup operations, such as deleting temporary files or stopping Docker containers used in tests, to be skipped. Letting all tests run is often wasteful in terms of resources such as CPU and causes longer feedback cycles for developers. Therefore, this milestone was all about introducing a safe cancellation mechanism to the JUnit Platform.

The Launcher API is the main entry point to the JUnit Platform for IDEs and build tools. The new CancellationToken API allows clients to request cancellation of a running test execution. The Launcher interface already contained two methods for executing tests; one taking a LauncherDiscoveryRequest and another taking a TestPlan. Rather than adding two additional overloads that take a CancellationToken, a new LauncherExecutionRequest class was introduced that encapsulates all parameters required for test execution, including an optional CancellationToken. This design allows for future extensions of the test execution parameters without breaking existing clients. A LauncherExecutionRequest can be created from a LauncherDiscoveryRequest, a TestPlan, or via LauncherDiscoveryRequestBuilder.forExecution().

Using these new concepts, clients can implement “fail fast” behavior as follows (see User Guide for details):

CancellationToken cancellationToken = CancellationToken.create();

TestExecutionListener failFastListener = new TestExecutionListener() {
    @Override
    public void executionFinished(TestIdentifier identifier, TestExecutionResult result) {
        if (result.getStatus() == FAILED) {
            cancellationToken.cancel();
        }
    }
};

LauncherExecutionRequest executionRequest = LauncherDiscoveryRequestBuilder.request()
        .selectors(selectClass(MyTestClass.class))
        .forExecution()
        .cancellationToken(cancellationToken)
        .listeners(failFastListener)
        .build();

try (LauncherSession session = LauncherFactory.openSession()) {
    session.getLauncher().execute(executionRequest);
}

Cancelling tests relies on test engines checking and responding to the CancellationToken. As a stop-gap solution, the Launcher also checks the token and cancels test execution when multiple test engines are present at runtime.

At the time of writing, the following test engines support cancellation:

In addition to the changes in JUnit, I submitted a pull request to the Maven Surefire project. It uses the new cancellation mechanism to implement support for Surefire’s skipAfterFailureCount feature. It was merged and released in Maven Surefire 3.5.4. For Gradle, there’s an open issue to implement similar support across all testing frameworks.

To try out the new safe cancellation mechanism, you need to use JUnit 6.0.0 or later. I am looking forward to your feedback!