├── .github └── workflows │ └── build.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── VERSION.txt ├── Version1.xInstructions.md ├── build.gradle.kts ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle.kts └── src ├── main └── java │ └── com │ └── ginsberg │ └── junit │ └── exit │ ├── ExitPreventerStrategy.java │ ├── ExpectSystemExit.java │ ├── ExpectSystemExitWithStatus.java │ ├── FailOnSystemExit.java │ ├── SystemExitExtension.java │ ├── SystemExitPreventedException.java │ ├── agent │ ├── AgentSystemExitHandlerStrategy.java │ ├── DoNotRewriteExitCalls.java │ └── Junit5SystemExitAgent.java │ └── assertions │ └── SystemExitAssertion.java └── test └── java └── com └── ginsberg └── junit └── exit ├── ExpectSystemExitTest.java ├── ExpectSystemExitWithStatusTest.java ├── FailOnSystemExitTest.java ├── TestUtils.java ├── WithParameterizedTest.java └── assertions └── SystemExitAssertionTest.java /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | [ pull_request, push ] 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: read 11 | packages: write 12 | pull-requests: write 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up JDK 17 18 | uses: actions/setup-java@v4 19 | with: 20 | java-version: '17' 21 | distribution: 'temurin' 22 | cache: 'gradle' 23 | 24 | - name: Build 25 | run: ./gradlew build --no-daemon 26 | 27 | - name: Publish 28 | if: github.ref != 'refs/heads/master' && !(github.event_name == 'pull_request' && github.base_ref == 'master') 29 | run: ./gradlew publish --no-daemon 30 | env: 31 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 32 | SONATYPE_TOKEN: ${{ secrets.SONATYPE_TOKEN }} 33 | SONATYPE_SIGNING_KEY: ${{ secrets.SONATYPE_SIGNING_KEY }} 34 | SONATYPE_SIGNING_PASSPHRASE: ${{ secrets.SONATYPE_SIGNING_PASSPHRASE }} 35 | 36 | - name: Add coverage to PR 37 | if: github.event_name == 'pull_request' 38 | id: jacoco 39 | uses: madrapps/jacoco-report@v1.6.1 40 | with: 41 | paths: ${{ github.workspace }}/**/build/reports/jacoco/test/jacocoTestReport.xml 42 | token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /build/ 3 | *.class 4 | *.jar 5 | .idea 6 | /out/ 7 | gradle.properties 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log for `junit5-system-exit` 2 | 3 | ### 2.0.2 4 | - Bugfix: [[#24]](https://github.com/tginsberg/junit5-system-exit/issues/24): Reset state between assertion calls. 5 | 6 | ### 2.0.1 7 | - Bugfix: [[#20]](https://github.com/tginsberg/junit5-system-exit/issues/20): Multiple calls to `System.exit()` do not always report the first exit status code. 8 | 9 | ### 2.0.0 10 | - Remove terminally deprecated `SecurityManager` approach for preventing `System.exit()` calls. 11 | - Add Java Agent-based approach. Calls to `System.exit()` are rewritten as classes are loaded. 12 | - Add AssertJ-style fluid assertions for cases when test authors do not want to use annotations, or want to write assertions after a `System.exit()` is detected. 13 | 14 | ### 1.1.2 15 | - Bugfix: [[#12]](https://github.com/tginsberg/junit5-system-exit/issues/12) ParameterizedTest does not work accurately. 16 | 17 | ### 1.1.1 18 | - Make `SystemExitPreventedException` public and add a `statusCode` getter. This should help with testing CLIs. 19 | - Add new `@FailOnSystemExit` annotation. When a test calls `System.exit()` the JVM running the test will terminate (in most setups). Annotating a test with `@FailOnSystemExit` will catch this condition and fail the test, rather than exiting the JVM the test is executing on. 20 | 21 | ### 1.1.0 22 | - Do Not Use. Prefer 1.1.1 or 1.1.2 please. 23 | 24 | ### 1.0.0 25 | - Initial Release. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Todd Ginsberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JUnit5 System.exit() Extension 2 | 3 | This [JUnit 5 Extension](https://junit.org/junit5/docs/current/user-guide/#extensions) helps you write tests for code that calls `System.exit()`. It requires Java 17 or greater, 4 | and has one dependency ([ASM](https://asm.ow2.io/)). 5 | 6 | ## Differences Between Version 1.x and 2.x 7 | 8 | **Version 1.x** used an approach that [replaced the system `SecurityManager`](https://todd.ginsberg.com/post/testing-system-exit/). 9 | That worked fine until Java 17 where the `SecurityManager` was deprecated for removal. As of Java 18, a property to 10 | explicitly enable programmatic access to the `SecurityManager` is required. This method works for now but will eventually 11 | stop working. If you are still on 1.x and cannot move to 2.x, [you can still find the instructions here](Version1.xInstructions.md). 12 | 13 | **Version 2.x** uses a Java Agent to rewrite bytecode as the JVM loads classes. Whenever a call to `System.exit()` is detected, 14 | the Junit 5 System Exit Agent replaces that call with a function that records an attempt to exit, preventing the JVM from exiting. 15 | As a consequence of rewriting bytecode, this library now has one dependency - [ASM](https://asm.ow2.io/). 16 | When the [Java Class-File API](https://openjdk.org/jeps/457) is released, I will explore using that instead (or in addition to). 17 | 18 | Version 2 also supports AssertJ-style fluid assertions in addition to the annotation-driven approach that came with Version 1. 19 | Other than enabling the Java Agent (see below), your code should not change when upgrading from Version 1.x to Version 2.x. 20 | 21 | ## Installing 22 | 23 | Installing involves two steps: adding the `junit5-system-exit` library to your build, and adding its Java Agent to 24 | your test task. Please consult the [FAQ](#faq) below if you run into problems. 25 | 26 | ### Gradle 27 | 28 | #### 1. Copy the following into your `build.gradle` or `build.gradle.kts`. 29 | 30 | ```groovy 31 | testImplementation("com.ginsberg:junit5-system-exit:2.0.2") 32 | ``` 33 | 34 | #### 2. Enable the Java Agent 35 | 36 | It is important to add the Junit5 System Exit Java Agent in a way that makes it come after other agents which 37 | you may be using, such as JaCoCo. See the notes below for details on why. 38 | 39 | If you use the Groovy DSL, add this code to your `build.gradle` file in the `test` task... 40 | 41 | ```groovy 42 | // Groovy DSL 43 | 44 | test { 45 | useJUnitPlatform() 46 | 47 | def junit5SystemExit = configurations.testRuntimeClasspath.files 48 | .find { it.name.contains('junit5-system-exit') } 49 | jvmArgumentProviders.add({["-javaagent:$junit5SystemExit"]} as CommandLineArgumentProvider) 50 | } 51 | ``` 52 | 53 | or if you use the Kotlin DSL... 54 | 55 | ```kotlin 56 | // Kotlin DSL 57 | test { 58 | useJUnitPlatform() 59 | 60 | jvmArgumentProviders.add(CommandLineArgumentProvider { 61 | listOf("-javaagent:${configurations.testRuntimeClasspath.get().files.find { 62 | it.name.contains("junit5-system-exit") } 63 | }") 64 | }) 65 | } 66 | ``` 67 | 68 | ### Maven 69 | 70 | #### 1. Copy the following into your `pom.xml` 71 | 72 | ```xml 73 | 74 | com.ginsberg 75 | junit5-system-exit 76 | 2.0.2 77 | test 78 | 79 | ``` 80 | 81 | #### 2. Enable the Java Agent 82 | 83 | In `pom.xml`, add the `properties` goal to `maven-dependency-plugin`, in order to create properties for each dependency. 84 | 85 | ```xml 86 | 87 | maven-dependency-plugin 88 | 89 | 90 | 91 | properties 92 | 93 | 94 | 95 | 96 | ``` 97 | 98 | Then add the following `` to `maven-surefire-plugin`, which references the property we just created for 99 | this library. This should account for other Java Agents and run after any others you may have, such as JaCoCo. 100 | 101 | ```xml 102 | 103 | org.apache.maven.plugins 104 | maven-surefire-plugin 105 | 106 | @{argLine} -javaagent:${com.ginsberg:junit5-system-exit:jar} 107 | 108 | 109 | ``` 110 | 111 | And 112 | 113 | ## Use Cases - Annotation-based 114 | 115 | **A Test that expects `System.exit()` to be called, with any status code:** 116 | 117 | ```java 118 | public class MyTestCases { 119 | 120 | @Test 121 | @ExpectSystemExit 122 | void thatSystemExitIsCalled() { 123 | System.exit(1); 124 | } 125 | } 126 | ``` 127 | 128 | **A Test that expects `System.exit(1)` to be called, with a specific status code:** 129 | 130 | ```java 131 | public class MyTestCases { 132 | 133 | @Test 134 | @ExpectSystemExitWithStatus(1) 135 | void thatSystemExitIsCalled() { 136 | System.exit(1); 137 | } 138 | } 139 | ``` 140 | 141 | **A Test that should not expect `System.exit()` to be called, and fails the test if it does:** 142 | 143 | ```java 144 | public class MyTestCases { 145 | 146 | @Test 147 | @FailOnSystemExit 148 | void thisTestWillFail() { 149 | System.exit(1); // !!! 150 | } 151 | } 152 | ``` 153 | 154 | The `@ExpectSystemExit`, `@ExpectSystemExitWithStatus`, and `@FailOnSystemExit` annotations can be applied to methods, classes, or annotations (to act as meta-annotations). 155 | 156 | ## Use Cases - Assertion-based 157 | 158 | **A Test that expects `System.exit()` to be called, with any status code:** 159 | 160 | ```java 161 | public class MyTestClasses { 162 | 163 | @Test 164 | void thatSystemExitIsCalled() { 165 | assertThatCallsSystemExit(() -> 166 | System.exit(42) 167 | ); 168 | } 169 | } 170 | ``` 171 | 172 | **A Test that expects `System.exit(1)` to be called, with a specific status code:** 173 | 174 | ```java 175 | public class MyTestClasses { 176 | 177 | @Test 178 | void thatSystemExitIsCalled() { 179 | assertThatCallsSystemExit(() -> 180 | System.exit(42) 181 | ).withExitCode(42); 182 | } 183 | } 184 | ``` 185 | 186 | **A Test that expects `System.exit(1)` to be called, with status code in a specified range (inclusive):** 187 | 188 | ```java 189 | public class MyTestClasses { 190 | 191 | @Test 192 | void thatSystemExitIsCalled() { 193 | assertThatCallsSystemExit(() -> 194 | System.exit(42) 195 | ).withExitCodeInRange(1, 100); 196 | } 197 | } 198 | ``` 199 | 200 | **A Test that should not expect `System.exit()` to be called, and fails the assertion if it does:** 201 | 202 | ```java 203 | public class MyTestClasses { 204 | 205 | @Test 206 | void thisTestWillFail() { 207 | assertThatDoesNotCallSystemExit(() -> 208 | System.exit(42) // !!! 209 | ); 210 | } 211 | } 212 | ``` 213 | 214 | ## FAQ 215 | 216 | ### :question: I don't want `Junit5-System-Exit` to rewrite the bytecode of a specific class or method that calls `System.exit()`. 217 | 218 | This is supported. When this library detects any annotation called `@DoNotRewriteExitCalls` on any method or class, bytecode 219 | rewriting will be skipped. While this library ships with its own implementation of `@DoNotRewriteExitCalls`, you'll probably 220 | 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: 221 | 222 | ```java 223 | @Retention(RetentionPolicy.RUNTIME) 224 | @Target({ElementType.TYPE, ElementType.METHOD}) 225 | public @interface DoNotRewriteExitCalls { 226 | } 227 | ``` 228 | 229 | ### :question: JaCoCo issues a warning - "Execution data for class does not match" 230 | 231 | This happens when JaCoCo's Java Agent runs after this one. The instructions above _should_ put this agent after JaCoCo 232 | but things change over time, and there are probably more gradle configurations that I have not tested. If you run into 233 | this and are confident that you followed the instructions above, please reach out to me with a minimal example and I will 234 | see what I can do. 235 | 236 | ### :question: JaCoCo coverage is not accurate 237 | 238 | Because this Java Agent rewrites bytecode and must run after JaCoCo, there will be discrepancies in the JaCoCo Report. 239 | I have not found a way to account for this, but if you discover something please let me know! 240 | 241 | ## Contributing and Issues 242 | 243 | Please feel free to file issues for change requests or bugs. If you would like to contribute new functionality, please contact me first! 244 | 245 | Copyright © 2021-2024 by Todd Ginsberg 246 | -------------------------------------------------------------------------------- /VERSION.txt: -------------------------------------------------------------------------------- 1 | 2.0.2 -------------------------------------------------------------------------------- /Version1.xInstructions.md: -------------------------------------------------------------------------------- 1 | # JUnit5 System.exit() Extension Version 1.x 2 | 3 | This [JUnit 5 Extension](https://junit.org/junit5/docs/current/user-guide/#extensions) helps you write tests for code 4 | that calls `System.exit()`. 5 | 6 | **Note** These instructions cover Version 1.x of this library, which is no longer supported. Please upgrade 7 | to [Version 2.x](https://github.com/tginsberg/junit5-system-exit) if you can. 8 | 9 | ## Installing 10 | 11 | Copy the following into your `build.gradle` or `build.xml`. 12 | 13 | **Gradle** 14 | 15 | ```groovy 16 | testImplementation("com.ginsberg:junit5-system-exit:1.1.2") 17 | ``` 18 | 19 | **Maven** 20 | 21 | ```xml 22 | 23 | com.ginsberg 24 | junit5-system-exit 25 | 1.1.2 26 | test 27 | 28 | ``` 29 | 30 | ## Notes On Compatibility With Newer JVMs 31 | 32 | **I highly recommend you upgrade this library to version 2.x if you can** 33 | 34 | Starting with **Java 17**, the use of the `SecurityManager` class ([see details of its use](https://todd.ginsberg.com/post/testing-system-exit/)) is deprecated 35 | and will emit a warning. [This is a known issue](https://github.com/tginsberg/junit5-system-exit/issues/10) and once an alternative is available, I will 36 | explore the possibility of upgrading this library. 37 | 38 | Starting with **Java 18**, the default behavior of the JVM was changed from allowing the `SecurityManager` to be changed at runtime to disallowing it by default. 39 | To re-enable the pre-Java 18 behavior, set the system property `java.security.manager` to the string `allow`. 40 | The details [can be read here](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/SecurityManager.html#set-security-manager) if you are interested. 41 | 42 | So, for Gradle: 43 | 44 | ```groovy 45 | test { 46 | systemProperty 'java.security.manager', 'allow' 47 | } 48 | ``` 49 | 50 | And for Maven: 51 | 52 | ```xml 53 | 54 | org.apache.maven.plugins 55 | maven-surefire-plugin 56 | 57 | 58 | allow 59 | 60 | 61 | 62 | ``` 63 | 64 | **Why doesn't `junit5-system-exit` set this property for me?** 65 | 66 | Good question, it certainly could! However, given the decision to terminally deprecate the `SecurityManager`, overriding this decision in a way that is not completely 67 | obvious to the people using is not something I want to do. I feel that users should make a conscious positive decision to override this behavior and take the time 68 | to evaluate the needs of their project. I don't want somebody caught by surprise that the `SecurityManager` is behaving in a non-default way because they happen to be 69 | using a testing library. 70 | 71 | ## Use cases 72 | 73 | **A Test that expects `System.exit()` to be called, with any status code:** 74 | 75 | ```java 76 | public class MyTestCases { 77 | 78 | @Test 79 | @ExpectSystemExit 80 | public void thatSystemExitIsCalled() { 81 | System.exit(1); 82 | } 83 | } 84 | ``` 85 | 86 | **A Test that expects `System.exit(1)` to be called, with a specific status code:** 87 | 88 | ```java 89 | public class MyTestCases { 90 | 91 | @Test 92 | @ExpectSystemExitWithStatus(1) 93 | public void thatSystemExitIsCalled() { 94 | System.exit(1); 95 | } 96 | } 97 | ``` 98 | 99 | **A Test that should not expect `System.exit(1)` to be called, and fails the test if it does:** 100 | 101 | ```java 102 | public class MyTestCases { 103 | 104 | @Test 105 | @FailOnSystemExit 106 | public void thisTestWillFail() { 107 | System.exit(1); 108 | } 109 | } 110 | ``` 111 | 112 | The `@ExpectSystemExit`, `@ExpectSystemExitWithStatus`, and `@FailOnSystemExit` annotations can be applied to methods, classes, or annotations (to act as meta-annotations). 113 | 114 | ## Contributing and Issues 115 | 116 | Please feel free to file issues for change requests or bugs. If you would like to contribute new functionality, please contact me first! 117 | 118 | Copyright © 2021-2024 by Todd Ginsberg 119 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024 Todd Ginsberg 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | import java.io.IOException 26 | import java.net.URI 27 | 28 | plugins { 29 | id("com.adarshr.test-logger") version "4.0.0" 30 | id("jacoco") 31 | id("java-library") 32 | id("org.barfuin.gradle.jacocolog") version "3.1.0" 33 | id("maven-publish") 34 | id("signing") 35 | } 36 | 37 | description = "A JUnit5 Extension to help write tests that call System.exit()" 38 | group = "com.ginsberg" 39 | version = file("VERSION.txt").readLines().first() 40 | 41 | val gitBranch = gitBranch() 42 | val junit5SystemExitVersion = if (gitBranch == "main" || gitBranch.startsWith("release/")) version.toString() 43 | else "${gitBranch.substringAfterLast("/")}-SNAPSHOT" 44 | 45 | val asmVersion by extra("9.7") 46 | val junitVersion by extra("5.11.0") 47 | val junitPlatformLauncherVersion by extra("1.11.0") 48 | 49 | java { 50 | toolchain { 51 | languageVersion = JavaLanguageVersion.of(17) 52 | } 53 | withJavadocJar() 54 | withSourcesJar() 55 | } 56 | 57 | repositories { 58 | mavenCentral() 59 | } 60 | 61 | dependencies { 62 | compileOnly("org.junit.jupiter:junit-jupiter-api:$junitVersion") { 63 | because("This library compiles against JUnit, but consumers will bring their own implementation") 64 | } 65 | 66 | implementation("org.ow2.asm:asm:$asmVersion") { 67 | because("Calls to System.exit() are rewritten in the agent using ASM.") 68 | } 69 | 70 | testRuntimeOnly("org.junit.platform:junit-platform-launcher:$junitPlatformLauncherVersion") { 71 | because("Starting in Gradle 9.0, this needs to be an explicitly declared dependency") 72 | } 73 | 74 | testImplementation("org.assertj:assertj-core:3.26.3") 75 | testImplementation("org.junit.jupiter:junit-jupiter:$junitVersion") 76 | testImplementation("org.junit.jupiter:junit-jupiter-params:$junitVersion") 77 | testImplementation("org.junit.platform:junit-platform-launcher:$junitPlatformLauncherVersion") 78 | } 79 | 80 | publishing { 81 | publications { 82 | create("junit5-system-exit") { 83 | from(components["java"]) 84 | pom { 85 | name = "Junit5 System Exit" 86 | description = project.description 87 | version = junit5SystemExitVersion 88 | url = "https://github.com/tginsberg/junit5-system-exit" 89 | organization { 90 | name = "com.ginsberg" 91 | url = "https://github.com/tginsberg" 92 | } 93 | issueManagement { 94 | system = "GitHub" 95 | url = "https://github.com/tginsberg/junit5-system-exit/issues" 96 | } 97 | licenses { 98 | license { 99 | name = "The Apache License, Version 2.0" 100 | url = "http://www.apache.org/licenses/LICENSE-2.0.txt" 101 | } 102 | } 103 | developers { 104 | developer { 105 | id = "tginsberg" 106 | name = "Todd Ginsberg" 107 | email = "todd@ginsberg.com" 108 | } 109 | } 110 | scm { 111 | connection = "scm:git:https://github.com/tginsberg/junit5-system-exit.git" 112 | developerConnection = "scm:git:https://github.com/tginsberg/junit5-system-exit.git" 113 | url = "https://github.com/tginsberg/junit5-system-exit" 114 | } 115 | } 116 | } 117 | } 118 | repositories { 119 | maven { 120 | url = if (version.toString().endsWith("-SNAPSHOT")) URI("https://oss.sonatype.org/content/repositories/snapshots/") 121 | else URI("https://oss.sonatype.org/service/local/staging/deploy/maven2/") 122 | credentials { 123 | username = System.getenv("SONATYPE_USERNAME") 124 | password = System.getenv("SONATYPE_TOKEN") 125 | } 126 | } 127 | } 128 | } 129 | 130 | signing { 131 | useInMemoryPgpKeys(System.getenv("SONATYPE_SIGNING_KEY") , System.getenv("SONATYPE_SIGNING_PASSPHRASE")) 132 | sign(publishing.publications["junit5-system-exit"]) 133 | } 134 | 135 | tasks { 136 | jacocoTestReport { 137 | dependsOn(test) 138 | reports { 139 | xml.required = true 140 | } 141 | } 142 | 143 | jar { 144 | manifest { 145 | attributes( 146 | "Implementation-Title" to "Junit5 System Exit", 147 | "Implementation-Version" to archiveVersion, 148 | "Premain-Class" to "com.ginsberg.junit.exit.agent.Junit5SystemExitAgent" 149 | ) 150 | } 151 | } 152 | 153 | javadoc { 154 | (options as CoreJavadocOptions).apply { 155 | addStringOption("source", rootProject.java.toolchain.languageVersion.get().toString()) 156 | addStringOption("Xdoclint:none", "-quiet") 157 | } 158 | } 159 | 160 | publish { 161 | doLast { 162 | println("Project Version: $version") 163 | println("Publish Version: $junit5SystemExitVersion") 164 | } 165 | } 166 | 167 | test { 168 | useJUnitPlatform() 169 | dependsOn(jar) 170 | finalizedBy(jacocoTestReport) 171 | jvmArgumentProviders.add(CommandLineArgumentProvider { 172 | listOf("-javaagent:${jar.get().archiveFile.get().asFile.absolutePath}") 173 | }) 174 | 175 | doLast { 176 | println( 177 | """ 178 | Note: Several tests were skipped during this run, by design. 179 | The skipped tests are run manually within other tests, to test instrumenting behavior. 180 | """.trimIndent() 181 | ) 182 | } 183 | } 184 | 185 | } 186 | 187 | fun gitBranch(): String = 188 | ProcessBuilder("git rev-parse --abbrev-ref HEAD".split(" ")) 189 | .redirectOutput(ProcessBuilder.Redirect.PIPE) 190 | .redirectError(ProcessBuilder.Redirect.PIPE) 191 | .start() 192 | .run { 193 | val error = errorStream.bufferedReader().readText() 194 | if (error.isNotEmpty()) throw IOException(error) 195 | inputStream.bufferedReader().readText().trim() 196 | } 197 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tginsberg/junit5-system-exit/90561b449cc314800399c3920d88349c6124ebc0/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024 Todd Ginsberg 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | 25 | distributionBase=GRADLE_USER_HOME 26 | distributionPath=wrapper/dists 27 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip 28 | zipStoreBase=GRADLE_USER_HOME 29 | zipStorePath=wrapper/dists 30 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024 Todd Ginsberg 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | rootProject.name = "junit5-system-exit" 26 | -------------------------------------------------------------------------------- /src/main/java/com/ginsberg/junit/exit/ExitPreventerStrategy.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024 Todd Ginsberg 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | package com.ginsberg.junit.exit; 25 | 26 | public interface ExitPreventerStrategy { 27 | 28 | Integer firstExitStatusCode(); 29 | 30 | default void beforeTest() { 31 | } 32 | 33 | default void afterTest() { 34 | } 35 | 36 | default void resetBetweenTests() { 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/com/ginsberg/junit/exit/ExpectSystemExit.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024 Todd Ginsberg 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | package com.ginsberg.junit.exit; 26 | 27 | import org.junit.jupiter.api.extension.ExtendWith; 28 | 29 | import java.lang.annotation.ElementType; 30 | import java.lang.annotation.Retention; 31 | import java.lang.annotation.RetentionPolicy; 32 | import java.lang.annotation.Target; 33 | 34 | /** 35 | * This is a marker annotation that indicates the given test method or class is expected 36 | * to call System.exit() 37 | */ 38 | @Retention(RetentionPolicy.RUNTIME) 39 | @Target({ElementType.TYPE, ElementType.METHOD, ElementType.ANNOTATION_TYPE}) 40 | @ExtendWith(SystemExitExtension.class) 41 | public @interface ExpectSystemExit { 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/com/ginsberg/junit/exit/ExpectSystemExitWithStatus.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024 Todd Ginsberg 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | package com.ginsberg.junit.exit; 26 | 27 | import org.junit.jupiter.api.extension.ExtendWith; 28 | 29 | import java.lang.annotation.ElementType; 30 | import java.lang.annotation.Retention; 31 | import java.lang.annotation.RetentionPolicy; 32 | import java.lang.annotation.Target; 33 | 34 | /** 35 | * This is a marker annotation that indicates the given test method or class is expected 36 | * to call System.exit() with a specific code 37 | */ 38 | @Retention(RetentionPolicy.RUNTIME) 39 | @Target({ElementType.TYPE, ElementType.METHOD, ElementType.ANNOTATION_TYPE}) 40 | @ExtendWith(SystemExitExtension.class) 41 | public @interface ExpectSystemExitWithStatus { 42 | int value(); 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/com/ginsberg/junit/exit/FailOnSystemExit.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024 Todd Ginsberg 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | package com.ginsberg.junit.exit; 26 | 27 | import org.junit.jupiter.api.extension.ExtendWith; 28 | 29 | import java.lang.annotation.ElementType; 30 | import java.lang.annotation.Retention; 31 | import java.lang.annotation.RetentionPolicy; 32 | import java.lang.annotation.Target; 33 | 34 | /** 35 | * This is a marker annotation that indicates the given test method or class is not expected 36 | * to call System.exit(). By annotating a test or a class with this annotation, we can prevent 37 | * an inadvertent System.exit() call from tearing down the test infrastructure. 38 | */ 39 | @Retention(RetentionPolicy.RUNTIME) 40 | @Target({ElementType.TYPE, ElementType.METHOD, ElementType.ANNOTATION_TYPE}) 41 | @ExtendWith(SystemExitExtension.class) 42 | public @interface FailOnSystemExit { 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/com/ginsberg/junit/exit/SystemExitExtension.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024 Todd Ginsberg 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | package com.ginsberg.junit.exit; 26 | 27 | import com.ginsberg.junit.exit.agent.AgentSystemExitHandlerStrategy; 28 | import com.ginsberg.junit.exit.agent.DoNotRewriteExitCalls; 29 | import org.junit.jupiter.api.extension.AfterEachCallback; 30 | import org.junit.jupiter.api.extension.BeforeEachCallback; 31 | import org.junit.jupiter.api.extension.ExtensionContext; 32 | import org.junit.jupiter.api.extension.TestExecutionExceptionHandler; 33 | 34 | import java.lang.annotation.Annotation; 35 | import java.util.Optional; 36 | 37 | import static org.junit.jupiter.api.Assertions.assertEquals; 38 | import static org.junit.jupiter.api.Assertions.assertNotNull; 39 | import static org.junit.jupiter.api.Assertions.assertNull; 40 | import static org.junit.platform.commons.support.AnnotationSupport.findAnnotation; 41 | 42 | /** 43 | * Entry point for JUnit tests. This class is responsible for installing the preferred `ExitPreventerStrategy` 44 | * and interpreting the results. 45 | */ 46 | @DoNotRewriteExitCalls 47 | public class SystemExitExtension implements BeforeEachCallback, AfterEachCallback, TestExecutionExceptionHandler { 48 | private Integer expectedStatusCode; 49 | private boolean failOnSystemExit; 50 | private final ExitPreventerStrategy exitPreventerStrategy; 51 | 52 | public SystemExitExtension() { 53 | if(AgentSystemExitHandlerStrategy.isLoadedFromAgent()) { 54 | exitPreventerStrategy = new AgentSystemExitHandlerStrategy(); 55 | } else { 56 | throw new IllegalStateException("SystemExitExtension Agent not loaded, please see documentation"); 57 | } 58 | } 59 | 60 | @Override 61 | public void afterEach(ExtensionContext context) { 62 | exitPreventerStrategy.afterTest(); 63 | 64 | try { 65 | if (failOnSystemExit) { 66 | assertNull( 67 | exitPreventerStrategy.firstExitStatusCode(), 68 | "Unexpected System.exit(" + exitPreventerStrategy.firstExitStatusCode() + ") caught" 69 | ); 70 | } else if (expectedStatusCode == null) { 71 | assertNotNull( 72 | exitPreventerStrategy.firstExitStatusCode(), 73 | "Expected System.exit() to be called, but it was not" 74 | ); 75 | } else { 76 | assertEquals( 77 | expectedStatusCode, 78 | exitPreventerStrategy.firstExitStatusCode(), 79 | "Expected System.exit(" + expectedStatusCode + ") to be called, but it was not." 80 | ); 81 | } 82 | } finally { 83 | // Clear state so if this is run as part of a @ParameterizedTest, the next time through we'll have the 84 | // correct state 85 | expectedStatusCode = null; 86 | failOnSystemExit = false; 87 | exitPreventerStrategy.resetBetweenTests(); 88 | } 89 | } 90 | 91 | @Override 92 | public void beforeEach(final ExtensionContext context) { 93 | // Should we fail on a System.exit() rather than letting it bubble out? 94 | failOnSystemExit = getAnnotation(context, FailOnSystemExit.class).isPresent(); 95 | 96 | // Get the expected exit status code, if any 97 | getAnnotation(context, ExpectSystemExitWithStatus.class).ifPresent(code -> expectedStatusCode = code.value()); 98 | 99 | // Allow the strategy to do pre-test housekeeping 100 | exitPreventerStrategy.beforeTest(); 101 | } 102 | 103 | /** 104 | * This is here so we can catch exceptions thrown by the `ExitPreventerStrategy` and prevent them from 105 | * stopping the annotated test. If anything other than our own exception comes through, throw it because 106 | * the `ExitPreventerStrategy` has encountered some other test-failing exception. 107 | * 108 | * @param context the current extension context; never {@code null} 109 | * @param throwable the {@code Throwable} to handle; never {@code null} 110 | * @throws Throwable if the throwable argument is not a SystemExitPreventedException 111 | */ 112 | @Override 113 | public void handleTestExecutionException( 114 | final ExtensionContext context, 115 | final Throwable throwable 116 | ) throws Throwable { 117 | if (!(throwable instanceof SystemExitPreventedException)) { 118 | throw throwable; 119 | } 120 | } 121 | 122 | // Find the annotation on a method, or failing that, a class. 123 | private Optional getAnnotation( 124 | final ExtensionContext context, 125 | final Class annotationClass 126 | ) { 127 | final Optional method = findAnnotation(context.getTestMethod(), annotationClass); 128 | return method.isPresent() ? method : findAnnotation(context.getTestClass(), annotationClass); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/main/java/com/ginsberg/junit/exit/SystemExitPreventedException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024 Todd Ginsberg 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | package com.ginsberg.junit.exit; 26 | 27 | /** 28 | * A marker exception so we know that a System.exit() call was intercepted and prevented. 29 | */ 30 | public class SystemExitPreventedException extends SecurityException { 31 | 32 | private final int statusCode; 33 | 34 | public SystemExitPreventedException(int statusCode) { 35 | this.statusCode = statusCode; 36 | } 37 | 38 | public int getStatusCode() { 39 | return statusCode; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/com/ginsberg/junit/exit/agent/AgentSystemExitHandlerStrategy.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024 Todd Ginsberg 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | package com.ginsberg.junit.exit.agent; 26 | 27 | import com.ginsberg.junit.exit.ExitPreventerStrategy; 28 | import com.ginsberg.junit.exit.SystemExitPreventedException; 29 | 30 | @DoNotRewriteExitCalls 31 | public class AgentSystemExitHandlerStrategy implements ExitPreventerStrategy { 32 | 33 | private static Integer firstExitStatusCode; 34 | private static boolean loadedFromAgent = false; 35 | private static boolean isRunningTest = false; 36 | 37 | public static void handleExit(final int status) { 38 | if(isRunningTest) { 39 | if (firstExitStatusCode == null) { 40 | firstExitStatusCode = status; 41 | } 42 | throw new SystemExitPreventedException(firstExitStatusCode); 43 | } else { 44 | System.exit(status); 45 | } 46 | } 47 | 48 | public static void agentInit() { 49 | loadedFromAgent = true; 50 | } 51 | 52 | public static boolean isLoadedFromAgent() { 53 | return loadedFromAgent; 54 | } 55 | 56 | @Override 57 | public Integer firstExitStatusCode() { 58 | return firstExitStatusCode; 59 | } 60 | 61 | @Override 62 | public void beforeTest() { 63 | isRunningTest = true; 64 | } 65 | 66 | @Override 67 | public void afterTest() { 68 | isRunningTest = false; 69 | } 70 | 71 | @Override 72 | public void resetBetweenTests() { 73 | firstExitStatusCode = null; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/main/java/com/ginsberg/junit/exit/agent/DoNotRewriteExitCalls.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024 Todd Ginsberg 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | package com.ginsberg.junit.exit.agent; 25 | 26 | import java.lang.annotation.ElementType; 27 | import java.lang.annotation.Retention; 28 | import java.lang.annotation.RetentionPolicy; 29 | import java.lang.annotation.Target; 30 | 31 | @Retention(RetentionPolicy.RUNTIME) 32 | @Target({ElementType.TYPE, ElementType.METHOD}) 33 | public @interface DoNotRewriteExitCalls { 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/ginsberg/junit/exit/agent/Junit5SystemExitAgent.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024 Todd Ginsberg 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | package com.ginsberg.junit.exit.agent; 25 | 26 | import org.objectweb.asm.AnnotationVisitor; 27 | import org.objectweb.asm.ClassReader; 28 | import org.objectweb.asm.ClassVisitor; 29 | import org.objectweb.asm.ClassWriter; 30 | import org.objectweb.asm.MethodVisitor; 31 | import org.objectweb.asm.Opcodes; 32 | 33 | import java.lang.instrument.ClassFileTransformer; 34 | import java.lang.instrument.Instrumentation; 35 | import java.security.ProtectionDomain; 36 | import java.util.Set; 37 | import java.util.logging.Logger; 38 | 39 | public class Junit5SystemExitAgent { 40 | 41 | private final static Logger log = Logger.getLogger(Junit5SystemExitAgent.class.getName()); 42 | 43 | private Junit5SystemExitAgent() { 44 | 45 | } 46 | 47 | private final static Set disallowedClassPrefixes = Set.of( 48 | "com/sun/", "java/", "jdk/", "worker/org/gradle/", "sun/" 49 | ); 50 | 51 | private final static String SKIP_ANNOTATION = "/DoNotRewriteExitCalls;"; 52 | 53 | public static void premain(final String agentArgs, final Instrumentation inst) { 54 | AgentSystemExitHandlerStrategy.agentInit(); 55 | inst.addTransformer(new SystemExitClassTransformer()); 56 | } 57 | 58 | static class SystemExitClassTransformer implements ClassFileTransformer { 59 | @Override 60 | public byte[] transform(final ClassLoader loader, 61 | final String className, 62 | final Class classBeingRedefined, 63 | final ProtectionDomain protectionDomain, 64 | final byte[] classFileBuffer) { 65 | if (disallowedClassPrefixes.stream().anyMatch(className::startsWith)) { 66 | return null; 67 | } 68 | final ClassReader classReader = new ClassReader(classFileBuffer); 69 | final ClassWriter classWriter = new ClassWriter(classReader, 0); 70 | classReader.accept(new SystemExitClassVisitor(className, classWriter), 0); 71 | return classWriter.toByteArray(); 72 | } 73 | } 74 | 75 | static class SystemExitClassVisitor extends ClassVisitor { 76 | private final String className; 77 | 78 | public SystemExitClassVisitor(final String className, final ClassVisitor cv) { 79 | super(Opcodes.ASM9, cv); 80 | this.className = className; 81 | } 82 | 83 | private boolean hasSkipAnnotation = false; 84 | 85 | @Override 86 | public AnnotationVisitor visitAnnotation(final String descriptor, final boolean visible) { 87 | if(descriptor.endsWith(SKIP_ANNOTATION)) { 88 | hasSkipAnnotation = true; 89 | } 90 | return super.visitAnnotation(descriptor, visible); 91 | } 92 | 93 | @Override 94 | public MethodVisitor visitMethod(final int access, 95 | final String name, 96 | final String descriptor, 97 | final String signature, 98 | final String[] exceptions) { 99 | if(hasSkipAnnotation) { 100 | return super.visitMethod(access, name, descriptor, signature, exceptions); 101 | } 102 | return new SystemExitMethodVisitor( 103 | className, 104 | name, 105 | super.visitMethod(access, name, descriptor, signature, exceptions) 106 | ); 107 | } 108 | } 109 | 110 | static class SystemExitMethodVisitor extends MethodVisitor { 111 | private boolean hasSkipAnnotation = false; 112 | private final String className; 113 | private final String methodName; 114 | 115 | public SystemExitMethodVisitor(final String className, final String methodName, final MethodVisitor mv) { 116 | super(Opcodes.ASM9, mv); 117 | this.className = className; 118 | this.methodName = methodName; 119 | } 120 | 121 | @Override 122 | public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) { 123 | if(descriptor.endsWith(SKIP_ANNOTATION)) { 124 | hasSkipAnnotation = true; 125 | } 126 | return super.visitAnnotation(descriptor, visible); 127 | } 128 | 129 | @Override 130 | public void visitMethodInsn(final int opcode, 131 | final String owner, 132 | final String name, 133 | final String descriptor, 134 | final boolean isInterface) { 135 | if (!hasSkipAnnotation && owner.equals("java/lang/System") && name.equals("exit")) { 136 | log.fine("Replacing System.exit() call in: " + className + "." + methodName); 137 | super.visitMethodInsn( 138 | Opcodes.INVOKESTATIC, 139 | "com/ginsberg/junit/exit/agent/AgentSystemExitHandlerStrategy", 140 | "handleExit", 141 | descriptor, 142 | false 143 | ); 144 | } else { 145 | if(hasSkipAnnotation) { 146 | log.fine("Not replacing System.exit() call in: " + className + "." + methodName + " due to presence of 'skip this' annotation"); 147 | } 148 | super.visitMethodInsn(opcode, owner, name, descriptor, isInterface); 149 | } 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/main/java/com/ginsberg/junit/exit/assertions/SystemExitAssertion.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024 Todd Ginsberg 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | package com.ginsberg.junit.exit.assertions; 26 | 27 | import com.ginsberg.junit.exit.ExitPreventerStrategy; 28 | import com.ginsberg.junit.exit.SystemExitPreventedException; 29 | import com.ginsberg.junit.exit.agent.AgentSystemExitHandlerStrategy; 30 | 31 | import static org.junit.jupiter.api.Assertions.assertEquals; 32 | import static org.junit.jupiter.api.Assertions.assertTrue; 33 | import static org.junit.jupiter.api.Assertions.fail; 34 | 35 | 36 | public class SystemExitAssertion { 37 | private final SystemExitPreventedException theException; 38 | 39 | public SystemExitAssertion(SystemExitPreventedException theException) { 40 | this.theException = theException; 41 | } 42 | 43 | public static SystemExitAssertion assertThatCallsSystemExit(final Runnable function) { 44 | return new SystemExitAssertion(catchSystemExitFrom(function)).calledSystemExit(); 45 | } 46 | 47 | public static void assertThatDoesNotCallSystemExit(final Runnable function) { 48 | new SystemExitAssertion(catchSystemExitFrom(function)).didNotCallSystemExit(); 49 | } 50 | 51 | private SystemExitAssertion calledSystemExit() { 52 | if (theException == null) { 53 | fail("Expected call to System.exit() did not happen"); 54 | } 55 | return this; 56 | } 57 | 58 | private SystemExitAssertion didNotCallSystemExit() { 59 | if (theException != null) { 60 | fail("Unexpected call to System.exit() with exit code " + theException.getStatusCode(), theException); 61 | } 62 | return this; 63 | } 64 | 65 | private static SystemExitPreventedException catchSystemExitFrom(final Runnable function) { 66 | final ExitPreventerStrategy exitPreventerStrategy = new AgentSystemExitHandlerStrategy(); 67 | try { 68 | exitPreventerStrategy.resetBetweenTests(); 69 | exitPreventerStrategy.beforeTest(); 70 | function.run(); 71 | } catch (SystemExitPreventedException e) { 72 | return e; 73 | } catch (Exception e) { 74 | throw new RuntimeException(e); 75 | } finally { 76 | exitPreventerStrategy.afterTest(); 77 | } 78 | return null; 79 | } 80 | 81 | public SystemExitAssertion withExitCode(final int code) { 82 | assertEquals(code, theException.getStatusCode(), "Wrong exit code found"); 83 | return this; 84 | } 85 | 86 | public SystemExitAssertion withExitCodeInRange(final int startInclusive, final int endInclusive) { 87 | assertTrue(startInclusive < endInclusive, "Start must come before end"); 88 | final int code = theException.getStatusCode(); 89 | assertTrue( 90 | startInclusive <= code && code <= endInclusive, 91 | "Exit code expected in range (" + startInclusive + " .. " + endInclusive + ") but was " + code 92 | ); 93 | return this; 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /src/test/java/com/ginsberg/junit/exit/ExpectSystemExitTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024 Todd Ginsberg 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | package com.ginsberg.junit.exit; 26 | 27 | import com.ginsberg.junit.exit.agent.AgentSystemExitHandlerStrategy; 28 | import org.junit.jupiter.api.DisplayName; 29 | import org.junit.jupiter.api.Nested; 30 | import org.junit.jupiter.api.Test; 31 | import org.junit.jupiter.api.condition.EnabledIfSystemProperty; 32 | 33 | import java.lang.reflect.Field; 34 | 35 | import static com.ginsberg.junit.exit.TestUtils.assertTestFails; 36 | import static org.junit.jupiter.api.Assertions.assertThrows; 37 | 38 | class ExpectSystemExitTest { 39 | 40 | @Nested 41 | @DisplayName("Success Cases") 42 | class HappyPath { 43 | @Test 44 | @DisplayName("System.exit() is caught and detected") 45 | @ExpectSystemExit 46 | void detectSystemExit() { 47 | System.exit(1234); 48 | } 49 | 50 | @Test 51 | @DisplayName("System.exit() is caught and detected within a thread") 52 | @ExpectSystemExit 53 | void detectSystemExitInThread() throws InterruptedException { 54 | final Thread t = new Thread(() -> System.exit(1234)); 55 | t.start(); 56 | t.join(); 57 | } 58 | 59 | @Nested 60 | @DisplayName("Class Success") 61 | @ExpectSystemExit 62 | class ExpectedSuccessClassLevel { 63 | @Test 64 | @DisplayName("Method in class annotated with ExpectSystemExit succeeds") 65 | void classLevelExpect() { 66 | System.exit(123456); 67 | } 68 | } 69 | } 70 | 71 | @Nested 72 | @DisplayName("Failure Cases") 73 | class FailurePath { 74 | @Test 75 | @DisplayName("System.exit() is expected for method but not called") 76 | void expectSystemExitThatDoesNotHappenMethod() { 77 | assertTestFails(ExpectedFailuresAtTestLevelTest.class, "doNotCallSystemExit"); 78 | } 79 | 80 | @Test 81 | @DisplayName("System.exit() is expected for class but not called") 82 | void expectSystemExitThatDoesNotHappenClass() { 83 | assertTestFails(ExpectedFailuresAtClassLevelTest.class); 84 | } 85 | 86 | @Test 87 | @DisplayName("Extension fails when agent instrumentation has not run") 88 | void expectAgentInstrumentation() throws NoSuchFieldException, IllegalAccessException { 89 | // Arrange 90 | final Field field = AgentSystemExitHandlerStrategy.class.getDeclaredField("loadedFromAgent"); 91 | field.setAccessible(true); 92 | field.setBoolean(AgentSystemExitHandlerStrategy.class, false); 93 | 94 | // Act 95 | try { 96 | assertThrows(IllegalStateException.class, SystemExitExtension::new); 97 | } finally { 98 | field.setBoolean(AgentSystemExitHandlerStrategy.class, true); 99 | } 100 | } 101 | } 102 | 103 | @SuppressWarnings("JUnitMalformedDeclaration") 104 | @EnabledIfSystemProperty(named = "running_within_test", matches = "true") 105 | static class ExpectedFailuresAtTestLevelTest { 106 | @Test 107 | @ExpectSystemExit 108 | void doNotCallSystemExit() { 109 | // Done! :) 110 | } 111 | } 112 | 113 | @SuppressWarnings("JUnitMalformedDeclaration") 114 | @EnabledIfSystemProperty(named = "running_within_test", matches = "true") 115 | @ExpectSystemExit 116 | static class ExpectedFailuresAtClassLevelTest { 117 | @Test 118 | void doNotCallSystemExit() { 119 | // Done! :) 120 | } 121 | } 122 | 123 | } 124 | -------------------------------------------------------------------------------- /src/test/java/com/ginsberg/junit/exit/ExpectSystemExitWithStatusTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024 Todd Ginsberg 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | package com.ginsberg.junit.exit; 26 | 27 | import org.junit.jupiter.api.DisplayName; 28 | import org.junit.jupiter.api.Nested; 29 | import org.junit.jupiter.api.Test; 30 | import org.junit.jupiter.api.condition.EnabledIfSystemProperty; 31 | 32 | import static com.ginsberg.junit.exit.TestUtils.assertTestFails; 33 | import static com.ginsberg.junit.exit.TestUtils.assertTestFailsExceptionally; 34 | 35 | class ExpectSystemExitWithStatusTest { 36 | 37 | @Nested 38 | @DisplayName("Success Cases") 39 | class HappyPath { 40 | @Test 41 | @DisplayName("System.exit(1234) is caught and detected") 42 | @ExpectSystemExitWithStatus(1234) 43 | void detectSystemExit() { 44 | System.exit(1234); 45 | } 46 | 47 | @Test 48 | @DisplayName("System.exit(1234) is caught and detected within a thread") 49 | @ExpectSystemExitWithStatus(1234) 50 | void detectSystemExitInThread() throws InterruptedException { 51 | final Thread t = new Thread(() -> System.exit(1234)); 52 | t.start(); 53 | t.join(); 54 | } 55 | 56 | @Nested 57 | @DisplayName("Class Success") 58 | @ExpectSystemExitWithStatus(123456) 59 | class ExpectedSuccessClassLevel { 60 | @Test 61 | @DisplayName("Method in class annotated with ExpectSystemExitWithStatus(123456) succeeds") 62 | void classLevelExpect() { 63 | System.exit(123456); 64 | } 65 | } 66 | } 67 | 68 | @Nested 69 | @DisplayName("Failure Cases") 70 | class FailurePath { 71 | @Test 72 | @DisplayName("System.exit(1234) is expected but not called at all, on method") 73 | void expectSystemExitThatDoesNotHappenMethod() { 74 | assertTestFails(ExpectedFailuresAtMethodLevelTest.class, "doNotCallSystemExit"); 75 | } 76 | 77 | @Test 78 | @DisplayName("System.exit(1234) is expected but another code was used, on method") 79 | void expectSystemExitWithDifferentCodeMethod() { 80 | assertTestFails(ExpectedFailuresAtMethodLevelTest.class, "exitWith4567"); 81 | } 82 | 83 | @Test 84 | @DisplayName("System.exit(1234) is expected but not called at all, on method") 85 | void expectSystemExitThatDoesNotHappenClass() { 86 | assertTestFails(ExpectedFailuresAtClassLevelNoExitTest.class); 87 | } 88 | 89 | @Test 90 | @DisplayName("System.exit(1234) is expected but another code was used, on class") 91 | void expectSystemExitWithDifferentCodeClass() { 92 | assertTestFails(ExpectedFailuresAtClassLevelDifferentCodeTest.class); 93 | } 94 | 95 | @Test 96 | @DisplayName("Exceptions other than SystemExitPreventedException bubble out") 97 | void expectNonSystemExitExceptionsToBubbleOutAndFailTest() { 98 | assertTestFailsExceptionally( 99 | ExpectedExceptionalFailureTest.class, 100 | "throwInsteadOfExit", 101 | IllegalStateException.class 102 | ); 103 | } 104 | } 105 | 106 | @SuppressWarnings("JUnitMalformedDeclaration") 107 | @EnabledIfSystemProperty(named = "running_within_test", matches = "true") 108 | static class ExpectedFailuresAtMethodLevelTest { 109 | 110 | @Test 111 | @ExpectSystemExitWithStatus(1234) 112 | void doNotCallSystemExit() { 113 | // Done! :) 114 | } 115 | 116 | @Test 117 | @ExpectSystemExitWithStatus(1234) 118 | void exitWith4567() { 119 | System.exit(4567); 120 | } 121 | } 122 | 123 | @SuppressWarnings("JUnitMalformedDeclaration") 124 | @EnabledIfSystemProperty(named = "running_within_test", matches = "true") 125 | @ExpectSystemExitWithStatus(1234) 126 | static class ExpectedFailuresAtClassLevelNoExitTest { 127 | 128 | @Test 129 | void doNotCallSystemExit() { 130 | // Done! :) 131 | } 132 | 133 | } 134 | 135 | @SuppressWarnings("JUnitMalformedDeclaration") 136 | @EnabledIfSystemProperty(named = "running_within_test", matches = "true") 137 | @ExpectSystemExitWithStatus(1234) 138 | static class ExpectedFailuresAtClassLevelDifferentCodeTest { 139 | @Test 140 | void exitWith4567() { 141 | System.exit(4567); 142 | } 143 | } 144 | 145 | @SuppressWarnings("JUnitMalformedDeclaration") 146 | @EnabledIfSystemProperty(named = "running_within_test", matches = "true") 147 | @ExpectSystemExit 148 | static class ExpectedExceptionalFailureTest { 149 | @Test 150 | void throwInsteadOfExit() { 151 | throw new IllegalStateException(); 152 | } 153 | } 154 | 155 | } 156 | -------------------------------------------------------------------------------- /src/test/java/com/ginsberg/junit/exit/FailOnSystemExitTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024 Todd Ginsberg 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | package com.ginsberg.junit.exit; 26 | 27 | import org.junit.jupiter.api.DisplayName; 28 | import org.junit.jupiter.api.Test; 29 | import org.junit.jupiter.api.condition.EnabledIfSystemProperty; 30 | 31 | import static com.ginsberg.junit.exit.TestUtils.assertTestFails; 32 | import static com.ginsberg.junit.exit.TestUtils.assertTestSucceeds; 33 | 34 | class FailOnSystemExitTest { 35 | 36 | @Test 37 | @DisplayName("@FailOnSystemExit on method - exception caught and fails test") 38 | void failOnSystemExitOnMethod() { 39 | assertTestFails(FailOnSystemExitAtTestLevelTest.class, "callsSystemExit"); 40 | } 41 | 42 | @Test 43 | @DisplayName("@FailOnSystemExit on method - System.exit not called") 44 | void succeedWhenNotCallingSystemExitInMethod() { 45 | assertTestSucceeds(FailOnSystemExitAtTestLevelTest.class, "doesNotCallSystemExit"); 46 | } 47 | 48 | @Test 49 | @DisplayName("@FailOnSystemExit on class - exception caught and fails test") 50 | void failOnSystemExitOnClass() { 51 | assertTestFails(FailOnSystemExitAtClassLevelTest.class); 52 | } 53 | 54 | @Test 55 | @DisplayName("@FailOnSystemExit on class - System.exit not called") 56 | void succeedWhenNotCallingSystemExitOnClass() { 57 | assertTestSucceeds(FailOnSystemExitAtClassLevelWithoutSystemExitTest.class); 58 | } 59 | 60 | @SuppressWarnings("JUnitMalformedDeclaration") 61 | @EnabledIfSystemProperty(named = "running_within_test", matches = "true") 62 | static class FailOnSystemExitAtTestLevelTest { 63 | @Test 64 | @FailOnSystemExit 65 | void callsSystemExit() { 66 | System.exit(42); 67 | } 68 | 69 | @Test 70 | @FailOnSystemExit 71 | void doesNotCallSystemExit() { 72 | // Nothing to do 73 | } 74 | } 75 | 76 | @SuppressWarnings("JUnitMalformedDeclaration") 77 | @EnabledIfSystemProperty(named = "running_within_test", matches = "true") 78 | @FailOnSystemExit 79 | static class FailOnSystemExitAtClassLevelTest { 80 | @Test 81 | void callsSystemExit() { 82 | System.exit(42); 83 | } 84 | } 85 | 86 | @SuppressWarnings("JUnitMalformedDeclaration") 87 | @EnabledIfSystemProperty(named = "running_within_test", matches = "true") 88 | @FailOnSystemExit 89 | static class FailOnSystemExitAtClassLevelWithoutSystemExitTest { 90 | @Test 91 | void doesNotCallSystemExit() { 92 | // Nothing to do 93 | } 94 | } 95 | 96 | 97 | } 98 | -------------------------------------------------------------------------------- /src/test/java/com/ginsberg/junit/exit/TestUtils.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024 Todd Ginsberg 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | package com.ginsberg.junit.exit; 26 | 27 | import org.junit.platform.launcher.LauncherDiscoveryRequest; 28 | import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder; 29 | import org.junit.platform.launcher.core.LauncherFactory; 30 | import org.junit.platform.launcher.listeners.SummaryGeneratingListener; 31 | import org.junit.platform.launcher.listeners.TestExecutionSummary; 32 | 33 | import java.util.HashSet; 34 | import java.util.Set; 35 | 36 | import static org.junit.jupiter.api.Assertions.assertEquals; 37 | import static org.junit.jupiter.api.Assertions.fail; 38 | import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; 39 | import static org.junit.platform.engine.discovery.DiscoverySelectors.selectMethod; 40 | 41 | class TestUtils { 42 | 43 | static void assertTestFails(final Class clazz, final String testMethod) { 44 | final SummaryGeneratingListener listener = executeTest(clazz, testMethod); 45 | 46 | assertEquals(1, listener.getSummary().getTestsFoundCount(), "Should have found one test"); 47 | assertEquals(1, listener.getSummary().getTestsFailedCount(), "Single test should have failed"); 48 | } 49 | 50 | static void assertTestFails(final Class clazz) { 51 | final SummaryGeneratingListener listener = executeTest(clazz, null); 52 | 53 | assertEquals(1, listener.getSummary().getTestsFoundCount(), "Should have found one test"); 54 | assertEquals(1, listener.getSummary().getTestsFailedCount(), "Single test should have failed"); 55 | } 56 | 57 | static void assertTestFailsExceptionally( 58 | final Class clazz, 59 | final String testMethod, 60 | final Class expectedExceptionClass 61 | ) { 62 | final SummaryGeneratingListener listener = executeTest(clazz, testMethod); 63 | assertEquals(1, listener.getSummary().getFailures().size(), "Test did not finish exceptionally"); 64 | assertEquals( 65 | expectedExceptionClass, 66 | listener.getSummary().getFailures().get(0).getException().getClass(), 67 | "Failed exceptionally but not because of the expected exception" 68 | ); 69 | 70 | assertEquals(1, listener.getSummary().getTestsFoundCount(), "Should have found one test"); 71 | assertEquals(1, listener.getSummary().getTestsFailedCount(), "Single test should have failed"); 72 | } 73 | 74 | static void assertParameterizedTestFails(final Class clazz, final Boolean... expectedSuccess) { 75 | final SummaryGeneratingListener listener = executeTest(clazz, null); 76 | final Set failedTestNumbers = new HashSet<>(); 77 | for(final TestExecutionSummary.Failure failure : listener.getSummary().getFailures()) { 78 | failedTestNumbers.add( 79 | Integer.parseInt(failure.getTestIdentifier().getDisplayName()) -1 80 | ); 81 | } 82 | 83 | assertEquals(expectedSuccess.length, listener.getSummary().getTestsFoundCount(), "Wrong number of tests found"); 84 | for(int testNumber = 0; testNumber < expectedSuccess.length; testNumber++) { 85 | if(expectedSuccess[testNumber] && failedTestNumbers.contains(testNumber)) { 86 | fail(String.format("Test %d should have succeeded, but didn't", testNumber)); 87 | } else if(!expectedSuccess[testNumber] && !failedTestNumbers.contains(testNumber)) { 88 | fail(String.format("Test %d should have failed, but didn't", testNumber)); 89 | } 90 | } 91 | 92 | } 93 | 94 | static void assertTestSucceeds(final Class clazz, final String testMethod) { 95 | final SummaryGeneratingListener listener = executeTest(clazz, testMethod); 96 | 97 | assertEquals(1, listener.getSummary().getTestsFoundCount(), "Should have found one test"); 98 | assertEquals(1, listener.getSummary().getTestsSucceededCount(), "Single test should have succeeded"); 99 | } 100 | 101 | static void assertTestSucceeds(final Class clazz) { 102 | final SummaryGeneratingListener listener = executeTest(clazz, null); 103 | 104 | assertEquals(1, listener.getSummary().getTestsFoundCount(), "Should have found one test"); 105 | assertEquals(1, listener.getSummary().getTestsSucceededCount(), "Single test should have succeeded"); 106 | } 107 | 108 | /** 109 | * Execute the given test and then return a summary of its execution. This is used for tests that 110 | * succeed when other tests fail ("Test that a test decorated with X fails when...") 111 | */ 112 | private static SummaryGeneratingListener executeTest(final Class clazz, final String testMethod) { 113 | final SummaryGeneratingListener listener = new SummaryGeneratingListener(); 114 | try { 115 | System.setProperty("running_within_test", "true"); 116 | final LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request() 117 | .selectors( 118 | testMethod == null ? 119 | selectClass(clazz) : 120 | selectMethod(clazz, testMethod) 121 | ) 122 | .build(); 123 | 124 | LauncherFactory.create().execute(request, listener); 125 | return listener; 126 | } finally { 127 | System.clearProperty("running_within_test"); 128 | } 129 | } 130 | 131 | } 132 | -------------------------------------------------------------------------------- /src/test/java/com/ginsberg/junit/exit/WithParameterizedTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024 Todd Ginsberg 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | package com.ginsberg.junit.exit; 26 | 27 | import org.junit.jupiter.api.DisplayName; 28 | import org.junit.jupiter.api.Test; 29 | import org.junit.jupiter.api.condition.EnabledIfSystemProperty; 30 | import org.junit.jupiter.params.ParameterizedTest; 31 | import org.junit.jupiter.params.provider.ValueSource; 32 | 33 | import static com.ginsberg.junit.exit.TestUtils.assertParameterizedTestFails; 34 | 35 | public class WithParameterizedTest { 36 | 37 | @Test 38 | @DisplayName("@ParameterizedTest on method should reset state between tests") 39 | void failOnSystemExitOnClass() { 40 | assertParameterizedTestFails(WithParameterizedTest.SucceedsAndThenFailsTest.class, true, false, true); 41 | } 42 | 43 | @EnabledIfSystemProperty(named = "running_within_test", matches = "true") 44 | static class SucceedsAndThenFailsTest { 45 | @ParameterizedTest(name = "{index}") 46 | @ValueSource(booleans = {true, false, true}) 47 | @ExpectSystemExit 48 | public void testBasicAssumptions(boolean shouldSucceed) { 49 | if(shouldSucceed) { 50 | System.exit(1); // This test expects a system exit to succeed 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/test/java/com/ginsberg/junit/exit/assertions/SystemExitAssertionTest.java: -------------------------------------------------------------------------------- 1 | package com.ginsberg.junit.exit.assertions; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.opentest4j.AssertionFailedError; 5 | 6 | import static com.ginsberg.junit.exit.assertions.SystemExitAssertion.assertThatCallsSystemExit; 7 | import static com.ginsberg.junit.exit.assertions.SystemExitAssertion.assertThatDoesNotCallSystemExit; 8 | import static org.assertj.core.api.Assertions.assertThat; 9 | import static org.junit.jupiter.api.Assertions.assertEquals; 10 | import static org.junit.jupiter.api.Assertions.fail; 11 | 12 | class SystemExitAssertionTest { 13 | 14 | @Test 15 | void catchesExit() { 16 | assertThatCallsSystemExit(() -> System.exit(1)); 17 | } 18 | 19 | @Test 20 | void catchesExitWithCode() { 21 | assertThatCallsSystemExit(() -> System.exit(2)).withExitCode(2); 22 | } 23 | 24 | @Test 25 | void catchesExitWithCodeInRange() { 26 | assertThatCallsSystemExit(() -> System.exit(3)).withExitCodeInRange(1, 3); 27 | } 28 | 29 | @Test 30 | void catchesMultipleExits() { 31 | assertThatCallsSystemExit(() -> { 32 | System.exit(4); 33 | System.exit(5); 34 | System.exit(6); 35 | }).withExitCode(4); 36 | } 37 | 38 | @Test 39 | void exitCodeDoesNotMatch() { 40 | try { 41 | assertThatCallsSystemExit(() -> System.exit(5)).withExitCode(6); 42 | fail("Should have failed test when System.exit was not called but expected"); 43 | } catch (AssertionFailedError e) { 44 | assertThat(e.getMessage()).startsWith("Wrong exit code found"); 45 | } 46 | } 47 | 48 | @Test 49 | void exitCodeNotInRangeHigh() { 50 | try { 51 | assertThatCallsSystemExit(() -> System.exit(7)).withExitCodeInRange(1, 6); 52 | fail("Should have failed test when System.exit was not in range"); 53 | } catch (AssertionFailedError e) { 54 | assertThat(e.getMessage()).startsWith("Exit code expected in range (1 .. 6) but was 7"); 55 | } 56 | } 57 | 58 | @Test 59 | void exitCodeNotInRangeLow() { 60 | try { 61 | assertThatCallsSystemExit(() -> System.exit(8)).withExitCodeInRange(9, 11); 62 | fail("Should have failed test when System.exit was not in range"); 63 | } catch (AssertionFailedError e) { 64 | assertThat(e.getMessage()).startsWith("Exit code expected in range (9 .. 11) but was 8"); 65 | } 66 | } 67 | 68 | @Test 69 | void expectingNoExit() { 70 | assertThatDoesNotCallSystemExit(System::currentTimeMillis); 71 | } 72 | 73 | @Test 74 | void expectingNoExitWhenExitHappens() { 75 | try { 76 | assertThatDoesNotCallSystemExit(() -> 77 | System.exit(9) 78 | ); 79 | fail("Should have failed test when System.exit was called but not expected"); 80 | } catch (AssertionFailedError e) { 81 | assertThat(e.getMessage()).startsWith("Unexpected call to System.exit()"); 82 | } 83 | } 84 | 85 | @Test 86 | void expectingSystemExitButSomethingElseThrown() { 87 | try { 88 | assertThatCallsSystemExit(() -> { 89 | throw new IllegalStateException(); 90 | }).withExitCode(10); 91 | } catch (final Exception e) { 92 | assertEquals(IllegalStateException.class, e.getCause().getClass()); 93 | } 94 | } 95 | 96 | @Test 97 | void failsWhenNoExit() { 98 | try { 99 | assertThatCallsSystemExit(System::currentTimeMillis).withExitCode(11); 100 | fail("Should have failed test when System.exit was not called but expected"); 101 | } catch (AssertionFailedError e) { 102 | assertThat(e.getMessage()).startsWith("Expected call to System.exit() did not happen"); 103 | } 104 | } 105 | } --------------------------------------------------------------------------------