This JUnit 5 Extension helps you write tests for code that calls System.exit()
. It requires Java 17 or greater,
and has one dependency (ASM).
Version 1.x used an approach that replaced the system SecurityManager
.
That worked fine until Java 17 where the SecurityManager
was deprecated for removal. As of Java 18, a property to
explicitly enable programmatic access to the SecurityManager
is required. This method works for now but will eventually
stop working. If you are still on 1.x and cannot move to 2.x, you can still find the instructions here.
Version 2.x uses a Java Agent to rewrite bytecode as the JVM loads classes. Whenever a call to System.exit()
is detected,
the Junit 5 System Exit Agent replaces that call with a function that records an attempt to exit, preventing the JVM from exiting.
As a consequence of rewriting bytecode, this library now has one dependency - ASM.
When the Java Class-File API is released, I will explore using that instead (or in addition to).
Version 2 also supports AssertJ-style fluid assertions in addition to the annotation-driven approach that came with Version 1. Other than enabling the Java Agent (see below), your code should not change when upgrading from Version 1.x to Version 2.x.
Installing involves two steps: adding the junit5-system-exit
library to your build, and adding its Java Agent to
your test task. Please consult the FAQ below if you run into problems.
testImplementation("com.ginsberg:junit5-system-exit:2.0.2")
It is important to add the Junit5 System Exit Java Agent in a way that makes it come after other agents which you may be using, such as JaCoCo. See the notes below for details on why.
If you use the Groovy DSL, add this code to your build.gradle
file in the test
task...
// Groovy DSL
test {
useJUnitPlatform()
def junit5SystemExit = configurations.testRuntimeClasspath.files
.find { it.name.contains('junit5-system-exit') }
jvmArgumentProviders.add({["-javaagent:$junit5SystemExit"]} as CommandLineArgumentProvider)
}
or if you use the Kotlin DSL...
// Kotlin DSL
test {
useJUnitPlatform()
jvmArgumentProviders.add(CommandLineArgumentProvider {
listOf("-javaagent:${configurations.testRuntimeClasspath.get().files.find {
it.name.contains("junit5-system-exit") }
}")
})
}
<dependency>
<groupId>com.ginsberg</groupId>
<artifactId>junit5-system-exit</artifactId>
<version>2.0.2</version>
<scope>test</scope>
</dependency>
In pom.xml
, add the properties
goal to maven-dependency-plugin
, in order to create properties for each dependency.
<plugin>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>properties</goal>
</goals>
</execution>
</executions>
</plugin>
Then add the following <argLine/>
to maven-surefire-plugin
, which references the property we just created for
this library. This should account for other Java Agents and run after any others you may have, such as JaCoCo.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>@{argLine} -javaagent:${com.ginsberg:junit5-system-exit:jar}</argLine>
</configuration>
</plugin>
And
A Test that expects System.exit()
to be called, with any status code:
public class MyTestCases {
@Test
@ExpectSystemExit
void thatSystemExitIsCalled() {
System.exit(1);
}
}
A Test that expects System.exit(1)
to be called, with a specific status code:
public class MyTestCases {
@Test
@ExpectSystemExitWithStatus(1)
void thatSystemExitIsCalled() {
System.exit(1);
}
}
A Test that should not expect System.exit()
to be called, and fails the test if it does:
public class MyTestCases {
@Test
@FailOnSystemExit
void thisTestWillFail() {
System.exit(1); // !!!
}
}
The @ExpectSystemExit
, @ExpectSystemExitWithStatus
, and @FailOnSystemExit
annotations can be applied to methods, classes, or annotations (to act as meta-annotations).
A Test that expects System.exit()
to be called, with any status code:
public class MyTestClasses {
@Test
void thatSystemExitIsCalled() {
assertThatCallsSystemExit(() ->
System.exit(42)
);
}
}
A Test that expects System.exit(1)
to be called, with a specific status code:
public class MyTestClasses {
@Test
void thatSystemExitIsCalled() {
assertThatCallsSystemExit(() ->
System.exit(42)
).withExitCode(42);
}
}
A Test that expects System.exit(1)
to be called, with status code in a specified range (inclusive):
public class MyTestClasses {
@Test
void thatSystemExitIsCalled() {
assertThatCallsSystemExit(() ->
System.exit(42)
).withExitCodeInRange(1, 100);
}
}
A Test that should not expect System.exit()
to be called, and fails the assertion if it does:
public class MyTestClasses {
@Test
void thisTestWillFail() {
assertThatDoesNotCallSystemExit(() ->
System.exit(42) // !!!
);
}
}
❓ I don't want Junit5-System-Exit
to rewrite the bytecode of a specific class or method that calls System.exit()
.
This is supported. When this library detects any annotation called @DoNotRewriteExitCalls
on any method or class, bytecode
rewriting will be skipped. While this library ships with its own implementation of @DoNotRewriteExitCalls
, you'll probably
want to write your own so you don't have to use this library outside the test scope. It is a marker annotation like this:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface DoNotRewriteExitCalls {
}
This happens when JaCoCo's Java Agent runs after this one. The instructions above should put this agent after JaCoCo but things change over time, and there are probably more gradle configurations that I have not tested. If you run into this and are confident that you followed the instructions above, please reach out to me with a minimal example and I will see what I can do.
Because this Java Agent rewrites bytecode and must run after JaCoCo, there will be discrepancies in the JaCoCo Report. I have not found a way to account for this, but if you discover something please let me know!
Please feel free to file issues for change requests or bugs. If you would like to contribute new functionality, please contact me first!
Copyright © 2021-2024 by Todd Ginsberg