Skip to content

Commit 7fb605f

Browse files
committed
GROOVY-11997: Add @ForkedJvm and @ExpectedToFail JUnit extensions to groovy-test-junit6 (tweaks)
1 parent 1380ed6 commit 7fb605f

5 files changed

Lines changed: 177 additions & 29 deletions

File tree

subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ExpectedToFail.java

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,31 @@
3535
* Useful for asserting that some condition reliably fails, and for testing
3636
* test-infrastructure code that should propagate failures.
3737
* <p>
38-
* Optionally constrain the expected failure by exception type and/or message
39-
* substring:
38+
* Optionally constrain the expected failure by exception type or by a closure
39+
* predicate evaluated against the thrown exception:
4040
* <pre>
4141
* &#64;Test
42-
* &#64;ExpectedToFail(IllegalStateException.class)
42+
* &#64;ExpectedToFail(IllegalStateException)
4343
* void mustThrowIse() { throw new IllegalStateException("boom") }
4444
*
4545
* &#64;Test
46-
* &#64;ExpectedToFail(messageContains = "boom")
47-
* void mustThrowWithBoomInMessage() { throw new RuntimeException("kaboom!") }
46+
* &#64;ExpectedToFail({ ex instanceof RuntimeException &amp;&amp; message.contains('boom') })
47+
* void mustMatchPredicate() { throw new RuntimeException("kaboom!") }
4848
* </pre>
4949
* <p>
50+
* Closure predicates are evaluated with three bindings: {@code ex} (the
51+
* thrown exception), {@code message} (its message, possibly {@code null}),
52+
* and {@code cause} (its cause, possibly {@code null}). The test is
53+
* reported as passing iff the predicate returns a Groovy-truthy value.
54+
* <p>
55+
* For callers who want compile-time enforcement that the configured class
56+
* is a {@link Throwable} subclass, set {@link #exception()} instead of
57+
* (or alongside) {@link #value()}. {@code exception} and a closure
58+
* {@code value} compose: the type acts as a guard that runs before the
59+
* predicate, so the closure body can drop the {@code ex instanceof X}
60+
* boilerplate. {@code exception} and a {@link Throwable} {@code value} are
61+
* mutually exclusive (they would specify the type twice).
62+
* <p>
5063
* {@code TestAbortedException} (the exception thrown by JUnit
5164
* {@code Assumptions}) is never treated as the expected failure; it's
5265
* always rethrown so the test is reported as aborted.
@@ -68,17 +81,28 @@
6881
@ExtendWith(ExpectedToFailExtension.class)
6982
public @interface ExpectedToFail {
7083
/**
71-
* Expected exception type. The thrown exception must be an instance of
72-
* this class (or a subclass) for the test to be reported as passing.
73-
* Defaults to {@link Throwable}, which matches anything.
84+
* Either an expected {@link Throwable} subclass or a {@code Closure}
85+
* predicate evaluated against the thrown exception. Defaults to
86+
* {@link Throwable}, which matches anything.
87+
* <p>
88+
* When set to a {@link Throwable} subclass, mutually exclusive with
89+
* {@link #exception()}. When set to a closure, composes with
90+
* {@link #exception()}: the type guard is checked first, then the
91+
* closure predicate.
7492
*/
75-
Class<? extends Throwable> value() default Throwable.class;
93+
Class<?> value() default Throwable.class;
7694

7795
/**
78-
* Substring that must appear in the thrown exception's message.
79-
* Defaults to empty (no message check).
96+
* Type-safe alternative to {@link #value()}: declares the expected
97+
* exception type with compile-time enforcement that it is a
98+
* {@link Throwable} subclass. Defaults to {@link Throwable}, which
99+
* matches anything.
100+
* <p>
101+
* Mutually exclusive with a {@link Throwable} {@link #value()};
102+
* composes with a closure {@link #value()} as a type guard evaluated
103+
* before the predicate.
80104
*/
81-
String messageContains() default "";
105+
Class<? extends Throwable> exception() default Throwable.class;
82106

83107
/**
84108
* Optional human-readable explanation of why this test is expected to fail.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package groovy.junit6.plugin;
20+
21+
/**
22+
* Delegate for closure predicates supplied to {@link ExpectedToFail}, exposing
23+
* the thrown exception under three convenient names: {@code ex} (the
24+
* {@link Throwable} itself), {@code message} (its message), and {@code cause}
25+
* (its cause, possibly {@code null}).
26+
*
27+
* @since 6.0.0
28+
*/
29+
public class ExpectedToFailContext {
30+
31+
private final Throwable ex;
32+
private final String message;
33+
private final Throwable cause;
34+
35+
public ExpectedToFailContext(Throwable thrown) {
36+
this.ex = thrown;
37+
this.message = thrown.getMessage();
38+
this.cause = thrown.getCause();
39+
}
40+
41+
public Throwable getEx() {
42+
return ex;
43+
}
44+
45+
public String getMessage() {
46+
return message;
47+
}
48+
49+
public Throwable getCause() {
50+
return cause;
51+
}
52+
}

subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ExpectedToFailExtension.java

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
*/
1919
package groovy.junit6.plugin;
2020

21+
import groovy.lang.Closure;
22+
import org.codehaus.groovy.runtime.typehandling.DefaultTypeTransformation;
2123
import org.junit.jupiter.api.extension.ExtensionContext;
2224
import org.junit.jupiter.api.extension.InvocationInterceptor;
2325
import org.junit.jupiter.api.extension.ReflectiveInvocationContext;
@@ -107,17 +109,40 @@ private static boolean annotationOnMethodOrClass(Method method, Class<? extends
107109
}
108110

109111
private static void evaluateOutcome(Throwable thrown, ExpectedToFail config) {
112+
Class<?> value = config.value();
113+
Class<? extends Throwable> exception = config.exception();
114+
boolean valueSet = value != Throwable.class;
115+
boolean exceptionSet = exception != Throwable.class;
116+
boolean valueIsClosure = Closure.class.isAssignableFrom(value);
117+
if (valueSet && !valueIsClosure && exceptionSet) {
118+
throw new AssertionError(
119+
"@ExpectedToFail: 'value' (as Throwable type) and 'exception' are mutually "
120+
+ "exclusive — use a closure for 'value' if you also want a type guard");
121+
}
122+
if (valueSet && !valueIsClosure && !Throwable.class.isAssignableFrom(value)) {
123+
throw new AssertionError(
124+
"@ExpectedToFail value must be a Throwable or Closure subclass, was: "
125+
+ value.getName());
126+
}
110127
if (thrown != null) {
111-
if (!config.value().isInstance(thrown)) {
128+
Class<? extends Throwable> typeFilter;
129+
if (exceptionSet) {
130+
typeFilter = exception;
131+
} else if (valueSet && !valueIsClosure) {
132+
@SuppressWarnings("unchecked")
133+
Class<? extends Throwable> t = (Class<? extends Throwable>) value;
134+
typeFilter = t;
135+
} else {
136+
typeFilter = Throwable.class;
137+
}
138+
if (!typeFilter.isInstance(thrown)) {
112139
throw new AssertionError("@ExpectedToFail expected exception of type "
113-
+ config.value().getName() + " but got "
140+
+ typeFilter.getName() + " but got "
114141
+ thrown.getClass().getName() + ": " + thrown.getMessage(), thrown);
115142
}
116-
String wanted = config.messageContains();
117-
if (!wanted.isEmpty()
118-
&& (thrown.getMessage() == null || !thrown.getMessage().contains(wanted))) {
119-
throw new AssertionError("@ExpectedToFail expected message containing '"
120-
+ wanted + "' but got: " + thrown.getMessage(), thrown);
143+
if (valueIsClosure && !evaluateClosure(value, thrown)) {
144+
throw new AssertionError("@ExpectedToFail predicate did not match thrown "
145+
+ thrown.getClass().getName() + ": " + thrown.getMessage(), thrown);
121146
}
122147
return; // matched: swallow, treat as success
123148
}
@@ -126,6 +151,20 @@ private static void evaluateOutcome(Throwable thrown, ExpectedToFail config) {
126151
+ (reason.isEmpty() ? "" : " (" + reason + ")"));
127152
}
128153

154+
private static boolean evaluateClosure(Class<?> closureClass, Throwable thrown) {
155+
Closure<?> closure;
156+
try {
157+
closure = (Closure<?>) closureClass.getConstructor(Object.class, Object.class)
158+
.newInstance(null, null);
159+
} catch (ReflectiveOperationException e) {
160+
throw new AssertionError(
161+
"@ExpectedToFail: failed to instantiate closure predicate", e);
162+
}
163+
closure.setDelegate(new ExpectedToFailContext(thrown));
164+
closure.setResolveStrategy(Closure.DELEGATE_FIRST);
165+
return DefaultTypeTransformation.castToBoolean(closure.call());
166+
}
167+
129168
private static ExpectedToFail findAnnotation(ExtensionContext context) {
130169
return context.getElement()
131170
.map(el -> el.getAnnotation(ExpectedToFail.class))

subprojects/groovy-test-junit6/src/test/groovy/ExpectedToFailTest.groovy

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,21 +55,39 @@ class ExpectedToFailTest {
5555
}
5656

5757
@Test
58-
@ExpectedToFail(messageContains = 'cosmic ray')
59-
void messageFilterMatchesSubstring() {
58+
@ExpectedToFail({ message.contains('cosmic ray') })
59+
void closurePredicateOnMessageBinding() {
6060
throw new RuntimeException('hit by a cosmic ray')
6161
}
6262

6363
@Test
64-
@ExpectedToFail(value = IllegalArgumentException, messageContains = 'bad')
65-
void typeAndMessageFiltersBothMatch() {
64+
@ExpectedToFail({ ex instanceof IllegalArgumentException && message.contains('bad') })
65+
void closurePredicateCombinesTypeAndMessage() {
66+
throw new IllegalArgumentException('this is bad input')
67+
}
68+
69+
@Test
70+
@ExpectedToFail({ cause?.message == 'root' })
71+
void closurePredicateOnCauseBinding() {
72+
throw new RuntimeException('wrapper', new IllegalStateException('root'))
73+
}
74+
75+
@Test
76+
@ExpectedToFail(exception = RuntimeException)
77+
void typeSafeExceptionAttribute() {
78+
throw new IllegalStateException('boom')
79+
}
80+
81+
@Test
82+
@ExpectedToFail(exception = IllegalArgumentException, value = { message.contains('bad') })
83+
void exceptionGuardComposesWithClosurePredicate() {
6684
throw new IllegalArgumentException('this is bad input')
6785
}
6886

6987
// ---------------- composition with @ForkedJvm ----------------
7088

7189
@Test
72-
@ExpectedToFail(value = AssertionError, messageContains = 'forked failure')
90+
@ExpectedToFail({ ex instanceof AssertionError && message.contains('forked failure') })
7391
@ForkedJvm
7492
void outerOrdering_failurePropagatesFromForkAndIsInverted() {
7593
// ExpectedToFail OUTER: parent does the inversion AFTER @ForkedJvm
@@ -80,7 +98,7 @@ class ExpectedToFailTest {
8098

8199
@Test
82100
@ForkedJvm
83-
@ExpectedToFail(value = AssertionError, messageContains = 'forked failure')
101+
@ExpectedToFail({ ex instanceof AssertionError && message.contains('forked failure') })
84102
void innerOrdering_inversionHappensInsideForkedChild() {
85103
// ExpectedToFail INNER: child JVM swallows the failure; parent sees
86104
// success. Doesn't exercise the parent's view of the propagation.
@@ -108,11 +126,20 @@ class ExpectedToFailTest {
108126
}
109127

110128
@Test
111-
void messageFilterMismatchIsReportedAsFailure() {
129+
void closurePredicateMismatchIsReportedAsFailure() {
112130
TestExecutionSummary summary = runFixture(BadFixtures, 'wrongMessageMethod')
113131
assertEquals(1, summary.totalFailureCount)
114132
def msg = summary.failures[0].exception.message
115-
assertTrue(msg.contains('expected message containing'),
133+
assertTrue(msg.contains('predicate did not match'),
134+
"actual: ${msg}")
135+
}
136+
137+
@Test
138+
void valueAndExceptionTogetherIsReportedAsFailure() {
139+
TestExecutionSummary summary = runFixture(BadFixtures, 'bothValueAndExceptionMethod')
140+
assertEquals(1, summary.totalFailureCount)
141+
def msg = summary.failures[0].exception.message
142+
assertTrue(msg.contains('mutually exclusive'),
116143
"actual: ${msg}")
117144
}
118145

@@ -166,11 +193,17 @@ class ExpectedToFailTest {
166193
}
167194

168195
@Test
169-
@ExpectedToFail(messageContains = 'foo')
196+
@ExpectedToFail({ message.contains('foo') })
170197
void wrongMessageMethod() {
171198
throw new RuntimeException('bar')
172199
}
173200

201+
@Test
202+
@ExpectedToFail(value = RuntimeException, exception = RuntimeException)
203+
void bothValueAndExceptionMethod() {
204+
throw new RuntimeException('boom')
205+
}
206+
174207
@Test
175208
@ExpectedToFail
176209
void assumptionMethod() {

subprojects/groovy-test-junit6/src/test/groovy/ForkedJvmTest.groovy

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ class ForkedJvmTest {
142142
}
143143

144144
@Test
145-
@ExpectedToFail(value = AssertionError, messageContains = 'expected failure from forked child')
145+
@ExpectedToFail({ ex instanceof AssertionError && message.contains('expected failure from forked child') })
146146
@ForkedJvm
147147
void failureInChildJvmPropagatesToParent() {
148148
// @ExpectedToFail is OUTER (declared before @ForkedJvm) so the

0 commit comments

Comments
 (0)