diff --git a/docs/interaction_based_testing.adoc b/docs/interaction_based_testing.adoc index 89dc056dbc..7cab28c849 100644 --- a/docs/interaction_based_testing.adoc +++ b/docs/interaction_based_testing.adoc @@ -1259,6 +1259,35 @@ is attached to a spec before usage: include::{sourcedir}/interaction/DetachedMockFactoryDocSpec.groovy[tags=use-custom-mock-creator-attach] ---- +[[external-interactions]] +=== Declaring Interactions Outside Specifications + +`DetachedMockFactory` lets you create a mock outside a `Specification`, but the mock's interactions still have to be declared inside the spec. +`MockInteractionSupport` lets you encapsulate interactions (and mock creation) in a reusable fixture. +It routes everything through the owning spec's mock controller, so the interactions behave exactly like interactions declared in a `setup:` block: the spec must be live (called from within a feature's lifecycle), and cardinality interactions are verified when the spec's controller scope leaves. + +[[MockInteractionSupport]] +==== MockInteractionSupport + +Implement `spock.mock.MockInteractionSupport` to build a reusable fixture object that both creates mocks and declares interactions. +The fixture supplies the owning spec through `getSpecification()` (typically stored in a constructor field), and the `Mock()`/`Stub()`/`Spy()` factory methods work just as they do inside a spec. + +[source,groovy,indent=0] +---- +include::{sourcedir}/interaction/ExternalMockInteractionsDocSpec.groovy[tags=support-fixture] +---- + +A spec can then delegate creation and configuration to the fixture: + +[source,groovy,indent=0] +---- +include::{sourcedir}/interaction/ExternalMockInteractionsDocSpec.groovy[tags=support-usage] +---- + +A class cannot be both a `Specification` and a `MockInteractionSupport`, because a spec already has the full capability. + +If you forget to attach the fixture, using it fails fast with a clear error that tells you the spec is missing. + == Further Reading If you would like to dive deeper into interaction-based testing, we recommend the following resources: diff --git a/docs/release_notes.adoc b/docs/release_notes.adoc index ee96a41d16..d7784af9c5 100644 --- a/docs/release_notes.adoc +++ b/docs/release_notes.adoc @@ -10,6 +10,7 @@ include::include.adoc[] * Add support for `final` local variables in `where:` blocks, declared at their beginning and evaluated once per feature, scoped to the where-block spockIssue:138[] * Improve `TooManyInvocationsError` now reports unsatisfied interactions with argument mismatch details, making it easier to diagnose why invocations didn't match expected interactions spockPull:2315[] +* Allow declaring mock interactions and creating mocks outside a `Specification` via the `MockInteractionSupport` interface, see <> === Misc @@ -20,6 +21,7 @@ include::include.adoc[] === Breaking Changes * Mock/Stub checks on `Comparable` with `T` being something other than `Object` now compare using the java identity hash code instead of always being equal spockIssue:2352[] +* `spock.mock.MockingApi` changed from a class to an interface to support `MockInteractionSupport`. This only affects code that violated the documented contract by extending `MockingApi` directly instead of `Specification`. `instanceof` checks, casts, and type references keep working. However, already compiled code (Java, Kotlin, or `@CompileStatic` Groovy) that invokes a mock factory method on a receiver whose static type is `MockingApi` was compiled as a virtual call and now requires interface dispatch; such code fails with an `IncompatibleClassChangeError` until it is recompiled. == 2.4 (2025-12-11) diff --git a/spock-core/src/main/java/org/spockframework/compiler/AstNodeCache.java b/spock-core/src/main/java/org/spockframework/compiler/AstNodeCache.java index c71f3c986e..26ccb3b0f3 100644 --- a/spock-core/src/main/java/org/spockframework/compiler/AstNodeCache.java +++ b/spock-core/src/main/java/org/spockframework/compiler/AstNodeCache.java @@ -45,6 +45,8 @@ public class AstNodeCache { public final ClassNode SpecInternals = ClassHelper.makeWithoutCaching(SpecInternals.class); public final ClassNode MockController = ClassHelper.makeWithoutCaching(MockController.class); public final ClassNode SpecificationContext = ClassHelper.makeWithoutCaching(SpecificationContext.class); + public final ClassNode MockInteractionSupport = ClassHelper.makeWithoutCaching(spock.mock.MockInteractionSupport.class); + public final ClassNode Checks = ClassHelper.makeWithoutCaching(org.spockframework.util.Checks.class); public final ClassNode DataVariableMultiplication = ClassHelper.makeWithoutCaching(DataVariableMultiplication.class); public final ClassNode DataVariableMultiplicationFactor = ClassHelper.makeWithoutCaching(DataVariableMultiplicationFactor.class); public final ClassNode BlockInfo = ClassHelper.makeWithoutCaching(BlockInfo.class); @@ -53,6 +55,15 @@ public class AstNodeCache { public final MethodNode Specification_GetSpecificationContext = Specification.getDeclaredMethods(Identifiers.GET_SPECIFICATION_CONTEXT).get(0); + public final MethodNode MockInteractionSupport_GetSpecification = + MockInteractionSupport.getDeclaredMethods("getSpecification").get(0); + + public final MethodNode Checks_NotNull = + Checks.getDeclaredMethods("notNull").stream() + .filter(method -> method.getParameters()[1].getType().equals(ClassHelper.STRING_TYPE)) + .findFirst() + .orElseThrow(IllegalStateException::new); + public final MethodNode SpockRuntime_VerifyCondition = SpockRuntime.getDeclaredMethods(org.spockframework.runtime.SpockRuntime.VERIFY_CONDITION).get(0); diff --git a/spock-core/src/main/java/org/spockframework/compiler/DeepBlockRewriter.java b/spock-core/src/main/java/org/spockframework/compiler/DeepBlockRewriter.java index 3b257b1b3d..3779d5af84 100644 --- a/spock-core/src/main/java/org/spockframework/compiler/DeepBlockRewriter.java +++ b/spock-core/src/main/java/org/spockframework/compiler/DeepBlockRewriter.java @@ -252,7 +252,7 @@ private boolean handleMockCall(MethodCallExpression expr) { // expand nevertheless so that inner scope (if any) won't trip over this again } - currSpecialMethodCall.expand(); + currSpecialMethodCall.expand(resources.getSpecificationReference()); return true; } @@ -274,7 +274,7 @@ private boolean handleThrownCall(MethodCallExpression expr) { foundExceptionCondition = expr; if (currSpecialMethodCall.isThrownCall()) { - currSpecialMethodCall.expand(); + currSpecialMethodCall.expand(resources.getSpecificationReference()); } return true; } diff --git a/spock-core/src/main/java/org/spockframework/compiler/ExternalInteractionRewriter.java b/spock-core/src/main/java/org/spockframework/compiler/ExternalInteractionRewriter.java new file mode 100644 index 0000000000..7784b4a96e --- /dev/null +++ b/spock-core/src/main/java/org/spockframework/compiler/ExternalInteractionRewriter.java @@ -0,0 +1,148 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed 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 + * + * https://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.spockframework.compiler; + +import org.codehaus.groovy.ast.MethodNode; +import org.codehaus.groovy.ast.expr.ArgumentListExpression; +import org.codehaus.groovy.ast.expr.BinaryExpression; +import org.codehaus.groovy.ast.expr.ClassExpression; +import org.codehaus.groovy.ast.expr.ConstantExpression; +import org.codehaus.groovy.ast.expr.Expression; +import org.codehaus.groovy.ast.expr.MethodCallExpression; +import org.codehaus.groovy.ast.stmt.BlockStatement; +import org.codehaus.groovy.ast.stmt.ExpressionStatement; +import org.codehaus.groovy.ast.stmt.Statement; +import org.spockframework.compiler.model.ExternalInteractionMethod; +import org.spockframework.util.ObjectUtil; + +import java.util.List; +import java.util.function.Supplier; + +import static org.spockframework.compiler.AstUtil.createDirectMethodCall; + +/** + * Rewrites the top-level interaction statements of an instance method that runs + * outside a {@code Specification}. Each top-level {@link ExpressionStatement} is + * offered to {@link InteractionRewriter}; matched statements are replaced with + * the registration call ({@code mockController.addInteraction(...)}). Built-in + * creation calls ({@code Mock()}/{@code Stub()}/{@code Spy()} ...) are either + * expanded (interface mechanism) or rejected as a compile error (annotation + * mechanism), controlled by {@code allowCreation}. + * + *

