Skip to content
Merged
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
5 changes: 5 additions & 0 deletions docs/release_notes.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ include::include.adoc[]

== 2.5 (tbd)

=== Enhancements

* Improve `TooManyInvocationsError` now reports unsatisfied interactions with argument mismatch details, making it easier to diagnose why invocations didn't match expected interactions spockPull:2315[]

=== Misc

* Fix argument mismatch descriptions for varargs methods by expanding varargs instead of reporting `<too few arguments>` spockPull:2315[]
* Fix Pattern flags being dropped when `java.util.regex.Pattern` instances are used in Spock regex conditions spockIssue:2298[]

== 2.4 (2025-12-11)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@

package org.spockframework.mock;

import java.util.Collections;
import java.util.List;

/**
* An interaction scope holds a group of interactions that will be verified,
* and thereafter removed, at the same time.
Expand All @@ -32,4 +35,14 @@ public interface IInteractionScope {
IMockInteraction match(IMockInvocation invocation);

void verifyInteractions();

/**
* Returns interactions that could still accept more invocations ({@code !isExhausted()}).
* Used to provide diagnostic context in {@link TooManyInvocationsError}.
*
* @since 2.5
*/
default List<IMockInteraction> getNonExhaustedInteractions() {
return Collections.emptyList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@

package org.spockframework.mock;

import org.spockframework.util.Nullable;

import java.util.List;
import java.util.function.Supplier;

Expand Down Expand Up @@ -48,4 +46,11 @@ public interface IMockInteraction {
boolean isExhausted();

boolean isRequired();

/**
* @since 2.5
*/
default boolean matchesTargetAndMethod(IMockInvocation invocation) {
return matches(invocation);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* Copyright 2025 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.mock;

import org.spockframework.util.Assert;
import org.spockframework.util.IMultiset;

import java.util.*;

import static java.util.Collections.sort;

/**
* Shared utilities for rendering interaction mismatch diagnostics
* in {@link TooFewInvocationsError} and {@link TooManyInvocationsError}.
*/
class InteractionDiagnostics {
private static final int MAX_MISMATCH_DESCRIPTIONS = 5;

/**
* Score invocations from a multiset against an interaction, preserving counts.
*/
static List<ScoredInvocation> scoreInvocations(IMockInteraction interaction, IMultiset<IMockInvocation> invocations) {
Assert.notNull(interaction);
List<ScoredInvocation> result = new ArrayList<>();
for (Map.Entry<IMockInvocation, Integer> entry : invocations.entrySet()) {
result.add(new ScoredInvocation(entry.getKey(), entry.getValue(), interaction.computeSimilarityScore(entry.getKey())));
}
sort(result);
return result;
}

/**
* Score invocations from a set against an interaction, filtering to only those matching target and method.
*/
static List<ScoredInvocation> scoreMatchingInvocations(IMockInteraction interaction, Set<IMockInvocation> invocations) {
Assert.notNull(interaction);
List<ScoredInvocation> result = new ArrayList<>();
for (IMockInvocation invocation : invocations) {
if (interaction.matchesTargetAndMethod(invocation)) {
result.add(new ScoredInvocation(invocation, 0, interaction.computeSimilarityScore(invocation)));
}
}
sort(result);
return result;
}

/**
* Append scored invocations with count prefix and mismatch descriptions.
* Format: {@code count * invocation\ndescribeMismatch\n}
*/
static void appendScoredInvocations(StringBuilder builder, IMockInteraction interaction, List<ScoredInvocation> scored) {
int idx = 0;
for (ScoredInvocation si : scored) {
builder.append(si.count);
builder.append(" * ");
builder.append(si.invocation);
builder.append('\n');
if (idx++ < MAX_MISMATCH_DESCRIPTIONS) {
appendMismatchDescription(builder, interaction, si.invocation);
}
}
}

/**
* Append only mismatch descriptions for scored invocations (no count/invocation header).
*/
static void appendMismatchDescriptions(StringBuilder builder, IMockInteraction interaction, List<ScoredInvocation> scored) {
int idx = 0;
for (ScoredInvocation si : scored) {
if (idx++ < MAX_MISMATCH_DESCRIPTIONS) {
appendMismatchDescription(builder, interaction, si.invocation);
} else {
break;
}
}
}

private static void appendMismatchDescription(StringBuilder builder, IMockInteraction interaction, IMockInvocation invocation) {
try {
builder.append(interaction.describeMismatch(invocation));
} catch (AssertionError | Exception e) {
builder.append("<Renderer threw Exception>: ").append(e.getMessage());
}
builder.append('\n');
}

static class ScoredInvocation implements Comparable<ScoredInvocation> {
final IMockInvocation invocation;
final int count;
final int score;

ScoredInvocation(IMockInvocation invocation, int count, int score) {
Assert.notNull(invocation);
this.invocation = invocation;
this.count = count;
this.score = score;
}

@Override
public int compareTo(ScoredInvocation other) {
int result = Integer.compare(score, other.score);
if (result != 0) return result;
return invocation.toString().compareTo(other.invocation.toString());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@
import java.io.IOException;
import java.util.*;

import static java.util.Collections.sort;

/**
* Thrown to indicate that one or more mandatory interactions matched too few invocations.
*
Expand Down Expand Up @@ -54,26 +52,12 @@ public synchronized String getMessage() {
builder.append("Too few invocations for:\n\n");
builder.append(interaction);
builder.append("\n\n");
List<ScoredInvocation> scoredInvocations = scoreInvocations(interaction, unmatchedMultiInvocations);
List<InteractionDiagnostics.ScoredInvocation> scoredInvocations = InteractionDiagnostics.scoreInvocations(interaction, unmatchedMultiInvocations);
builder.append("Unmatched invocations (ordered by similarity):\n\n");
if (scoredInvocations.isEmpty()) {
builder.append("None\n");
} else {
int idx = 0;
for (ScoredInvocation scoredInvocation : scoredInvocations) {
builder.append(scoredInvocation.count);
builder.append(" * ");
builder.append(scoredInvocation.invocation);
builder.append('\n');
if (idx++ < 5) {
try {
builder.append(interaction.describeMismatch(scoredInvocation.invocation));
} catch (AssertionError | Exception e) {
builder.append("<Renderer threw Exception>: ").append(e.getMessage());
}
builder.append('\n');
}
}
InteractionDiagnostics.appendScoredInvocations(builder, interaction, scoredInvocations);
}
builder.append('\n');
}
Expand All @@ -87,30 +71,4 @@ private void writeObject(java.io.ObjectOutputStream out) throws IOException {
getMessage();
out.defaultWriteObject();
}

private List<ScoredInvocation> scoreInvocations(IMockInteraction interaction, IMultiset<IMockInvocation> invocations) {
List<ScoredInvocation> result = new ArrayList<>();
for (Map.Entry<IMockInvocation, Integer> entry : invocations.entrySet()) {
result.add(new ScoredInvocation(entry.getKey(), entry.getValue(), interaction.computeSimilarityScore(entry.getKey())));
}
sort(result);
return result;
}

private static class ScoredInvocation implements Comparable<ScoredInvocation> {
final IMockInvocation invocation;
final int count;
final int score;

private ScoredInvocation(IMockInvocation invocation, int count, int score) {
this.invocation = invocation;
this.count = count;
this.score = score;
}

@Override
public int compareTo(ScoredInvocation other) {
return score - other.score;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ public class TooManyInvocationsError extends InteractionNotSatisfiedError {

private final transient IMockInteraction interaction;
private final transient List<IMockInvocation> acceptedInvocations;
private transient List<IMockInteraction> unsatisfiedInteractions;
private String message;

public TooManyInvocationsError(IMockInteraction interaction, List<IMockInvocation> acceptedInvocations) {
Assert.notNull(interaction);
this.interaction = interaction;
this.acceptedInvocations = acceptedInvocations;
}
Expand All @@ -46,6 +48,11 @@ public List<IMockInvocation> getAcceptedInvocations() {
return acceptedInvocations;
}

public void enrichWithScopeContext(List<IMockInteraction> unsatisfiedInteractions) {
this.unsatisfiedInteractions = unsatisfiedInteractions;
this.message = null;
}

@Override
public synchronized String getMessage() {
if (message != null) return message;
Expand Down Expand Up @@ -73,9 +80,44 @@ public synchronized String getMessage() {
}
builder.append("\n");

if (unsatisfiedInteractions != null && !unsatisfiedInteractions.isEmpty()) {
appendUnmatchedInteractions(builder);
}

message = builder.toString();
return message;
}

private void appendUnmatchedInteractions(StringBuilder builder) {
Set<IMockInvocation> acceptedPool = new LinkedHashSet<>(acceptedInvocations);

// Filter to unsatisfied interactions where at least one accepted invocation matches target+method
List<IMockInteraction> relevantUnsatisfied = new ArrayList<>();
for (IMockInteraction unsatisfied : unsatisfiedInteractions) {
for (IMockInvocation invocation : acceptedPool) {
if (unsatisfied.matchesTargetAndMethod(invocation)) {
relevantUnsatisfied.add(unsatisfied);
break;
}
}
}

if (relevantUnsatisfied.isEmpty()) {
return;
}

builder.append("Unmatched invocations (ordered by similarity):\n\n");

for (IMockInteraction unsatisfied : relevantUnsatisfied) {
builder.append(unsatisfied);
builder.append('\n');
List<InteractionDiagnostics.ScoredInvocation> scored = InteractionDiagnostics.scoreMatchingInvocations(unsatisfied, acceptedPool);
InteractionDiagnostics.appendMismatchDescriptions(builder, unsatisfied, scored);
}

builder.append('\n');
}

private void writeObject(java.io.ObjectOutputStream out) throws IOException {
// create the message so that it is available for serialization
getMessage();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ public boolean isSatisfiedBy(IMockInvocation invocation) {
public String describeMismatch(IMockInvocation invocation) {
List<Object> args = invocation.getArguments();

if (argConstraints.size() != args.size() && hasExpandableVarArgs(invocation.getMethod(), args)) {
args = expandVarArgs(args);
}

if (argConstraints.isEmpty()) return "<no args expected>";
int constraintsToArgs = argConstraints.size() - args.size();
if (constraintsToArgs > 0) return "<too few arguments>";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,29 @@ public IMockInteraction match(IMockInvocation invocation) {

@Override
public void verifyInteractions() {
List<IMockInteraction> unsatisfiedInteractions = new ArrayList<>();

for (IMockInteraction interaction : interactions)
if (!interaction.isSatisfied()) unsatisfiedInteractions.add(interaction);

List<IMockInteraction> unsatisfiedInteractions = getUnsatisfiedInteractions();
if (!unsatisfiedInteractions.isEmpty())
throw new TooFewInvocationsError(unsatisfiedInteractions, unmatchedInvocations);
}

@Override
public List<IMockInteraction> getNonExhaustedInteractions() {
List<IMockInteraction> result = new ArrayList<>();
for (IMockInteraction interaction : interactions) {
if (!interaction.isExhausted()) {
result.add(interaction);
}
}
return result;
}

private List<IMockInteraction> getUnsatisfiedInteractions() {
List<IMockInteraction> result = new ArrayList<>();
for (IMockInteraction interaction : interactions) {
if (!interaction.isSatisfied()) {
result.add(interaction);
}
}
return result;
}
}
Loading