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 7fb605f2e4 GROOVY-11997: Add @ForkedJvm and @ExpectedToFail JUnit 
extensions to groovy-test-junit6 (tweaks)
7fb605f2e4 is described below

commit 7fb605f2e44174e27bbeba9695dccf4ab54d4abf
Author: Paul King <[email protected]>
AuthorDate: Wed May 6 23:20:17 2026 +1000

    GROOVY-11997: Add @ForkedJvm and @ExpectedToFail JUnit extensions to 
groovy-test-junit6 (tweaks)
---
 .../java/groovy/junit6/plugin/ExpectedToFail.java  | 48 +++++++++++++++-----
 .../junit6/plugin/ExpectedToFailContext.java       | 52 +++++++++++++++++++++
 .../junit6/plugin/ExpectedToFailExtension.java     | 53 +++++++++++++++++++---
 .../src/test/groovy/ExpectedToFailTest.groovy      | 51 +++++++++++++++++----
 .../src/test/groovy/ForkedJvmTest.groovy           |  2 +-
 5 files changed, 177 insertions(+), 29 deletions(-)

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
index 69f11722b6..4fd0edd278 100644
--- 
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
@@ -35,18 +35,31 @@ import java.lang.annotation.Target;
  * 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:
+ * Optionally constrain the expected failure by exception type or by a closure
+ * predicate evaluated against the thrown exception:
  * <pre>
  * &#64;Test
- * &#64;ExpectedToFail(IllegalStateException.class)
+ * &#64;ExpectedToFail(IllegalStateException)
  * void mustThrowIse() { throw new IllegalStateException("boom") }
  *
  * &#64;Test
- * &#64;ExpectedToFail(messageContains = "boom")
- * void mustThrowWithBoomInMessage() { throw new RuntimeException("kaboom!") }
+ * &#64;ExpectedToFail({ ex instanceof RuntimeException &amp;&amp; 
message.contains('boom') })
+ * void mustMatchPredicate() { throw new RuntimeException("kaboom!") }
  * </pre>
  * <p>