Only top-level statements are rewritten; closure-nested interaction forms + * ({@code with(mock) { ... }}) are not handled here. + */ +public class ExternalInteractionRewriter { + private final AstNodeCache nodeCache; + private final ErrorReporter errorReporter; + private final SourceLookup sourceLookup; + private final boolean allowCreation; + + /** + * @param allowCreation whether built-in creation calls may be expanded + * (interface mechanism) or are rejected (annotation + * mechanism). + */ + public ExternalInteractionRewriter(AstNodeCache nodeCache, ErrorReporter errorReporter, + SourceLookup sourceLookup, boolean allowCreation) { + this.nodeCache = nodeCache; + this.errorReporter = errorReporter; + this.sourceLookup = sourceLookup; + this.allowCreation = allowCreation; + } + + /** + * Rewrites {@code method}'s body in place. {@code specificationReferenceFactory} + * produces a fresh spec-locator expression on each call, used for both + * interaction registration and (when allowed) mock creation. + */ + public void rewriteInPlace(MethodNode method, Supplier specificationReferenceFactory) { + if (!(method.getCode() instanceof BlockStatement)) return; + + ExternalRewriteResources resources = new ExternalRewriteResources( + specificationReferenceFactory, new ExternalInteractionMethod(method), nodeCache, sourceLookup, errorReporter); + + // the spec is located through `this`, which is unavailable in static scope; + // interactions in static methods are rejected by InteractionRewriter itself + boolean staticScopeViolation = method.isStatic(); + + BlockStatement body = (BlockStatement) method.getCode(); + List statements = body.getStatements(); + boolean rewrote = false; + for (int i = 0; i < statements.size(); i++) { + Statement stat = statements.get(i); + ExpressionStatement exprStat = ObjectUtil.asInstance(stat, ExpressionStatement.class); + if (exprStat == null) continue; + + // expand or reject built-in creation calls (Mock/Stub/Spy ...) first + rewrote |= handleCreation(exprStat, specificationReferenceFactory, staticScopeViolation); + + ExpressionStatement rewritten = new InteractionRewriter(resources, null).rewrite(exprStat); + if (rewritten != null) { + statements.set(i, rewritten); + rewrote = true; + } + } + + // guard the located spec: anything we rewrote depends on it being non-null + if (rewrote) { + statements.add(0, createSpecificationNotNullCheck(specificationReferenceFactory.get())); + } + } + + private boolean handleCreation(ExpressionStatement exprStat, Supplier specificationReferenceFactory, + boolean staticScopeViolation) { + Expression expr = exprStat.getExpression(); + BinaryExpression binaryExpr = ObjectUtil.asInstance(expr, BinaryExpression.class); + Expression callCandidate = binaryExpr != null ? binaryExpr.getRightExpression() : expr; + MethodCallExpression call = ObjectUtil.asInstance(callCandidate, MethodCallExpression.class); + if (call == null) return false; + + SpecialMethodCall smc = SpecialMethodCall.parse(call, binaryExpr, nodeCache); + if (smc == null || !smc.isTestDouble()) return false; + + if (staticScopeViolation) { + errorReporter.error(call, "Mocks cannot be created in static scope"); + return false; + } + + if (!allowCreation) { + errorReporter.error(call, + "Mock/Stub/Spy creation is not allowed in an @Interactions method; pass the mock in as a parameter, or use MockInteractionSupport."); + return false; + } + smc.expand(specificationReferenceFactory.get()); + return true; + } + + /** + * Builds {@code Checks.notNull(, "...")}, guarding against a missing + * owning spec (e.g. a {@code MockInteractionSupport} whose + * {@code getSpecification()} was never attached). + */ + private Statement createSpecificationNotNullCheck(Expression specificationReference) { + MethodCallExpression check = createDirectMethodCall( + new ClassExpression(nodeCache.Checks), + nodeCache.Checks_NotNull, + new ArgumentListExpression(specificationReference, new ConstantExpression(SPEC_NULL_MESSAGE))); + return new ExpressionStatement(check); + } + + private static final String SPEC_NULL_MESSAGE = + "Cannot declare mock interactions: the owning Specification is null. Attach the MockInteractionSupport to a " + + "running Specification through a constructor field."; +} diff --git a/spock-core/src/main/java/org/spockframework/compiler/ExternalRewriteResources.java b/spock-core/src/main/java/org/spockframework/compiler/ExternalRewriteResources.java new file mode 100644 index 0000000000..93667a8eb7 --- /dev/null +++ b/spock-core/src/main/java/org/spockframework/compiler/ExternalRewriteResources.java @@ -0,0 +1,113 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed 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 + * + * https://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.spockframework.compiler; + +import org.codehaus.groovy.ast.ASTNode; +import org.codehaus.groovy.ast.expr.ArgumentListExpression; +import org.codehaus.groovy.ast.expr.Expression; +import org.codehaus.groovy.ast.expr.MethodCallExpression; +import org.codehaus.groovy.ast.expr.VariableExpression; + +import org.spockframework.compiler.condition.IConditionErrorRecorders; +import org.spockframework.compiler.model.Block; +import org.spockframework.compiler.model.Method; + +import java.util.function.Supplier; + +import static org.spockframework.compiler.AstUtil.createDirectMethodCall; + +/** + * Standalone {@link IRewriteResources} for rewriting interactions in code that + * does not run on a {@code Specification} instance. Modeled on + * {@code DefaultConditionRewriterResources}: it merely holds its dependencies. + * + *

The spec reference is supplied by the caller and is the only thing that + * differs from the in-spec rewrite ({@code this} for a real spec, + * {@code this.getSpecification()} for a {@code MockInteractionSupport} class, + * or the injected {@code $spec} parameter for a {@code @Interactions} + * companion). Everything that registers an interaction layers on top of it. + */ +public class ExternalRewriteResources implements IRewriteResources { + private final Supplier specificationReferenceFactory; + private final Method currentMethod; + private final AstNodeCache nodeCache; + private final SourceLookup sourceLookup; + private final ErrorReporter errorReporter; + + /** + * @param specificationReferenceFactory produces a fresh spec-reference + * expression on each call; a fresh node is required because the + * reference is embedded into a new AST location for every interaction + * or creation it locates. + */ + public ExternalRewriteResources(Supplier specificationReferenceFactory, Method currentMethod, + AstNodeCache nodeCache, SourceLookup sourceLookup, ErrorReporter errorReporter) { + this.specificationReferenceFactory = specificationReferenceFactory; + this.currentMethod = currentMethod; + this.nodeCache = nodeCache; + this.sourceLookup = sourceLookup; + this.errorReporter = errorReporter; + } + + @Override + public Expression getSpecificationReference() { + return specificationReferenceFactory.get(); + } + + @Override + public Method getCurrentMethod() { + return currentMethod; + } + + @Override + public Block getCurrentBlock() { + throw new UnsupportedOperationException("External interaction rewriting has no block structure"); + } + + @Override + public VariableExpression captureOldValue(Expression oldValue) { + throw new UnsupportedOperationException("old() is not supported outside a then-block"); + } + + @Override + public MethodCallExpression getMockInvocationMatcher() { + MethodCallExpression specificationContext = createDirectMethodCall(specificationReferenceFactory.get(), + nodeCache.Specification_GetSpecificationContext, ArgumentListExpression.EMPTY_ARGUMENTS); + return createDirectMethodCall(specificationContext, + nodeCache.SpecificationContext_GetMockController, ArgumentListExpression.EMPTY_ARGUMENTS); + } + + @Override + public AstNodeCache getAstNodeCache() { + return nodeCache; + } + + @Override + public String getSourceText(ASTNode node) { + return sourceLookup.lookup(node); + } + + @Override + public ErrorReporter getErrorReporter() { + return errorReporter; + } + + @Override + public IConditionErrorRecorders getErrorRecorders() { + throw new UnsupportedOperationException("External interaction rewriting does not record conditions"); + } +} diff --git a/spock-core/src/main/java/org/spockframework/compiler/IRewriteResources.java b/spock-core/src/main/java/org/spockframework/compiler/IRewriteResources.java index 6e68670ef1..d9f087ae39 100644 --- a/spock-core/src/main/java/org/spockframework/compiler/IRewriteResources.java +++ b/spock-core/src/main/java/org/spockframework/compiler/IRewriteResources.java @@ -30,6 +30,13 @@ * @author Peter Niederwieser */ public interface IRewriteResources { + /** + * The AST expression that yields the {@code Specification} instance at this + * rewrite site (the "spec reference"). Real specs return {@code this}; + * external mechanisms return an expression that locates the owning spec. + */ + Expression getSpecificationReference(); + Method getCurrentMethod(); Block getCurrentBlock(); diff --git a/spock-core/src/main/java/org/spockframework/compiler/ISpecialMethodCall.java b/spock-core/src/main/java/org/spockframework/compiler/ISpecialMethodCall.java index c724f0d63e..c8af1d1d1f 100644 --- a/spock-core/src/main/java/org/spockframework/compiler/ISpecialMethodCall.java +++ b/spock-core/src/main/java/org/spockframework/compiler/ISpecialMethodCall.java @@ -16,6 +16,7 @@ package org.spockframework.compiler; import org.codehaus.groovy.ast.expr.ClosureExpression; +import org.codehaus.groovy.ast.expr.Expression; import org.codehaus.groovy.ast.expr.MethodCallExpression; import org.codehaus.groovy.ast.stmt.Statement; @@ -61,5 +62,5 @@ public interface ISpecialMethodCall { ClosureExpression getClosureExpr(); - void expand(); + void expand(Expression specificationReference); } diff --git a/spock-core/src/main/java/org/spockframework/compiler/NoSpecialMethodCall.java b/spock-core/src/main/java/org/spockframework/compiler/NoSpecialMethodCall.java index 0e89c842bf..961bb27b98 100644 --- a/spock-core/src/main/java/org/spockframework/compiler/NoSpecialMethodCall.java +++ b/spock-core/src/main/java/org/spockframework/compiler/NoSpecialMethodCall.java @@ -16,6 +16,7 @@ package org.spockframework.compiler; import org.codehaus.groovy.ast.expr.ClosureExpression; +import org.codehaus.groovy.ast.expr.Expression; import org.codehaus.groovy.ast.expr.MethodCallExpression; import org.codehaus.groovy.ast.stmt.Statement; @@ -125,7 +126,7 @@ public ClosureExpression getClosureExpr() { } @Override - public void expand() { + public void expand(Expression specificationReference) { throw new UnsupportedOperationException(); } } diff --git a/spock-core/src/main/java/org/spockframework/compiler/SpecRewriter.java b/spock-core/src/main/java/org/spockframework/compiler/SpecRewriter.java index 1911fe7c72..2db94b509f 100644 --- a/spock-core/src/main/java/org/spockframework/compiler/SpecRewriter.java +++ b/spock-core/src/main/java/org/spockframework/compiler/SpecRewriter.java @@ -692,8 +692,13 @@ public VariableExpression captureOldValue(Expression oldValue) { return var; } + @Override + public Expression getSpecificationReference() { + return VariableExpression.THIS_EXPRESSION; + } + public MethodCallExpression getSpecificationContext() { - return createDirectMethodCall(VariableExpression.THIS_EXPRESSION, + return createDirectMethodCall(getSpecificationReference(), nodeCache.Specification_GetSpecificationContext, ArgumentListExpression.EMPTY_ARGUMENTS); } diff --git a/spock-core/src/main/java/org/spockframework/compiler/SpecialMethodCall.java b/spock-core/src/main/java/org/spockframework/compiler/SpecialMethodCall.java index dcfaefb304..882e945603 100644 --- a/spock-core/src/main/java/org/spockframework/compiler/SpecialMethodCall.java +++ b/spock-core/src/main/java/org/spockframework/compiler/SpecialMethodCall.java @@ -172,9 +172,9 @@ public ClosureExpression getClosureExpr() { } @Override - public void expand() { + public void expand(Expression specificationReference) { List args = new ArrayList<>(); - args.add(VariableExpression.THIS_EXPRESSION); + args.add(specificationReference); args.add(inferredName); args.add(inferredType); args.addAll(AstUtil.getArgumentList(methodCallExpr)); @@ -184,6 +184,10 @@ public void expand() { methodCallExpr.setArguments(argsExpr); methodCallExpr.setObjectExpression(new ClassExpression(nodeCache.SpecInternals)); methodCallExpr.setMethod(new ConstantExpression(methodName + "Impl")); + // the receiver is now an explicit class reference, not the (possibly implicit) + // original `this`; clear the flag so Groovy's trait transform does not later + // rewrite the SpecInternals receiver to the trait's $self parameter + methodCallExpr.setImplicitThis(false); } public static SpecialMethodCall parse(MethodCallExpression methodCallExpr, @Nullable BinaryExpression binaryExpr, diff --git a/spock-core/src/main/java/org/spockframework/compiler/SpockTransform.java b/spock-core/src/main/java/org/spockframework/compiler/SpockTransform.java index b2eaff4282..3df7528ed5 100644 --- a/spock-core/src/main/java/org/spockframework/compiler/SpockTransform.java +++ b/spock-core/src/main/java/org/spockframework/compiler/SpockTransform.java @@ -21,8 +21,10 @@ import org.spockframework.util.VersionChecker; import java.util.List; +import java.util.function.Supplier; import org.codehaus.groovy.ast.*; +import org.codehaus.groovy.ast.expr.*; import org.codehaus.groovy.control.*; import org.codehaus.groovy.transform.*; @@ -58,8 +60,23 @@ void visit(ASTNode[] nodes, SourceUnit sourceUnit) { try (SourceLookup sourceLookup = new SourceLookup(sourceUnit)) { List classes = sourceUnit.getAST().getClasses(); - for (ClassNode clazz : classes) - if (isSpec(clazz)) processSpec(sourceUnit, clazz, errorReporter, sourceLookup); + for (ClassNode clazz : classes) { + boolean spec = isSpec(clazz); + boolean support = isMockInteractionSupport(clazz); + + if (spec && support) { + errorReporter.error( + "Class '%s' must not be both a Specification and a MockInteractionSupport; a spec already supports interactions directly.", + clazz.getName()); + continue; + } + + if (spec) { + processSpec(sourceUnit, clazz, errorReporter, sourceLookup); + } else if (support) { + processMockInteractionSupport(sourceUnit, clazz, errorReporter, sourceLookup); + } + } } } @@ -67,6 +84,35 @@ boolean isSpec(ClassNode clazz) { return clazz.isDerivedFrom(nodeCache.Specification); } + boolean isMockInteractionSupport(ClassNode clazz) { + return clazz.implementsInterface(nodeCache.MockInteractionSupport); + } + + void processMockInteractionSupport(SourceUnit sourceUnit, ClassNode clazz, ErrorReporter errorReporter, SourceLookup sourceLookup) { + Supplier specRef = () -> AstUtil.createDirectMethodCall( + VariableExpression.THIS_EXPRESSION, nodeCache.MockInteractionSupport_GetSpecification, + ArgumentListExpression.EMPTY_ARGUMENTS); + // allowCreation=true (mocks can be created here). The spec is reached via + // this.getSpecification(), so static methods that declare interactions or + // create mocks get a clear "static scope" compile error. The located spec is + // always guarded with Checks.notNull, since getSpecification() may be null + // if the fixture was never attached. + ExternalInteractionRewriter rewriter = + new ExternalInteractionRewriter(nodeCache, errorReporter, sourceLookup, true); + rewriteDeclaredMethods(clazz, rewriter, specRef); + if (!sourceUnit.getErrorCollector().hasErrors()) { + new VariableScopeVisitor(sourceUnit).visitClass(clazz); + } + } + + /** Rewrites every concrete method declared directly on {@code clazz} in place. */ + void rewriteDeclaredMethods(ClassNode clazz, ExternalInteractionRewriter rewriter, Supplier specRef) { + for (MethodNode method : clazz.getMethods()) { + if (method.isAbstract() || method.getDeclaringClass() != clazz) continue; + rewriter.rewriteInPlace(method, specRef); + } + } + void processSpec(SourceUnit sourceUnit, ClassNode clazz, ErrorReporter errorReporter, SourceLookup sourceLookup) { try { Spec spec = new SpecParser(nodeCache, errorReporter).build(clazz); diff --git a/spock-core/src/main/java/org/spockframework/compiler/condition/DefaultConditionRewriterResources.java b/spock-core/src/main/java/org/spockframework/compiler/condition/DefaultConditionRewriterResources.java index 69b5d6516a..03a3787a9f 100644 --- a/spock-core/src/main/java/org/spockframework/compiler/condition/DefaultConditionRewriterResources.java +++ b/spock-core/src/main/java/org/spockframework/compiler/condition/DefaultConditionRewriterResources.java @@ -48,6 +48,16 @@ class DefaultConditionRewriterResources implements IRewriteResources { this.errorRecorders = notNull(errorRecorders); } + @Override + public Expression getSpecificationReference() { + // Preserves the historical behavior: SpecialMethodCall.expand() used to + // hardcode `this` as the spec argument. A verification helper method that + // contains a mock-creation call (e.g. the illegal `1 * Mock(Object)...`) + // reaches expand() before the "interactions are not allowed" error aborts, + // so this must yield `this` rather than throw. + return VariableExpression.THIS_EXPRESSION; + } + @Override public Method getCurrentMethod() { return method; diff --git a/spock-core/src/main/java/org/spockframework/compiler/model/ExternalInteractionMethod.java b/spock-core/src/main/java/org/spockframework/compiler/model/ExternalInteractionMethod.java new file mode 100644 index 0000000000..3997692e22 --- /dev/null +++ b/spock-core/src/main/java/org/spockframework/compiler/model/ExternalInteractionMethod.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed 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 + * + * https://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.spockframework.compiler.model; + +import org.codehaus.groovy.ast.MethodNode; + +/** + * Minimal {@link Method} model wrapping a plain {@link MethodNode} for external + * interaction rewriting. It exists only so that {@code InteractionRewriter} can + * read the underlying {@link MethodNode} (for example its static modifier). + */ +public class ExternalInteractionMethod extends Method { + public ExternalInteractionMethod(MethodNode code) { + super(null, code); + } +} diff --git a/spock-core/src/main/java/org/spockframework/mock/ClosureParameterTypeFromVariableType.java b/spock-core/src/main/java/org/spockframework/mock/ClosureParameterTypeFromVariableType.java index 0a6b6a9204..ba47fca601 100644 --- a/spock-core/src/main/java/org/spockframework/mock/ClosureParameterTypeFromVariableType.java +++ b/spock-core/src/main/java/org/spockframework/mock/ClosureParameterTypeFromVariableType.java @@ -31,6 +31,7 @@ public class ClosureParameterTypeFromVariableType extends SingleSignatureClosureHint { private final static ClassNode MOCKING_API = new ClassNode(MockingApi.class); + private final static String MOCKING_API_NAME = MockingApi.class.getName(); private final static ClassNode OBJECT = new ClassNode(Object.class); private final static Set MOCK_METHODS = new HashSet<>(); @@ -50,7 +51,7 @@ public ClassNode[] getParameterTypes(MethodNode node, String[] options, SourceUn .getAST() .getClasses() .stream() - .filter(cn -> cn.isDerivedFrom(MOCKING_API)) + .filter(ClosureParameterTypeFromVariableType::extendsOrImplementsMockingApi) .map(ClassNode::getMethods) .flatMap(Collection::stream) .map(MethodNode::getCode) @@ -85,4 +86,29 @@ public void visitMethodCallExpression(MethodCallExpression call) { })); return new ClassNode[]{result[0]}; } + + /** + * Whether {@code cn} is a {@code MockingApi}, either by extending it (the + * historical case, when {@code MockingApi} was a class) or by implementing it + * (the current case, since {@code Specification implements MockingApi}). The + * check walks the superclass chain and compares interfaces by name so it works + * regardless of how the {@link ClassNode}s were created. + */ + static boolean extendsOrImplementsMockingApi(ClassNode cn) { + for (ClassNode current = cn; current != null; current = current.getSuperClass()) { + if (MOCKING_API_NAME.equals(current.getName())) return true; + for (ClassNode iface : current.getInterfaces()) { + if (declaresInterfaceByName(iface, MOCKING_API_NAME)) return true; + } + } + return false; + } + + private static boolean declaresInterfaceByName(ClassNode iface, String name) { + if (name.equals(iface.getName())) return true; + for (ClassNode superIface : iface.getInterfaces()) { + if (declaresInterfaceByName(superIface, name)) return true; + } + return false; + } } diff --git a/spock-core/src/main/java/org/spockframework/util/Checks.java b/spock-core/src/main/java/org/spockframework/util/Checks.java index ba11a51e97..9fb1bc2562 100644 --- a/spock-core/src/main/java/org/spockframework/util/Checks.java +++ b/spock-core/src/main/java/org/spockframework/util/Checks.java @@ -55,6 +55,22 @@ public static T notNull(T object, Supplier messageProvider) { return object; } + /** + * Assert that the supplied {@link Object} is not {@code null}. + * + * @param object the object to check + * @param message violation message + * @return the supplied object as a convenience + * @throws IllegalArgumentException if the supplied object is {@code null} + */ + @Contract("null, _ -> fail") + public static T notNull(T object, String message) { + if (object == null) { + throw new IllegalArgumentException(message); + } + return object; + } + /** * Assert that the supplied array is neither {@code null} nor empty. *

diff --git a/spock-core/src/main/java/spock/lang/Specification.java b/spock-core/src/main/java/spock/lang/Specification.java index b920228999..ce456b6b77 100644 --- a/spock-core/src/main/java/spock/lang/Specification.java +++ b/spock-core/src/main/java/spock/lang/Specification.java @@ -41,7 +41,7 @@ */ @Testable @SuppressWarnings("UnusedDeclaration") -public abstract class Specification extends MockingApi { +public abstract class Specification implements MockingApi { /** * The wildcard symbol. Used in several places as a don't care value: *