Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/interaction_based_testing.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions docs/release_notes.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<interaction_based_testing.adoc#external-interactions,Declaring Interactions Outside Specifications>>

=== Misc

Expand All @@ -20,6 +21,7 @@ include::include.adoc[]
=== Breaking Changes

* Mock/Stub checks on `Comparable<T>` 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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -274,7 +274,7 @@ private boolean handleThrownCall(MethodCallExpression expr) {

foundExceptionCondition = expr;
if (currSpecialMethodCall.isThrownCall()) {
currSpecialMethodCall.expand();
currSpecialMethodCall.expand(resources.getSpecificationReference());
}
return true;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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}.
*
* <p>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<Expression> 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<Statement> 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<Expression> 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(<specRef>, "...")}, 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.";
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<Expression> 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<Expression> 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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -61,5 +62,5 @@ public interface ISpecialMethodCall {

ClosureExpression getClosureExpr();

void expand();
void expand(Expression specificationReference);
}
Loading
Loading