+ * Closure predicates are evaluated with three bindings: {@code ex} (the
+ * thrown exception), {@code message} (its message, possibly {@code null}),
+ * and {@code cause} (its cause, possibly {@code null}). The test is
+ * reported as passing iff the predicate returns a Groovy-truthy value.
+ * <p>
+ * For callers who want compile-time enforcement that the configured class
+ * is a {@link Throwable} subclass, set {@link #exception()} instead of
+ * (or alongside) {@link #value()}. {@code exception} and a closure
+ * {@code value} compose: the type acts as a guard that runs before the
+ * predicate, so the closure body can drop the {@code ex instanceof X}
+ * boilerplate. {@code exception} and a {@link Throwable} {@code value} are
+ * mutually exclusive (they would specify the type twice).
+ * <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.
@@ -68,17 +81,28 @@ import java.lang.annotation.Target;
 @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.
+     * Either an expected {@link Throwable} subclass or a {@code Closure}
+     * predicate evaluated against the thrown exception. Defaults to
+     * {@link Throwable}, which matches anything.
+     * <p>
+     * When set to a {@link Throwable} subclass, mutually exclusive with
+     * {@link #exception()}. When set to a closure, composes with
+     * {@link #exception()}: the type guard is checked first, then the
+     * closure predicate.
      */
-    Class<? extends Throwable> value() default Throwable.class;
+    Class<?> value() default Throwable.class;
 
     /**
-     * Substring that must appear in the thrown exception's message.
-     * Defaults to empty (no message check).
+     * Type-safe alternative to {@link #value()}: declares the expected
+     * exception type with compile-time enforcement that it is a
+     * {@link Throwable} subclass. Defaults to {@link Throwable}, which
+     * matches anything.
+     * <p>
+     * Mutually exclusive with a {@link Throwable} {@link #value()};
+     * composes with a closure {@link #value()} as a type guard evaluated
+     * before the predicate.
      */
-    String messageContains() default "";
+    Class<? extends Throwable> exception() default Throwable.class;
 
     /**
      * Optional human-readable explanation of why this test is expected to 
fail.
diff --git 
a/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ExpectedToFailContext.java
 
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ExpectedToFailContext.java
new file mode 100644
index 0000000000..448e101cbb
--- /dev/null
+++ 
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ExpectedToFailContext.java
@@ -0,0 +1,52 @@
+/*
+ *  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;
+
+/**
+ * Delegate for closure predicates supplied to {@link ExpectedToFail}, exposing
+ * the thrown exception under three convenient names: {@code ex} (the
+ * {@link Throwable} itself), {@code message} (its message), and {@code cause}
+ * (its cause, possibly {@code null}).
+ *
+ * @since 6.0.0
+ */
+public class ExpectedToFailContext {
+
+    private final Throwable ex;
+    private final String message;
+    private final Throwable cause;
+
+    public ExpectedToFailContext(Throwable thrown) {
+        this.ex = thrown;
+        this.message = thrown.getMessage();
+        this.cause = thrown.getCause();
+    }
+
+    public Throwable getEx() {
+        return ex;
+    }
+
+    public String getMessage() {
+        return message;
+    }
+
+    public Throwable getCause() {
+        return cause;
+    }
+}
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
index 23e84166e4..77feab404f 100644
--- 
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
@@ -18,6 +18,8 @@
  */
 package groovy.junit6.plugin;
 
+import groovy.lang.Closure;
+import org.codehaus.groovy.runtime.typehandling.DefaultTypeTransformation;
 import org.junit.jupiter.api.extension.ExtensionContext;
 import org.junit.jupiter.api.extension.InvocationInterceptor;
 import org.junit.jupiter.api.extension.ReflectiveInvocationContext;
@@ -107,17 +109,40 @@ public class ExpectedToFailExtension implements 
InvocationInterceptor {
     }
 
     private static void evaluateOutcome(Throwable thrown, ExpectedToFail 
config) {
+        Class<?> value = config.value();
+        Class<? extends Throwable> exception = config.exception();
+        boolean valueSet = value != Throwable.class;
+        boolean exceptionSet = exception != Throwable.class;
+        boolean valueIsClosure = Closure.class.isAssignableFrom(value);
+        if (valueSet && !valueIsClosure && exceptionSet) {
+            throw new AssertionError(
+                    "@ExpectedToFail: 'value' (as Throwable type) and 
'exception' are mutually "
+                            + "exclusive — use a closure for 'value' if you 
also want a type guard");
+        }
+        if (valueSet && !valueIsClosure && 
!Throwable.class.isAssignableFrom(value)) {
+            throw new AssertionError(
+                    "@ExpectedToFail value must be a Throwable or Closure 
subclass, was: "
+                            + value.getName());
+        }
         if (thrown != null) {
-            if (!config.value().isInstance(thrown)) {
+            Class<? extends Throwable> typeFilter;
+            if (exceptionSet) {
+                typeFilter = exception;
+            } else if (valueSet && !valueIsClosure) {
+                @SuppressWarnings("unchecked")
+                Class<? extends Throwable> t = (Class<? extends Throwable>) 
value;
+                typeFilter = t;
+            } else {
+                typeFilter = Throwable.class;
+            }
+            if (!typeFilter.isInstance(thrown)) {
                 throw new AssertionError("@ExpectedToFail expected exception 
of type "
-                        + config.value().getName() + " but got "
+                        + typeFilter.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);
+            if (valueIsClosure && !evaluateClosure(value, thrown)) {
+                throw new AssertionError("@ExpectedToFail predicate did not 
match thrown "
+                        + thrown.getClass().getName() + ": " + 
thrown.getMessage(), thrown);
             }
             return; // matched: swallow, treat as success
         }
@@ -126,6 +151,20 @@ public class ExpectedToFailExtension implements 
InvocationInterceptor {
                 + (reason.isEmpty() ? "" : " (" + reason + ")"));
     }
 
+    private static boolean evaluateClosure(Class<?> closureClass, Throwable 
thrown) {
+        Closure<?> closure;
+        try {
+            closure = (Closure<?>) closureClass.getConstructor(Object.class, 
Object.class)
+                    .newInstance(null, null);
+        } catch (ReflectiveOperationException e) {
+            throw new AssertionError(
+                    "@ExpectedToFail: failed to instantiate closure 
predicate", e);
+        }
+        closure.setDelegate(new ExpectedToFailContext(thrown));
+        closure.setResolveStrategy(Closure.DELEGATE_FIRST);
+        return DefaultTypeTransformation.castToBoolean(closure.call());
+    }
+
     private static ExpectedToFail findAnnotation(ExtensionContext context) {
         return context.getElement()
                 .map(el -> el.getAnnotation(ExpectedToFail.class))
diff --git 
a/subprojects/groovy-test-junit6/src/test/groovy/ExpectedToFailTest.groovy 
b/subprojects/groovy-test-junit6/src/test/groovy/ExpectedToFailTest.groovy
index 9a11f60304..ca764d432b 100644
--- a/subprojects/groovy-test-junit6/src/test/groovy/ExpectedToFailTest.groovy
+++ b/subprojects/groovy-test-junit6/src/test/groovy/ExpectedToFailTest.groovy
@@ -55,21 +55,39 @@ class ExpectedToFailTest {
     }
 
     @Test
-    @ExpectedToFail(messageContains = 'cosmic ray')
-    void messageFilterMatchesSubstring() {
+    @ExpectedToFail({ message.contains('cosmic ray') })
+    void closurePredicateOnMessageBinding() {
         throw new RuntimeException('hit by a cosmic ray')
     }
 
     @Test
-    @ExpectedToFail(value = IllegalArgumentException, messageContains = 'bad')
-    void typeAndMessageFiltersBothMatch() {
+    @ExpectedToFail({ ex instanceof IllegalArgumentException && 
message.contains('bad') })
+    void closurePredicateCombinesTypeAndMessage() {
+        throw new IllegalArgumentException('this is bad input')
+    }
+
+    @Test
+    @ExpectedToFail({ cause?.message == 'root' })
+    void closurePredicateOnCauseBinding() {
+        throw new RuntimeException('wrapper', new 
IllegalStateException('root'))
+    }
+
+    @Test
+    @ExpectedToFail(exception = RuntimeException)
+    void typeSafeExceptionAttribute() {
+        throw new IllegalStateException('boom')
+    }
+
+    @Test
+    @ExpectedToFail(exception = IllegalArgumentException, value = { 
message.contains('bad') })
+    void exceptionGuardComposesWithClosurePredicate() {
         throw new IllegalArgumentException('this is bad input')
     }
 
     // ---------------- composition with @ForkedJvm ----------------
 
     @Test
-    @ExpectedToFail(value = AssertionError, messageContains = 'forked failure')
+    @ExpectedToFail({ ex instanceof AssertionError && message.contains('forked 
failure') })
     @ForkedJvm
     void outerOrdering_failurePropagatesFromForkAndIsInverted() {
         // ExpectedToFail OUTER: parent does the inversion AFTER @ForkedJvm
@@ -80,7 +98,7 @@ class ExpectedToFailTest {
 
     @Test
     @ForkedJvm
-    @ExpectedToFail(value = AssertionError, messageContains = 'forked failure')
+    @ExpectedToFail({ ex instanceof AssertionError && message.contains('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.
@@ -108,11 +126,20 @@ class ExpectedToFailTest {
     }
 
     @Test
-    void messageFilterMismatchIsReportedAsFailure() {
+    void closurePredicateMismatchIsReportedAsFailure() {
         TestExecutionSummary summary = runFixture(BadFixtures, 
'wrongMessageMethod')
         assertEquals(1, summary.totalFailureCount)
         def msg = summary.failures[0].exception.message
-        assertTrue(msg.contains('expected message containing'),
+        assertTrue(msg.contains('predicate did not match'),
+                "actual: ${msg}")
+    }
+
+    @Test
+    void valueAndExceptionTogetherIsReportedAsFailure() {
+        TestExecutionSummary summary = runFixture(BadFixtures, 
'bothValueAndExceptionMethod')
+        assertEquals(1, summary.totalFailureCount)
+        def msg = summary.failures[0].exception.message
+        assertTrue(msg.contains('mutually exclusive'),
                 "actual: ${msg}")
     }
 
@@ -166,11 +193,17 @@ class ExpectedToFailTest {
         }
 
         @Test
-        @ExpectedToFail(messageContains = 'foo')
+        @ExpectedToFail({ message.contains('foo') })
         void wrongMessageMethod() {
             throw new RuntimeException('bar')
         }
 
+        @Test
+        @ExpectedToFail(value = RuntimeException, exception = RuntimeException)
+        void bothValueAndExceptionMethod() {
+            throw new RuntimeException('boom')
+        }
+
         @Test
         @ExpectedToFail
         void assumptionMethod() {
diff --git 
a/subprojects/groovy-test-junit6/src/test/groovy/ForkedJvmTest.groovy 
b/subprojects/groovy-test-junit6/src/test/groovy/ForkedJvmTest.groovy
index 778ed9e560..766eaded02 100644
--- a/subprojects/groovy-test-junit6/src/test/groovy/ForkedJvmTest.groovy
+++ b/subprojects/groovy-test-junit6/src/test/groovy/ForkedJvmTest.groovy
@@ -142,7 +142,7 @@ class ForkedJvmTest {
     }
 
     @Test
-    @ExpectedToFail(value = AssertionError, messageContains = 'expected 
failure from forked child')
+    @ExpectedToFail({ ex instanceof AssertionError && 
message.contains('expected failure from forked child') })
     @ForkedJvm
     void failureInChildJvmPropagatesToParent() {
         // @ExpectedToFail is OUTER (declared before @ForkedJvm) so the

Reply via email to