This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
The following commit(s) were added to refs/heads/master by this push:
new 16ba9168f2 GROOVY-11997: Add @ForkedJvm and @ExpectedToFail JUnit
extensions to groovy-test-junit6
16ba9168f2 is described below
commit 16ba9168f23cbcd29c64fd66e535383295bfc82f
Author: Paul King <[email protected]>
AuthorDate: Wed May 6 01:35:33 2026 +1000
GROOVY-11997: Add @ForkedJvm and @ExpectedToFail JUnit extensions to
groovy-test-junit6
---
build.gradle | 1 +
src/test/groovy/groovy/ValDisabledTest.groovy | 44 ++---
.../transform/stc/STCExtensionMethodsTest.groovy | 55 +++---
.../m12n/ExtensionModuleHelperForTests.groovy | 123 --------------
.../groovy/runtime/m12n/ExtensionModuleTest.groovy | 147 ++++++++--------
subprojects/groovy-test-junit6/build.gradle | 9 +
.../java/groovy/junit6/plugin/ExpectedToFail.java | 88 ++++++++++
.../junit6/plugin/ExpectedToFailExtension.java | 136 +++++++++++++++
.../main/java/groovy/junit6/plugin/ForkedJvm.java | 74 ++++++++
.../groovy/junit6/plugin/ForkedJvmExtension.java | 165 ++++++++++++++++++
.../groovy/junit6/plugin/ForkedJvmTestRunner.java | 155 +++++++++++++++++
.../src/test/groovy/ExpectedToFailTest.groovy | 186 +++++++++++++++++++++
.../src/test/groovy/ForkedJvmTest.groovy | 124 ++++++++++++++
13 files changed, 1054 insertions(+), 253 deletions(-)
diff --git a/build.gradle b/build.gradle
index 91769c78cc..c4e6e7d851 100644
--- a/build.gradle
+++ b/build.gradle
@@ -125,6 +125,7 @@ dependencies {
testImplementation projects.groovyTest
testImplementation projects.groovyMacro
testImplementation projects.groovyDateutil
+ testImplementation projects.groovyTestJunit6 // for
groovy.junit6.plugin.ForkedJvm
testImplementation "net.jcip:jcip-annotations:${versions.jcipAnnotations}"
testImplementation "com.thoughtworks.qdox:qdox:${versions.qdox}"
testImplementation
"com.fasterxml.jackson.core:jackson-databind:${versions.jackson}"
diff --git a/src/test/groovy/groovy/ValDisabledTest.groovy
b/src/test/groovy/groovy/ValDisabledTest.groovy
index 3eebe4aed7..bbaa586e46 100644
--- a/src/test/groovy/groovy/ValDisabledTest.groovy
+++ b/src/test/groovy/groovy/ValDisabledTest.groovy
@@ -18,32 +18,24 @@
*/
package groovy
+import groovy.junit6.plugin.ForkedJvm
import org.junit.jupiter.api.Test
-import static
org.codehaus.groovy.runtime.m12n.ExtensionModuleHelperForTests.doInFork
+import static groovy.test.GroovyAssert.assertScript
/**
* Tests that when {@code groovy.val.enabled=false}, GEP-16 breaking
* changes are resolved and {@code val} behaves as a regular identifier.
- *
- * Each test runs in a freshly forked JVM (compile + execution) with the
- * property set, so the lexer's {@code static final VAL_ENABLED} is
- * initialised to {@code false}.
+ * <p>
+ * Each test runs in a freshly forked JVM with the property set, so the
+ * lexer's {@code static final VAL_ENABLED} is initialised to {@code false}.
*/
+@ForkedJvm(systemProperties = ['groovy.val.enabled=false'])
final class ValDisabledTest {
- private static final List<String> JVM_ARGS = ['-Dgroovy.val.enabled=false']
-
- private static void doInForkWithValDisabled(String script) {
- // Wrap each snippet in assertScript so top-level class declarations
work
- // (the snippet is otherwise placed inside a method body where local
- // classes are not supported).
- doInFork('java.lang.Object', "assertScript '''${script.replace("'",
"\\'")}'''", JVM_ARGS)
- }
-
@Test
void testFieldNamedValBeforeMethod() {
- doInForkWithValDisabled '''
+ assertScript '''
class Foo {
def val
void doSomething() {}
@@ -56,16 +48,16 @@ final class ValDisabledTest {
@Test
void testValAsCastExpression() {
- doInForkWithValDisabled '''
+ assertScript '''
def val = 42
def result = val as String
- assert result == "42"
+ assert result == '42'
'''
}
@Test
void testClassNamedVal() {
- doInForkWithValDisabled '''
+ assertScript '''
class val {
int x
}
@@ -76,7 +68,7 @@ final class ValDisabledTest {
@Test
void testValAsMethodReturnType() {
- doInForkWithValDisabled '''
+ assertScript '''
class val {
int x
}
@@ -90,7 +82,7 @@ final class ValDisabledTest {
@Test
void testValAsExplicitType() {
- doInForkWithValDisabled '''
+ assertScript '''
class val {
int x
}
@@ -101,7 +93,7 @@ final class ValDisabledTest {
@Test
void testDefValAssignment() {
- doInForkWithValDisabled '''
+ assertScript '''
def val = 1
assert val == 1
'''
@@ -109,7 +101,7 @@ final class ValDisabledTest {
@Test
void testValReassignment() {
- doInForkWithValDisabled '''
+ assertScript '''
def val = 1
val = 2
assert val == 2
@@ -118,7 +110,7 @@ final class ValDisabledTest {
@Test
void testValAsMapKey() {
- doInForkWithValDisabled '''
+ assertScript '''
def m = [val: 42]
assert m.val == 42
'''
@@ -126,10 +118,10 @@ final class ValDisabledTest {
@Test
void testValPropertyAccess() {
- doInForkWithValDisabled '''
- class Foo { def val = "hello" }
+ assertScript '''
+ class Foo { def val = 'hello' }
def f = new Foo()
- assert f.val == "hello"
+ assert f.val == 'hello'
'''
}
}
diff --git
a/src/test/groovy/groovy/transform/stc/STCExtensionMethodsTest.groovy
b/src/test/groovy/groovy/transform/stc/STCExtensionMethodsTest.groovy
index d363978114..ea629a1842 100644
--- a/src/test/groovy/groovy/transform/stc/STCExtensionMethodsTest.groovy
+++ b/src/test/groovy/groovy/transform/stc/STCExtensionMethodsTest.groovy
@@ -18,10 +18,10 @@
*/
package groovy.transform.stc
+import groovy.junit6.plugin.ForkedJvm
+import org.codehaus.groovy.runtime.m12n.ExtensionModuleRegistry
import org.junit.jupiter.api.Test
-import static
org.codehaus.groovy.runtime.m12n.ExtensionModuleHelperForTests.doInFork
-
/**
* Unit tests for static type checking : extension methods.
*/
@@ -53,33 +53,32 @@ class STCExtensionMethodsTest extends
StaticTypeCheckingTestCase {
* @see org.codehaus.groovy.runtime.m12n.TestStaticStringExtension
*/
@Test
+ @ForkedJvm
void testShouldFindExtensionMethodWithGrab() {
- doInFork 'groovy.transform.stc.StaticTypeCheckingTestCase', '''
- def impl = new MetaClassImpl(String)
- impl.initialize()
- String.metaClass = impl
- try {
- ExtensionModuleRegistry registry =
GroovySystem.metaClassRegistry.moduleRegistry
- // ensure that the module isn't loaded
- assert !registry.modules.any { it.name == 'Test module for
Grab' && it.version == '1.4' }
-
- def jarURL = this.class.getResource('/jars')
- assert jarURL
-
- assertScript """@GrabResolver(name='local',root='$jarURL')
- @Grab('module-test:module-test:1.4;changing=true')
- import org.codehaus.groovy.runtime.m12n.*
-
- // the following methods are added by the Grab test module
- def str = 'This is a string'
- assert str.reverseToUpperCase2() ==
str.toUpperCase().reverse()
- // a static method added to String thanks to a @Grab
extension
- assert String.answer2() == 42
- """
- } finally {
- String.metaClass = null
- }
- '''
+ def impl = new MetaClassImpl(String)
+ impl.initialize()
+ String.metaClass = impl
+ try {
+ ExtensionModuleRegistry registry =
GroovySystem.metaClassRegistry.moduleRegistry
+ // ensure that the module isn't loaded
+ assert !registry.modules.any { it.name == 'Test module for Grab'
&& it.version == '1.4' }
+
+ def jarURL = this.class.getResource('/jars')
+ assert jarURL
+
+ assertScript """@GrabResolver(name='local',root='$jarURL')
+ @Grab('module-test:module-test:1.4;changing=true')
+ import org.codehaus.groovy.runtime.m12n.*
+
+ // the following methods are added by the Grab test module
+ def str = 'This is a string'
+ assert str.reverseToUpperCase2() == str.toUpperCase().reverse()
+ // a static method added to String thanks to a @Grab extension
+ assert String.answer2() == 42
+ """
+ } finally {
+ String.metaClass = null
+ }
}
/**
diff --git
a/src/test/groovy/org/codehaus/groovy/runtime/m12n/ExtensionModuleHelperForTests.groovy
b/src/test/groovy/org/codehaus/groovy/runtime/m12n/ExtensionModuleHelperForTests.groovy
deleted file mode 100644
index bd36f05855..0000000000
---
a/src/test/groovy/org/codehaus/groovy/runtime/m12n/ExtensionModuleHelperForTests.groovy
+++ /dev/null
@@ -1,123 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-package org.codehaus.groovy.runtime.m12n
-
-import groovy.ant.AntBuilder
-
-final class ExtensionModuleHelperForTests {
-
- private ExtensionModuleHelperForTests() {}
-
- static void doInFork(String baseTestClass = 'java.lang.Object', String
code) {
- doInFork(baseTestClass, code, Collections.<String>emptyList())
- }
-
- static void doInFork(String baseTestClass, String code, List<String>
extraJvmArgs) {
- File baseDir = File.createTempDir()
- File sourceFile = new File(baseDir, 'Temp.groovy')
- sourceFile << """import org.codehaus.groovy.runtime.m12n.*
- import static groovy.test.GroovyAssert.assertScript
- class TempTest extends $baseTestClass {
- @org.junit.jupiter.api.Test
- void testCode() {
- $code
- }
- }
-
- import org.junit.platform.launcher.core.*
- import
org.junit.platform.launcher.listeners.SummaryGeneratingListener
- import static
org.junit.platform.engine.discovery.DiscoverySelectors.*
-
- def launcher = LauncherFactory.create()
- def listener = new SummaryGeneratingListener()
- launcher.registerTestExecutionListeners(listener)
- def testPlan = launcher.discover(
- LauncherDiscoveryRequestBuilder.request().selectors(
- selectClass("TempTest")
- ).build()
- )
- launcher.execute(testPlan)
- // JUnit Platform's launcher is silent on failure; surface
failures via
- // stderr so the parent process (doInFork) detects them as stray
lines.
- def summary = listener.summary
- if (summary.totalFailureCount) {
- summary.failures.each { f ->
- System.err.println('TEST FAILED: ' +
f.testIdentifier.displayName + ' :: ' + f.exception)
- f.exception.printStackTrace(System.err)
- }
- }
- """
-
- Set<String> cp =
System.getProperty('java.class.path').split(File.pathSeparator) as Set
- cp << baseDir.absolutePath
-
- def ant = new AntBuilder()
- def allowed = [
- ~/Picked up JAVA_TOOL_OPTIONS: .*/,
- ~/Picked up _JAVA_OPTIONS: .*/
- ]
- def jvmArgs = []
- jvmArgs.addAll(extraJvmArgs)
- if (Runtime.version().feature() == 25) {
- // JEP 471/498: silence terminal-deprecation warnings for
sun.misc.Unsafe
- // memory-access methods called from agents on the inherited
classpath
- // (e.g. testlens's shaded protobuf UnsafeUtil::arrayBaseOffset).
- // remove when we can - this could mask errors we want to pick up
- jvmArgs << '--sun-misc-unsafe-memory-access=allow'
- }
- try {
- ant.with {
- // Compile via FileSystemCompilerFacade in a forked JVM (same
as forked groovyc),
- // but using ant.java so we can attach arbitrary JVM args
(e.g. system properties).
- java(classname:
'org.codehaus.groovy.ant.FileSystemCompilerFacade', fork: 'true', failonerror:
'true') {
- jvmArgs.each { jvmarg(value: it) }
- classpath {
- cp.each { pathelement location: it }
- }
- arg(value: '--classpath')
- arg(value: cp.join(File.pathSeparator))
- arg(value: '-d')
- arg(value: baseDir.absolutePath)
- arg(value: sourceFile.absolutePath)
- }
- java(classname: 'Temp', fork: 'true', outputproperty: 'out',
errorproperty: 'err') {
- jvmArgs.each { jvmarg(value: it) }
- classpath {
- cp.each {
- pathelement location: it
- }
- }
- }
- }
- } finally {
- baseDir.deleteDir()
- String out = ant.project.properties.out
- String err = ant.project.properties.err
- def stray = err?.readLines()?.findAll { line ->
- line.trim() && !allowed.any { line ==~ it }
- } ?: []
- if (stray) {
- throw new RuntimeException("${stray.join('\n')}\nClasspath:
${cp.join('\n')}")
- }
- if (out && (out.contains('FAILURES') || !out.contains('OK'))) {
- throw new RuntimeException("$out\nClasspath: ${cp.join('\n')}")
- }
- }
- }
-}
diff --git
a/src/test/groovy/org/codehaus/groovy/runtime/m12n/ExtensionModuleTest.groovy
b/src/test/groovy/org/codehaus/groovy/runtime/m12n/ExtensionModuleTest.groovy
index 3bba1357b3..964d76270b 100644
---
a/src/test/groovy/org/codehaus/groovy/runtime/m12n/ExtensionModuleTest.groovy
+++
b/src/test/groovy/org/codehaus/groovy/runtime/m12n/ExtensionModuleTest.groovy
@@ -18,107 +18,104 @@
*/
package org.codehaus.groovy.runtime.m12n
+import groovy.junit6.plugin.ForkedJvm
import org.junit.jupiter.api.Test
-import static
org.codehaus.groovy.runtime.m12n.ExtensionModuleHelperForTests.doInFork
+import static groovy.test.GroovyAssert.assertScript
/**
* Unit tests for extension methods loading.
+ * <p>
+ * Each test runs in a freshly forked JVM so its MetaClass-registry mutations
+ * (e.g. modules loaded via {@code @Grab}) don't pollute the main test JVM.
*/
+@ForkedJvm
final class ExtensionModuleTest {
@Test
void testThatModuleHasBeenLoaded() {
- doInFork '''
- ExtensionModuleRegistry registry =
GroovySystem.metaClassRegistry.moduleRegistry
- assert registry.modules
- // look for the 'Test module' module; it should always be available
- assert registry.modules.any { it.name == 'Test module' &&
it.version == '1.0-test' }
-
- // the following methods are added by the test module
- def str = 'This is a string'
- assert str.reverseToUpperCase() == str.toUpperCase().reverse()
- assert String.answer() == 42
- '''
+ ExtensionModuleRegistry registry =
GroovySystem.metaClassRegistry.moduleRegistry
+ assert registry.modules
+ // look for the 'Test module' module; it should always be available
+ assert registry.modules.any { it.name == 'Test module' && it.version
== '1.0-test' }
+
+ // the following methods are added by the test module
+ def str = 'This is a string'
+ assert str.reverseToUpperCase() == str.toUpperCase().reverse()
+ assert String.answer() == 42
}
@Test
void testThatModuleCanBeLoadedWithGrab() {
- doInFork '''
- ExtensionModuleRegistry registry =
GroovySystem.metaClassRegistry.moduleRegistry
- // ensure that the module isn't loaded
- assert !registry.modules.any { it.name == 'Test module for Grab'
&& it.version == '1.4' }
+ ExtensionModuleRegistry registry =
GroovySystem.metaClassRegistry.moduleRegistry
+ // ensure that the module isn't loaded
+ assert !registry.modules.any { it.name == 'Test module for Grab' &&
it.version == '1.4' }
- // find jar resource
- def jarURL = this.class.getResource("/jars")
- assert jarURL
+ // find jar resource
+ def jarURL = this.class.getResource("/jars")
+ assert jarURL
- def resolver = "@GrabResolver('$jarURL')"
+ def resolver = "@GrabResolver('$jarURL')"
- assertScript resolver + """
- @Grab(value='module-test:module-test:1.4', changing=true)
- import org.codehaus.groovy.runtime.m12n.*
+ assertScript resolver + """
+ @Grab(value='module-test:module-test:1.4', changing=true)
+ import org.codehaus.groovy.runtime.m12n.*
- // ensure that the module is now loaded
- ExtensionModuleRegistry registry =
GroovySystem.metaClassRegistry.moduleRegistry
- assert registry.modules.any { it.name == 'Test module for
Grab' && it.version == '1.4' }
+ // ensure that the module is now loaded
+ ExtensionModuleRegistry registry =
GroovySystem.metaClassRegistry.moduleRegistry
+ assert registry.modules.any { it.name == 'Test module for Grab' &&
it.version == '1.4' }
- // the following methods are added by the 'Test module for
Grab' module
- def str = 'This is a string'
- assert str.reverseToUpperCase2() == str.toUpperCase().reverse()
- assert String.answer2() == 42
- """
+ // the following methods are added by the 'Test module for Grab'
module
+ def str = 'This is a string'
+ assert str.reverseToUpperCase2() == str.toUpperCase().reverse()
+ assert String.answer2() == 42
+ """
- // the module should still be available
- assert registry.modules.any { it.name == 'Test module for Grab' &&
it.version == '1.4' }
- '''
+ // the module should still be available
+ assert registry.modules.any { it.name == 'Test module for Grab' &&
it.version == '1.4' }
}
@Test
void testExtensionModuleUsingGrabAndMap() {
- doInFork '''
- ExtensionModuleRegistry registry =
GroovySystem.metaClassRegistry.moduleRegistry
- // ensure that the module isn't loaded
- assert !registry.modules.any { it.name == 'Test module for Grab'
&& it.version == '1.4' }
+ ExtensionModuleRegistry registry =
GroovySystem.metaClassRegistry.moduleRegistry
+ // ensure that the module isn't loaded
+ assert !registry.modules.any { it.name == 'Test module for Grab' &&
it.version == '1.4' }
- // find jar resource
- def jarURL = this.class.getResource("/jars")
- assert jarURL
+ // find jar resource
+ def jarURL = this.class.getResource("/jars")
+ assert jarURL
- def resolver = "@GrabResolver('$jarURL')"
+ def resolver = "@GrabResolver('$jarURL')"
- assertScript resolver + """
- @Grab(value='module-test:module-test:1.4', changing=true)
- import org.codehaus.groovy.runtime.m12n.*
+ assertScript resolver + """
+ @Grab(value='module-test:module-test:1.4', changing=true)
+ import org.codehaus.groovy.runtime.m12n.*
- def map = [:]
- assert 'foo'.taille() == 3
- assert map.taille() == 0
- """
- '''
+ def map = [:]
+ assert 'foo'.taille() == 3
+ assert map.taille() == 0
+ """
}
// GROOVY-7225
@Test
void testExtensionModuleUsingGrabAndClosure() {
- doInFork '''
- ExtensionModuleRegistry registry =
GroovySystem.metaClassRegistry.moduleRegistry
- // ensure that the module isn't loaded
- assert !registry.modules.any { it.name == 'Test module for Grab'
&& it.version == '1.4' }
-
- // find jar resource
- def jarURL = this.class.getResource("/jars")
- assert jarURL
-
- assertScript """
- @GrabResolver('$jarURL')
- @Grab(value='module-test:module-test:1.4', changing=true)
- import org.codehaus.groovy.runtime.m12n.*
-
- assert 'test'.groovy7225() == 'test: ok'
- assert {->}.groovy7225() == '{"field":"value"}'
- """
- '''
+ ExtensionModuleRegistry registry =
GroovySystem.metaClassRegistry.moduleRegistry
+ // ensure that the module isn't loaded
+ assert !registry.modules.any { it.name == 'Test module for Grab' &&
it.version == '1.4' }
+
+ // find jar resource
+ def jarURL = this.class.getResource("/jars")
+ assert jarURL
+
+ assertScript """
+ @GrabResolver('$jarURL')
+ @Grab(value='module-test:module-test:1.4', changing=true)
+ import org.codehaus.groovy.runtime.m12n.*
+
+ assert 'test'.groovy7225() == 'test: ok'
+ assert {->}.groovy7225() == '{"field":"value"}'
+ """
}
/**
@@ -128,13 +125,11 @@ final class ExtensionModuleTest {
*/
@Test
void testOverrideLocalDateTimeCompareTo() {
- doInFork '''
- def d1 = java.time.LocalDateTime.now()
- def d2 = java.time.LocalDate.now().plusDays(42)
- def d3 = java.time.LocalDate.now().minusDays(42)
-
- assert d1 < d2
- assert d1 > d3
- '''
+ def d1 = java.time.LocalDateTime.now()
+ def d2 = java.time.LocalDate.now().plusDays(42)
+ def d3 = java.time.LocalDate.now().minusDays(42)
+
+ assert d1 < d2
+ assert d1 > d3
}
}
diff --git a/subprojects/groovy-test-junit6/build.gradle
b/subprojects/groovy-test-junit6/build.gradle
index aa33a60d19..36b35ede8e 100644
--- a/subprojects/groovy-test-junit6/build.gradle
+++ b/subprojects/groovy-test-junit6/build.gradle
@@ -37,3 +37,12 @@ dependencies {
testImplementation projects.groovyTest
testImplementation
"org.junit.jupiter:junit-jupiter-params:${versions.junit6}"
}
+
+tasks.named('test') {
+ useJUnitPlatform {
+ // Fixture classes for negative-path tests are tagged 'manual' and
+ // invoked explicitly via the JUnit Platform Launcher; keep them out
+ // of normal discovery so their deliberate failures don't fail CI.
+ excludeTags 'manual'
+ }
+}
diff --git
a/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ExpectedToFail.java
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ExpectedToFail.java
new file mode 100644
index 0000000000..69f11722b6
--- /dev/null
+++
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ExpectedToFail.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package groovy.junit6.plugin;
+
+import org.apache.groovy.lang.annotation.Incubating;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Inverts the pass/fail outcome of the annotated test method (or every test
+ * method on the annotated class): a test that throws is reported as passing,
+ * and a test that completes normally is reported as failing.
+ * <p>
+ * Useful for asserting that some condition reliably fails, and for testing
+ * test-infrastructure code that should propagate failures.
+ * <p>
+ * Optionally constrain the expected failure by exception type and/or message
+ * substring:
+ * <pre>
+ * @Test
+ * @ExpectedToFail(IllegalStateException.class)
+ * void mustThrowIse() { throw new IllegalStateException("boom") }
+ *
+ * @Test
+ * @ExpectedToFail(messageContains = "boom")
+ * void mustThrowWithBoomInMessage() { throw new RuntimeException("kaboom!") }
+ * </pre>
+ * <p>
+ * {@code TestAbortedException} (the exception thrown by JUnit
+ * {@code Assumptions}) is never treated as the expected failure; it's
+ * always rethrown so the test is reported as aborted.
+ * <p>
+ * <b>Composition with {@link ForkedJvm}:</b> works in either declaration
+ * order via explicit coordination between the two extensions (no reliance on
+ * annotation iteration order). When {@code @ExpectedToFail} is declared
+ * <em>before</em> {@code @ForkedJvm} (i.e., outer), the inversion happens in
+ * the parent JVM after the failure propagates from the fork — exercising
+ * {@code @ForkedJvm}'s serialisation. When declared after (inner), the
+ * inversion happens inside the forked child.
+ *
+ * @since 6.0.0
+ */
+@Documented
+@Incubating
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.TYPE, ElementType.METHOD})
+@ExtendWith(ExpectedToFailExtension.class)
+public @interface ExpectedToFail {
+ /**
+ * Expected exception type. The thrown exception must be an instance of
+ * this class (or a subclass) for the test to be reported as passing.
+ * Defaults to {@link Throwable}, which matches anything.
+ */
+ Class<? extends Throwable> value() default Throwable.class;
+
+ /**
+ * Substring that must appear in the thrown exception's message.
+ * Defaults to empty (no message check).
+ */
+ String messageContains() default "";
+
+ /**
+ * Optional human-readable explanation of why this test is expected to
fail.
+ * Surfaced in the failure message when the test unexpectedly passes.
+ */
+ String reason() default "";
+}
diff --git
a/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ExpectedToFailExtension.java
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ExpectedToFailExtension.java
new file mode 100644
index 0000000000..23e84166e4
--- /dev/null
+++
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ExpectedToFailExtension.java
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package groovy.junit6.plugin;
+
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.InvocationInterceptor;
+import org.junit.jupiter.api.extension.ReflectiveInvocationContext;
+import org.opentest4j.TestAbortedException;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+
+/**
+ * JUnit 5 {@link InvocationInterceptor} backing the {@link ExpectedToFail}
+ * annotation. Inverts a test's pass/fail outcome with optional exception-type
+ * and message-substring filters.
+ * <p>
+ * Composes with {@link ForkedJvm} in either declaration order via explicit
+ * coordination through {@link ExtensionContext.Store} and a child-JVM system
+ * property — not via brittle reliance on annotation iteration order.
+ *
+ * @since 6.0.0
+ */
+public class ExpectedToFailExtension implements InvocationInterceptor {
+
+ /**
+ * Shared {@link ExtensionContext.Namespace} used to coordinate inversion
+ * placement between this extension and {@link ForkedJvmExtension}.
+ */
+ static final ExtensionContext.Namespace NAMESPACE =
+ ExtensionContext.Namespace.create("groovy.junit6.expectedtofail");
+
+ /**
+ * Store key set by the parent's {@code @ExpectedToFail} interceptor when
+ * it intends to invert the outcome itself (i.e. it's the OUTER
+ * interceptor relative to {@code @ForkedJvm}). {@link ForkedJvmExtension}
+ * reads this key and, if present, instructs the forked child to defer
+ * inversion via {@link #DEFERRED_TO_PARENT_PROP}.
+ */
+ static final String STORE_KEY_PARENT_INVERTING = "parentInverting";
+
+ /**
+ * System property set on the child JVM's command line by
+ * {@link ForkedJvmExtension} when the parent's {@code @ExpectedToFail}
+ * has claimed the inversion. The child's {@code @ExpectedToFail}
+ * interceptor checks this property and passes through honestly so the
+ * parent can observe the propagated outcome.
+ */
+ public static final String DEFERRED_TO_PARENT_PROP =
"groovy.junit6.expectedtofail.deferred";
+
+ @Override
+ public void interceptTestMethod(Invocation<Void> invocation,
+ ReflectiveInvocationContext<Method>
invocationContext,
+ ExtensionContext extensionContext) throws
Throwable {
+ ExpectedToFail config = findAnnotation(extensionContext);
+ if (config == null) {
+ invocation.proceed();
+ return;
+ }
+ Method method = invocationContext.getExecutable();
+
+ // If this is the child JVM and the parent claimed inversion, just pass
+ // through; the parent's interceptor will observe the propagated
outcome.
+ if (Boolean.parseBoolean(System.getProperty(DEFERRED_TO_PARENT_PROP)))
{
+ invocation.proceed();
+ return;
+ }
+
+ // Otherwise this layer handles inversion. If we're in the parent and
+ // @ForkedJvm is also present, signal the fork (via the shared store)
+ // so the child's interceptor knows to defer to us.
+ boolean inForkedJvm =
Boolean.parseBoolean(System.getProperty(ForkedJvmTestRunner.FORKED_FLAG));
+ if (!inForkedJvm && annotationOnMethodOrClass(method,
ForkedJvm.class)) {
+
extensionContext.getStore(NAMESPACE).put(STORE_KEY_PARENT_INVERTING,
Boolean.TRUE);
+ }
+
+ Throwable thrown = null;
+ try {
+ invocation.proceed();
+ } catch (TestAbortedException tae) {
+ // Assumption failures never count as the expected failure.
+ throw tae;
+ } catch (Throwable t) {
+ thrown = t;
+ }
+ evaluateOutcome(thrown, config);
+ }
+
+ private static boolean annotationOnMethodOrClass(Method method, Class<?
extends Annotation> a) {
+ return method.isAnnotationPresent(a) ||
method.getDeclaringClass().isAnnotationPresent(a);
+ }
+
+ private static void evaluateOutcome(Throwable thrown, ExpectedToFail
config) {
+ if (thrown != null) {
+ if (!config.value().isInstance(thrown)) {
+ throw new AssertionError("@ExpectedToFail expected exception
of type "
+ + config.value().getName() + " but got "
+ + thrown.getClass().getName() + ": " +
thrown.getMessage(), thrown);
+ }
+ String wanted = config.messageContains();
+ if (!wanted.isEmpty()
+ && (thrown.getMessage() == null ||
!thrown.getMessage().contains(wanted))) {
+ throw new AssertionError("@ExpectedToFail expected message
containing '"
+ + wanted + "' but got: " + thrown.getMessage(),
thrown);
+ }
+ return; // matched: swallow, treat as success
+ }
+ String reason = config.reason();
+ throw new AssertionError("@ExpectedToFail: test was expected to fail
but passed"
+ + (reason.isEmpty() ? "" : " (" + reason + ")"));
+ }
+
+ private static ExpectedToFail findAnnotation(ExtensionContext context) {
+ return context.getElement()
+ .map(el -> el.getAnnotation(ExpectedToFail.class))
+ .orElseGet(() -> context.getTestClass()
+ .map(c -> c.getAnnotation(ExpectedToFail.class))
+ .orElse(null));
+ }
+}
diff --git
a/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvm.java
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvm.java
new file mode 100644
index 0000000000..80138b3cbb
--- /dev/null
+++
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvm.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package groovy.junit6.plugin;
+
+import org.apache.groovy.lang.annotation.Incubating;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Runs the annotated test method (or every test method on the annotated class)
+ * in a freshly forked JVM, optionally configured with extra system properties
+ * and JVM arguments.
+ * <p>
+ * Useful for testing behaviour gated by JVM startup state that cannot be
+ * toggled at runtime, e.g. {@code static final} fields initialised from
+ * {@code System.getProperty(...)} at class load time
+ * (such as {@code groovy.val.enabled}).
+ * <p>
+ * System properties are supplied as {@code "key=value"} strings:
+ * <pre>
+ * @Test
+ * @ForkedJvm(systemProperties = {"groovy.val.enabled=false"})
+ * void runsInChildJvm() {
+ * assert System.getProperty("groovy.val.enabled").equals("false");
+ * }
+ * </pre>
+ * <p>
+ * JVM arguments are passed verbatim:
+ * <pre>
+ * @Test
+ * @ForkedJvm(jvmArgs = {"--add-opens=java.base/java.lang=ALL-UNNAMED"})
+ * void withModuleAccess() { ... }
+ * </pre>
+ *
+ * @since 6.0.0
+ */
+@Documented
+@Incubating
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.TYPE, ElementType.METHOD})
+@ExtendWith(ForkedJvmExtension.class)
+public @interface ForkedJvm {
+ /**
+ * System properties to set on the forked JVM, each as a {@code
"key=value"} string.
+ */
+ String[] systemProperties() default {};
+
+ /**
+ * Raw JVM arguments to prepend to the forked JVM's command line, e.g.
+ * {@code "-Xmx128m"} or {@code
"--add-opens=java.base/java.lang=ALL-UNNAMED"}.
+ */
+ String[] jvmArgs() default {};
+}
diff --git
a/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvmExtension.java
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvmExtension.java
new file mode 100644
index 0000000000..197305d49e
--- /dev/null
+++
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvmExtension.java
@@ -0,0 +1,165 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package groovy.junit6.plugin;
+
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.InvocationInterceptor;
+import org.junit.jupiter.api.extension.ReflectiveInvocationContext;
+import org.opentest4j.TestAbortedException;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * JUnit 5 {@link InvocationInterceptor} backing the {@link ForkedJvm}
+ * annotation. When applied, the test method is skipped in the current JVM and
+ * re-run in a freshly forked JVM via {@link ForkedJvmTestRunner}, with any
+ * declared system properties and JVM args.
+ * <p>
+ * Recursion is avoided by setting the system property
+ * {@link ForkedJvmTestRunner#FORKED_FLAG} on the child; when the extension
+ * sees that flag set in the current JVM it just proceeds with the normal
+ * invocation (i.e. the child JVM actually runs the test body).
+ *
+ * @since 6.0.0
+ */
+public class ForkedJvmExtension implements InvocationInterceptor {
+
+ @Override
+ public void interceptTestMethod(Invocation<Void> invocation,
+ ReflectiveInvocationContext<Method>
invocationContext,
+ ExtensionContext extensionContext) throws
Throwable {
+ if
(Boolean.parseBoolean(System.getProperty(ForkedJvmTestRunner.FORKED_FLAG))) {
+ // Already in the child JVM — run the body for real.
+ invocation.proceed();
+ return;
+ }
+
+ ForkedJvm config = findAnnotation(extensionContext);
+ if (config == null) {
+ invocation.proceed();
+ return;
+ }
+
+ // Skip in this (parent) JVM; we'll run the same method in a child.
+ invocation.skip();
+
+ Class<?> testClass = invocationContext.getTargetClass();
+ Method testMethod = invocationContext.getExecutable();
+ boolean parentInverting = Boolean.TRUE.equals(extensionContext
+ .getStore(ExpectedToFailExtension.NAMESPACE)
+ .get(ExpectedToFailExtension.STORE_KEY_PARENT_INVERTING));
+ runInForkedJvm(testClass, testMethod, config, parentInverting);
+ }
+
+ private static ForkedJvm findAnnotation(ExtensionContext context) {
+ return context.getElement()
+ .map(el -> el.getAnnotation(ForkedJvm.class))
+ .orElseGet(() -> context.getTestClass()
+ .map(c -> c.getAnnotation(ForkedJvm.class))
+ .orElse(null));
+ }
+
+ private static void runInForkedJvm(Class<?> testClass, Method testMethod,
+ ForkedJvm config, boolean
parentInverting) throws Throwable {
+ Path resultFile = Files.createTempFile("groovy-forked-jvm-result",
".bin");
+ try {
+ List<String> command = buildCommand(testClass, testMethod, config,
resultFile, parentInverting);
+ ProcessBuilder pb = new ProcessBuilder(command).inheritIO();
+ int exit = pb.start().waitFor();
+ propagateOutcome(exit, resultFile, testClass, testMethod);
+ } finally {
+ try { Files.deleteIfExists(resultFile); } catch (IOException
ignore) {}
+ }
+ }
+
+ private static List<String> buildCommand(Class<?> testClass, Method
testMethod,
+ ForkedJvm config, Path resultFile,
+ boolean parentInverting) {
+ List<String> cmd = new ArrayList<>();
+ String javaHome = System.getProperty("java.home");
+ String javaExe = System.getProperty("os.name",
"").startsWith("Windows") ? "java.exe" : "java";
+ cmd.add(javaHome + File.separator + "bin" + File.separator + javaExe);
+ cmd.add("-D" + ForkedJvmTestRunner.FORKED_FLAG + "=true");
+ cmd.add("-D" + ForkedJvmTestRunner.RESULT_FILE_PROP + "=" +
resultFile);
+ if (parentInverting) {
+ cmd.add("-D" + ExpectedToFailExtension.DEFERRED_TO_PARENT_PROP +
"=true");
+ }
+ for (String sp : config.systemProperties()) {
+ int eq = sp.indexOf('=');
+ if (eq < 0) {
+ throw new IllegalArgumentException(
+ "@ForkedJvm system property must be 'key=value', got:
" + sp);
+ }
+ cmd.add("-D" + sp);
+ }
+ for (String arg : config.jvmArgs()) {
+ cmd.add(arg);
+ }
+ cmd.add("-cp");
+ cmd.add(System.getProperty("java.class.path"));
+ cmd.add(ForkedJvmTestRunner.class.getName());
+ cmd.add(testClass.getName());
+ cmd.add(testMethod.getName());
+ return cmd;
+ }
+
+ private static void propagateOutcome(int exit, Path resultFile,
+ Class<?> testClass, Method
testMethod) throws Throwable {
+ String location = testClass.getName() + "#" + testMethod.getName();
+ byte[] bytes;
+ try {
+ bytes = Files.readAllBytes(resultFile);
+ } catch (IOException ioe) {
+ throw new AssertionError("Forked JVM for " + location
+ + " exited with code " + exit
+ + " and the result file at " + resultFile + " was
unreadable", ioe);
+ }
+ if (exit == 0 && bytes.length == 0) return;
+
+ if (bytes.length == 0) {
+ throw new AssertionError("Forked JVM for " + location
+ + " exited with code " + exit + " (no failure detail
captured)");
+ }
+ if (bytes[0] == ForkedJvmTestRunner.ABORTED_MARKER) {
+ String reason = new String(bytes, 1, bytes.length - 1,
StandardCharsets.UTF_8);
+ throw new TestAbortedException("Forked JVM for " + location + "
aborted: " + reason);
+ }
+ if (bytes[0] == ForkedJvmTestRunner.TEXT_FALLBACK_MARKER) {
+ throw new AssertionError("Forked JVM for " + location + "
failed:\n"
+ + new String(bytes, 1, bytes.length - 1,
StandardCharsets.UTF_8));
+ }
+ try (ObjectInputStream ois = new ObjectInputStream(new
ByteArrayInputStream(bytes))) {
+ Throwable t = (Throwable) ois.readObject();
+ // Rethrow exactly so JUnit reports the original failure
type/message.
+ throw t;
+ } catch (ClassNotFoundException | IOException deserFailed) {
+ throw new AssertionError("Forked JVM for " + location
+ + " failed and result couldn't be deserialised",
deserFailed);
+ }
+ }
+}
diff --git
a/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvmTestRunner.java
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvmTestRunner.java
new file mode 100644
index 0000000000..0b4bc7315b
--- /dev/null
+++
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvmTestRunner.java
@@ -0,0 +1,155 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package groovy.junit6.plugin;
+
+import org.junit.platform.engine.discovery.DiscoverySelectors;
+import org.junit.platform.launcher.Launcher;
+import org.junit.platform.launcher.LauncherDiscoveryRequest;
+import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
+import org.junit.platform.launcher.core.LauncherFactory;
+import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
+import org.junit.platform.launcher.listeners.TestExecutionSummary;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.ObjectOutputStream;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+/**
+ * Child-JVM entry point for {@link ForkedJvm}-annotated tests.
+ * <p>
+ * Invoked by {@link ForkedJvmExtension} with the qualified test class name
+ * and method name as command-line arguments. Runs exactly that one test method
+ * via the JUnit Platform {@link Launcher}, then reports outcome to the parent
+ * via the file referenced by the system property
+ * {@code groovy.junit6.forked.result}: empty file on success, serialised
+ * {@link Throwable} (with text fallback) on failure.
+ *
+ * @since 6.0.0
+ */
+public final class ForkedJvmTestRunner {
+
+ /** Set on the child JVM so {@link ForkedJvmExtension} doesn't re-fork. */
+ public static final String FORKED_FLAG = "groovy.junit6.forked";
+
+ /** Path of the result file the child writes into. */
+ public static final String RESULT_FILE_PROP =
"groovy.junit6.forked.result";
+
+ /**
+ * Marker byte at the start of the result file when the failure had to be
+ * written as text instead of a serialised {@link Throwable}.
+ * Distinguishable because {@link ObjectOutputStream}'s STREAM_MAGIC is
+ * {@code 0xACED}, never starts with {@code 0x00}.
+ */
+ public static final byte TEXT_FALLBACK_MARKER = 0;
+
+ /** Marker byte at the start of the result file for an aborted test. */
+ public static final byte ABORTED_MARKER = 1;
+
+ /** Exit code used when the child reports an aborted test. */
+ public static final int ABORTED_EXIT_CODE = 4;
+
+ private ForkedJvmTestRunner() {}
+
+ public static void main(String[] args) throws Exception {
+ if (args.length != 2) {
+ System.err.println("Usage: ForkedJvmTestRunner <testClass>
<methodName>");
+ System.exit(2);
+ }
+ String className = args[0];
+ String methodName = args[1];
+ String resultFilePath = System.getProperty(RESULT_FILE_PROP);
+ if (resultFilePath == null) {
+ System.err.println("ForkedJvmTestRunner: required system property "
+ + RESULT_FILE_PROP + " is not set");
+ System.exit(2);
+ }
+ Path resultPath = Paths.get(resultFilePath);
+
+ Class<?> testClass = Class.forName(className);
+ // Resolve a single overload by parameter-less name; ForkedJvm tests
+ // shouldn't have parameterised method signatures we can't match.
+ LauncherDiscoveryRequest req =
LauncherDiscoveryRequestBuilder.request()
+ .selectors(DiscoverySelectors.selectMethod(testClass,
methodName))
+ .build();
+
+ Launcher launcher = LauncherFactory.create();
+ SummaryGeneratingListener listener = new SummaryGeneratingListener();
+ launcher.registerTestExecutionListeners(listener);
+ launcher.execute(req);
+
+ TestExecutionSummary summary = listener.getSummary();
+ if (summary.getTestsFoundCount() == 0) {
+ writeTextFallback(resultPath,
+ "ForkedJvmTestRunner: no test discovered for "
+ + className + "#" + methodName);
+ System.exit(3);
+ }
+ if (summary.getTotalFailureCount() == 0 &&
summary.getTestsAbortedCount() > 0) {
+ // Test was aborted (e.g. via Assumptions); propagate as abort,
not as success.
+ String reason = "test aborted";
+ writeAborted(resultPath, reason);
+ System.exit(ABORTED_EXIT_CODE);
+ }
+ if (summary.getTotalFailureCount() == 0) {
+ Files.write(resultPath, new byte[0]);
+ System.exit(0);
+ }
+ Throwable cause = summary.getFailures().get(0).getException();
+ writeFailure(resultPath, cause);
+ System.exit(1);
+ }
+
+ private static void writeAborted(Path path, String reason) throws
IOException {
+ try (OutputStream out = Files.newOutputStream(path)) {
+ out.write(ABORTED_MARKER);
+
out.write(reason.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ }
+ }
+
+ private static void writeFailure(Path path, Throwable t) throws
IOException {
+ try (ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ ObjectOutputStream oos = new ObjectOutputStream(bytes)) {
+ oos.writeObject(t);
+ oos.flush();
+ Files.write(path, bytes.toByteArray());
+ } catch (Exception serializationFailed) {
+ // Some test exceptions aren't Serializable; fall back to text.
+ writeTextFallback(path, stackTraceText(t));
+ }
+ }
+
+ private static void writeTextFallback(Path path, String text) throws
IOException {
+ try (OutputStream out = Files.newOutputStream(path)) {
+ out.write(TEXT_FALLBACK_MARKER);
+ out.write(text.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ }
+ }
+
+ private static String stackTraceText(Throwable t) {
+ StringWriter sw = new StringWriter();
+ t.printStackTrace(new PrintWriter(sw));
+ return sw.toString();
+ }
+}
diff --git
a/subprojects/groovy-test-junit6/src/test/groovy/ExpectedToFailTest.groovy
b/subprojects/groovy-test-junit6/src/test/groovy/ExpectedToFailTest.groovy
new file mode 100644
index 0000000000..9a11f60304
--- /dev/null
+++ b/subprojects/groovy-test-junit6/src/test/groovy/ExpectedToFailTest.groovy
@@ -0,0 +1,186 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import groovy.junit6.plugin.ExpectedToFail
+import groovy.junit6.plugin.ForkedJvm
+import org.junit.jupiter.api.Assumptions
+import org.junit.jupiter.api.Tag
+import org.junit.jupiter.api.Test
+import org.junit.platform.engine.discovery.DiscoverySelectors
+import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder
+import org.junit.platform.launcher.core.LauncherFactory
+import org.junit.platform.launcher.listeners.SummaryGeneratingListener
+import org.junit.platform.launcher.listeners.TestExecutionSummary
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertNotNull
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+class ExpectedToFailTest {
+
+ // ---------------- happy paths (the body throws as expected)
----------------
+
+ @Test
+ @ExpectedToFail
+ void throwingBodyIsTreatedAsSuccess() {
+ throw new AssertionError('expected boom')
+ }
+
+ @Test
+ @ExpectedToFail(IllegalStateException)
+ void typeFilterMatchesExactType() {
+ throw new IllegalStateException('boom')
+ }
+
+ @Test
+ @ExpectedToFail(RuntimeException)
+ void typeFilterMatchesSubtype() {
+ throw new IllegalStateException('boom')
+ }
+
+ @Test
+ @ExpectedToFail(messageContains = 'cosmic ray')
+ void messageFilterMatchesSubstring() {
+ throw new RuntimeException('hit by a cosmic ray')
+ }
+
+ @Test
+ @ExpectedToFail(value = IllegalArgumentException, messageContains = 'bad')
+ void typeAndMessageFiltersBothMatch() {
+ throw new IllegalArgumentException('this is bad input')
+ }
+
+ // ---------------- composition with @ForkedJvm ----------------
+
+ @Test
+ @ExpectedToFail(value = AssertionError, messageContains = 'forked failure')
+ @ForkedJvm
+ void outerOrdering_failurePropagatesFromForkAndIsInverted() {
+ // ExpectedToFail OUTER: parent does the inversion AFTER @ForkedJvm
+ // serialises the failure across the JVM boundary, exercising the
+ // round-trip and verifying type+message fidelity.
+ throw new AssertionError('forked failure round-trips')
+ }
+
+ @Test
+ @ForkedJvm
+ @ExpectedToFail(value = AssertionError, messageContains = 'forked failure')
+ void innerOrdering_inversionHappensInsideForkedChild() {
+ // ExpectedToFail INNER: child JVM swallows the failure; parent sees
+ // success. Doesn't exercise the parent's view of the propagation.
+ throw new AssertionError('forked failure handled inside child')
+ }
+
+ // ---------------- negative paths (verified via Launcher) ----------------
+
+ @Test
+ void unexpectedlyPassingTestIsReportedAsFailure() {
+ TestExecutionSummary summary = runFixture(BadFixtures, 'passingMethod')
+ assertEquals(1, summary.totalFailureCount)
+ assertTrue(summary.failures[0].exception.message.contains('expected to
fail but passed'))
+ }
+
+ @Test
+ void typeFilterMismatchIsReportedAsFailure() {
+ TestExecutionSummary summary = runFixture(BadFixtures,
'wrongTypeMethod')
+ assertEquals(1, summary.totalFailureCount)
+ def msg = summary.failures[0].exception.message
+ assertTrue(msg.contains('expected exception of type'),
+ "actual: ${msg}")
+ assertTrue(msg.contains('IllegalStateException'),
+ "actual: ${msg}")
+ }
+
+ @Test
+ void messageFilterMismatchIsReportedAsFailure() {
+ TestExecutionSummary summary = runFixture(BadFixtures,
'wrongMessageMethod')
+ assertEquals(1, summary.totalFailureCount)
+ def msg = summary.failures[0].exception.message
+ assertTrue(msg.contains('expected message containing'),
+ "actual: ${msg}")
+ }
+
+ @Test
+ void assumptionAbortIsNotSwallowed() {
+ TestExecutionSummary summary = runFixture(BadFixtures,
'assumptionMethod')
+ // Aborts count as skipped, not failed; but they're definitely NOT
+ // counted as passes — that's the property we care about.
+ assertEquals(0, summary.totalFailureCount)
+ assertTrue(summary.testsAbortedCount >= 1,
+ "expected at least one aborted test, summary: ${summary}")
+ }
+
+ @Test
+ void reasonAttributeAppearsInUnexpectedlyPassingFailure() {
+ TestExecutionSummary summary = runFixture(BadFixtures,
'reasonedPassingMethod')
+ assertEquals(1, summary.totalFailureCount)
+ assertTrue(summary.failures[0].exception.message.contains('GROOVY-9999
placeholder'),
+ "actual: ${summary.failures[0].exception.message}")
+ }
+
+ // ---------------- helper ----------------
+
+ private static TestExecutionSummary runFixture(Class<?> fixtureClass,
String methodName) {
+ def listener = new SummaryGeneratingListener()
+ def launcher = LauncherFactory.create()
+ launcher.registerTestExecutionListeners(listener)
+ launcher.execute(LauncherDiscoveryRequestBuilder.request()
+ .selectors(DiscoverySelectors.selectMethod(fixtureClass,
methodName))
+ .build())
+ listener.summary
+ }
+
+ /**
+ * Fixtures used as inputs to the negative-path tests; tagged
+ * {@code manual} so the suite-level config excludes them from normal
+ * discovery — we invoke them explicitly via the Launcher.
+ */
+ @Tag('manual')
+ static class BadFixtures {
+ @Test
+ @ExpectedToFail
+ void passingMethod() {
+ // returns normally — should be reported as failure
+ }
+
+ @Test
+ @ExpectedToFail(IllegalStateException)
+ void wrongTypeMethod() {
+ throw new IllegalArgumentException('wrong type')
+ }
+
+ @Test
+ @ExpectedToFail(messageContains = 'foo')
+ void wrongMessageMethod() {
+ throw new RuntimeException('bar')
+ }
+
+ @Test
+ @ExpectedToFail
+ void assumptionMethod() {
+ Assumptions.assumeTrue(false, 'unconditional abort')
+ }
+
+ @Test
+ @ExpectedToFail(reason = 'GROOVY-9999 placeholder')
+ void reasonedPassingMethod() {
+ // returns normally
+ }
+ }
+}
diff --git
a/subprojects/groovy-test-junit6/src/test/groovy/ForkedJvmTest.groovy
b/subprojects/groovy-test-junit6/src/test/groovy/ForkedJvmTest.groovy
new file mode 100644
index 0000000000..34ff890ec4
--- /dev/null
+++ b/subprojects/groovy-test-junit6/src/test/groovy/ForkedJvmTest.groovy
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import groovy.junit6.plugin.ExpectedToFail
+import groovy.junit6.plugin.ForkedJvm
+import org.junit.jupiter.api.Assumptions
+import org.junit.jupiter.api.Tag
+import org.junit.jupiter.api.Test
+import org.junit.platform.engine.discovery.DiscoverySelectors
+import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder
+import org.junit.platform.launcher.core.LauncherFactory
+import org.junit.platform.launcher.listeners.SummaryGeneratingListener
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertNull
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+class ForkedJvmTest {
+
+ @Test
+ @ForkedJvm(systemProperties = ['groovy.junit6.test.example=hello'])
+ void runsInChildJvmWithSystemProperty() {
+ assertEquals('hello', System.getProperty('groovy.junit6.test.example'))
+ }
+
+ @Test
+ void unannotatedTestRunsInParentJvm() {
+ // No @ForkedJvm, no fork: the property set only in the forked test
above
+ // should be invisible here.
+ assertNull(System.getProperty('groovy.junit6.test.example'))
+ }
+
+ @Test
+ @ForkedJvm(systemProperties = ['groovy.junit6.test.a=1',
'groovy.junit6.test.b=2'])
+ void multipleSystemPropertiesArePassed() {
+ assertEquals('1', System.getProperty('groovy.junit6.test.a'))
+ assertEquals('2', System.getProperty('groovy.junit6.test.b'))
+ }
+
+ @Test
+ @ForkedJvm(jvmArgs = ['-Xmx64m'])
+ void jvmArgIsApplied() {
+ // -Xmx64m is innocuous; just assert we're in a fresh JVM by checking
+ // the heap max is at most ~64m (with some headroom for JVM overhead).
+ long max = Runtime.runtime.maxMemory()
+ assertTrue(max < 128L * 1024 * 1024,
+ "expected max heap < 128m under -Xmx64m, was ${max}")
+ }
+
+ @Test
+ @ForkedJvm(systemProperties = ['groovy.junit6.test.recursion=outer'])
+ void doesNotRecursivelyFork() {
+ // If recursion guard worked, the FORKED_FLAG is true here in the
child.
+ assertEquals('true', System.getProperty('groovy.junit6.forked'))
+ assertEquals('outer',
System.getProperty('groovy.junit6.test.recursion'))
+ }
+
+ @Test
+ @ForkedJvm
+ void emptyAnnotationStillForks() {
+ // No properties or args, but the FORKED_FLAG should still be set.
+ assertEquals('true', System.getProperty('groovy.junit6.forked'))
+ }
+
+ @Test
+ @ExpectedToFail(value = AssertionError, messageContains = 'expected
failure from forked child')
+ @ForkedJvm
+ void failureInChildJvmPropagatesToParent() {
+ // @ExpectedToFail is OUTER (declared before @ForkedJvm) so the
+ // inversion happens in the parent JVM AFTER @ForkedJvm propagates the
+ // failure across the fork boundary. The type-and-message filters
+ // verify the failure round-tripped without losing fidelity.
+ throw new AssertionError('expected failure from forked child')
+ }
+
+ @Test
+ void abortInChildJvmPropagatesAsAbortNotPass() {
+ // Run the AbortFixture's @ForkedJvm test (which calls
+ // Assumptions.assumeFalse(true)) via the Launcher and assert that the
+ // outcome is reported as ABORTED, not PASSED. Regression guard for
+ // Copilot review comment #2.
+ def listener = new SummaryGeneratingListener()
+ def launcher = LauncherFactory.create()
+ launcher.registerTestExecutionListeners(listener)
+ launcher.execute(LauncherDiscoveryRequestBuilder.request()
+ .selectors(DiscoverySelectors.selectMethod(AbortFixture,
'aborts'))
+ .build())
+
+ def summary = listener.summary
+ assertEquals(0, summary.totalFailureCount,
+ "expected no failures, got: ${summary.failures*.exception}")
+ assertEquals(1, summary.testsAbortedCount,
+ "expected exactly one aborted test, summary:
tests=${summary.testsFoundCount} aborted=${summary.testsAbortedCount}
succeeded=${summary.testsSucceededCount}")
+ }
+
+ /**
+ * Fixture for {@link #abortInChildJvmPropagatesAsAbortNotPass}. Tagged
+ * {@code manual} so the suite-level config excludes it from normal runs.
+ */
+ @Tag('manual')
+ static class AbortFixture {
+ @Test
+ @ForkedJvm
+ void aborts() {
+ Assumptions.assumeFalse(true, 'unconditional abort from forked
child')
+ }
+ }
+}