Skip to content

A JUnit5 Extension to help write tests that call System.exit()

License

Notifications You must be signed in to change notification settings

tginsberg/junit5-system-exit

Repository files navigation

JUnit5 System.exit() Extension

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).

Differences Between Version 1.x and 2.x

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

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.

Gradle

1. Copy the following into your build.gradle or build.gradle.kts.

testImplementation("com.ginsberg:junit5-system-exit:2.0.2")

2. Enable the Java Agent

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") }
        }")
    })
}

Maven

1. Copy the following into your pom.xml

<dependency>
    <groupId>com.ginsberg</groupId>
    <artifactId>junit5-system-exit</artifactId>
    <version>2.0.2</version>
    <scope>test</scope>
</dependency>

2. Enable the Java Agent

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

Use Cases - Annotation-based

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).

Use Cases - Assertion-based

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) // !!!
        );
    }
}

FAQ

❓ 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 {
}

❓ JaCoCo issues a warning - "Execution data for class does not match"

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.

❓ JaCoCo coverage is not accurate

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!

Contributing and Issues

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