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>
* @Test
- * @ExpectedToFail(IllegalStateException.class)
+ * @ExpectedToFail(IllegalStateException)
* void mustThrowIse() { throw new IllegalStateException("boom") }
*
* @Test
- * @ExpectedToFail(messageContains = "boom")
- * void mustThrowWithBoomInMessage() { throw new RuntimeException("kaboom!") }
+ * @ExpectedToFail({ ex instanceof RuntimeException &&
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