Skip to content

Commit 9c6342c

Browse files
authored
Improve TooManyInvocationsError reporting (#2315)
it now reports unsatisfied interactions with argument mismatch details. Varargs methods now correctly expand args in mismatch descriptions instead of reporting `<too few arguments>`.
1 parent c9b09cb commit 9c6342c

File tree

13 files changed

+946
-81
lines changed

13 files changed

+946
-81
lines changed

docs/release_notes.adoc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,13 @@ include::include.adoc[]
66

77
== 2.5 (tbd)
88

9+
=== Enhancements
10+
11+
* Improve `TooManyInvocationsError` now reports unsatisfied interactions with argument mismatch details, making it easier to diagnose why invocations didn't match expected interactions spockPull:2315[]
12+
913
=== Misc
1014

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

1318
== 2.4 (2025-12-11)

spock-core/src/main/java/org/spockframework/mock/IInteractionScope.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616

1717
package org.spockframework.mock;
1818

19+
import java.util.Collections;
20+
import java.util.List;
21+
1922
/**
2023
* An interaction scope holds a group of interactions that will be verified,
2124
* and thereafter removed, at the same time.
@@ -32,4 +35,14 @@ public interface IInteractionScope {
3235
IMockInteraction match(IMockInvocation invocation);
3336

3437
void verifyInteractions();
38+
39+
/**
40+
* Returns interactions that could still accept more invocations ({@code !isExhausted()}).
41+
* Used to provide diagnostic context in {@link TooManyInvocationsError}.
42+
*
43+
* @since 2.5
44+
*/
45+
default List<IMockInteraction> getNonExhaustedInteractions() {
46+
return Collections.emptyList();
47+
}
3548
}

spock-core/src/main/java/org/spockframework/mock/IMockInteraction.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@
1616

1717
package org.spockframework.mock;
1818

19-
import org.spockframework.util.Nullable;
20-
2119
import java.util.List;
2220
import java.util.function.Supplier;
2321

@@ -48,4 +46,11 @@ public interface IMockInteraction {
4846
boolean isExhausted();
4947

5048
boolean isRequired();
49+
50+
/**
51+
* @since 2.5
52+
*/
53+
default boolean matchesTargetAndMethod(IMockInvocation invocation) {
54+
return matches(invocation);
55+
}
5156
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.spockframework.mock;
18+
19+
import org.spockframework.util.Assert;
20+
import org.spockframework.util.IMultiset;
21+
22+
import java.util.*;
23+
24+
import static java.util.Collections.sort;
25+
26+
/**
27+
* Shared utilities for rendering interaction mismatch diagnostics
28+
* in {@link TooFewInvocationsError} and {@link TooManyInvocationsError}.
29+
*/
30+
class InteractionDiagnostics {
31+
private static final int MAX_MISMATCH_DESCRIPTIONS = 5;
32+
33+
/**
34+
* Score invocations from a multiset against an interaction, preserving counts.
35+
*/
36+
static List<ScoredInvocation> scoreInvocations(IMockInteraction interaction, IMultiset<IMockInvocation> invocations) {
37+
Assert.notNull(interaction);
38+
List<ScoredInvocation> result = new ArrayList<>();
39+
for (Map.Entry<IMockInvocation, Integer> entry : invocations.entrySet()) {
40+
result.add(new ScoredInvocation(entry.getKey(), entry.getValue(), interaction.computeSimilarityScore(entry.getKey())));
41+
}
42+
sort(result);
43+
return result;
44+
}
45+
46+
/**
47+
* Score invocations from a set against an interaction, filtering to only those matching target and method.
48+
*/
49+
static List<ScoredInvocation> scoreMatchingInvocations(IMockInteraction interaction, Set<IMockInvocation> invocations) {
50+
Assert.notNull(interaction);
51+
List<ScoredInvocation> result = new ArrayList<>();
52+
for (IMockInvocation invocation : invocations) {
53+
if (interaction.matchesTargetAndMethod(invocation)) {
54+
result.add(new ScoredInvocation(invocation, 0, interaction.computeSimilarityScore(invocation)));
55+
}
56+
}
57+
sort(result);
58+
return result;
59+
}
60+
61+
/**
62+
* Append scored invocations with count prefix and mismatch descriptions.
63+
* Format: {@code count * invocation\ndescribeMismatch\n}
64+
*/
65+
static void appendScoredInvocations(StringBuilder builder, IMockInteraction interaction, List<ScoredInvocation> scored) {
66+
int idx = 0;
67+
for (ScoredInvocation si : scored) {
68+
builder.append(si.count);
69+
builder.append(" * ");
70+
builder.append(si.invocation);
71+
builder.append('\n');
72+
if (idx++ < MAX_MISMATCH_DESCRIPTIONS) {
73+
appendMismatchDescription(builder, interaction, si.invocation);
74+
}
75+
}
76+
}
77+
78+
/**
79+
* Append only mismatch descriptions for scored invocations (no count/invocation header).
80+
*/
81+
static void appendMismatchDescriptions(StringBuilder builder, IMockInteraction interaction, List<ScoredInvocation> scored) {
82+
int idx = 0;
83+
for (ScoredInvocation si : scored) {
84+
if (idx++ < MAX_MISMATCH_DESCRIPTIONS) {
85+
appendMismatchDescription(builder, interaction, si.invocation);
86+
} else {
87+
break;
88+
}
89+
}
90+
}
91+
92+
private static void appendMismatchDescription(StringBuilder builder, IMockInteraction interaction, IMockInvocation invocation) {
93+
try {
94+
builder.append(interaction.describeMismatch(invocation));
95+
} catch (AssertionError | Exception e) {
96+
builder.append("<Renderer threw Exception>: ").append(e.getMessage());
97+
}
98+
builder.append('\n');
99+
}
100+
101+
static class ScoredInvocation implements Comparable<ScoredInvocation> {
102+
final IMockInvocation invocation;
103+
final int count;
104+
final int score;
105+
106+
ScoredInvocation(IMockInvocation invocation, int count, int score) {
107+
Assert.notNull(invocation);
108+
this.invocation = invocation;
109+
this.count = count;
110+
this.score = score;
111+
}
112+
113+
@Override
114+
public int compareTo(ScoredInvocation other) {
115+
int result = Integer.compare(score, other.score);
116+
if (result != 0) return result;
117+
return invocation.toString().compareTo(other.invocation.toString());
118+
}
119+
}
120+
}

spock-core/src/main/java/org/spockframework/mock/TooFewInvocationsError.java

Lines changed: 2 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@
2121
import java.io.IOException;
2222
import java.util.*;
2323

24-
import static java.util.Collections.sort;
25-
2624
/**
2725
* Thrown to indicate that one or more mandatory interactions matched too few invocations.
2826
*
@@ -54,26 +52,12 @@ public synchronized String getMessage() {
5452
builder.append("Too few invocations for:\n\n");
5553
builder.append(interaction);
5654
builder.append("\n\n");
57-
List<ScoredInvocation> scoredInvocations = scoreInvocations(interaction, unmatchedMultiInvocations);
55+
List<InteractionDiagnostics.ScoredInvocation> scoredInvocations = InteractionDiagnostics.scoreInvocations(interaction, unmatchedMultiInvocations);
5856
builder.append("Unmatched invocations (ordered by similarity):\n\n");
5957
if (scoredInvocations.isEmpty()) {
6058
builder.append("None\n");
6159
} else {
62-
int idx = 0;
63-
for (ScoredInvocation scoredInvocation : scoredInvocations) {
64-
builder.append(scoredInvocation.count);
65-
builder.append(" * ");
66-
builder.append(scoredInvocation.invocation);
67-
builder.append('\n');
68-
if (idx++ < 5) {
69-
try {
70-
builder.append(interaction.describeMismatch(scoredInvocation.invocation));
71-
} catch (AssertionError | Exception e) {
72-
builder.append("<Renderer threw Exception>: ").append(e.getMessage());
73-
}
74-
builder.append('\n');
75-
}
76-
}
60+
InteractionDiagnostics.appendScoredInvocations(builder, interaction, scoredInvocations);
7761
}
7862
builder.append('\n');
7963
}
@@ -87,30 +71,4 @@ private void writeObject(java.io.ObjectOutputStream out) throws IOException {
8771
getMessage();
8872
out.defaultWriteObject();
8973
}
90-
91-
private List<ScoredInvocation> scoreInvocations(IMockInteraction interaction, IMultiset<IMockInvocation> invocations) {
92-
List<ScoredInvocation> result = new ArrayList<>();
93-
for (Map.Entry<IMockInvocation, Integer> entry : invocations.entrySet()) {
94-
result.add(new ScoredInvocation(entry.getKey(), entry.getValue(), interaction.computeSimilarityScore(entry.getKey())));
95-
}
96-
sort(result);
97-
return result;
98-
}
99-
100-
private static class ScoredInvocation implements Comparable<ScoredInvocation> {
101-
final IMockInvocation invocation;
102-
final int count;
103-
final int score;
104-
105-
private ScoredInvocation(IMockInvocation invocation, int count, int score) {
106-
this.invocation = invocation;
107-
this.count = count;
108-
this.score = score;
109-
}
110-
111-
@Override
112-
public int compareTo(ScoredInvocation other) {
113-
return score - other.score;
114-
}
115-
}
11674
}

spock-core/src/main/java/org/spockframework/mock/TooManyInvocationsError.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ public class TooManyInvocationsError extends InteractionNotSatisfiedError {
3131

3232
private final transient IMockInteraction interaction;
3333
private final transient List<IMockInvocation> acceptedInvocations;
34+
private transient List<IMockInteraction> unsatisfiedInteractions;
3435
private String message;
3536

3637
public TooManyInvocationsError(IMockInteraction interaction, List<IMockInvocation> acceptedInvocations) {
38+
Assert.notNull(interaction);
3739
this.interaction = interaction;
3840
this.acceptedInvocations = acceptedInvocations;
3941
}
@@ -46,6 +48,11 @@ public List<IMockInvocation> getAcceptedInvocations() {
4648
return acceptedInvocations;
4749
}
4850

51+
public void enrichWithScopeContext(List<IMockInteraction> unsatisfiedInteractions) {
52+
this.unsatisfiedInteractions = unsatisfiedInteractions;
53+
this.message = null;
54+
}
55+
4956
@Override
5057
public synchronized String getMessage() {
5158
if (message != null) return message;
@@ -73,9 +80,44 @@ public synchronized String getMessage() {
7380
}
7481
builder.append("\n");
7582

83+
if (unsatisfiedInteractions != null && !unsatisfiedInteractions.isEmpty()) {
84+
appendUnmatchedInteractions(builder);
85+
}
86+
7687
message = builder.toString();
7788
return message;
7889
}
90+
91+
private void appendUnmatchedInteractions(StringBuilder builder) {
92+
Set<IMockInvocation> acceptedPool = new LinkedHashSet<>(acceptedInvocations);
93+
94+
// Filter to unsatisfied interactions where at least one accepted invocation matches target+method
95+
List<IMockInteraction> relevantUnsatisfied = new ArrayList<>();
96+
for (IMockInteraction unsatisfied : unsatisfiedInteractions) {
97+
for (IMockInvocation invocation : acceptedPool) {
98+
if (unsatisfied.matchesTargetAndMethod(invocation)) {
99+
relevantUnsatisfied.add(unsatisfied);
100+
break;
101+
}
102+
}
103+
}
104+
105+
if (relevantUnsatisfied.isEmpty()) {
106+
return;
107+
}
108+
109+
builder.append("Unmatched invocations (ordered by similarity):\n\n");
110+
111+
for (IMockInteraction unsatisfied : relevantUnsatisfied) {
112+
builder.append(unsatisfied);
113+
builder.append('\n');
114+
List<InteractionDiagnostics.ScoredInvocation> scored = InteractionDiagnostics.scoreMatchingInvocations(unsatisfied, acceptedPool);
115+
InteractionDiagnostics.appendMismatchDescriptions(builder, unsatisfied, scored);
116+
}
117+
118+
builder.append('\n');
119+
}
120+
79121
private void writeObject(java.io.ObjectOutputStream out) throws IOException {
80122
// create the message so that it is available for serialization
81123
getMessage();

spock-core/src/main/java/org/spockframework/mock/constraint/PositionalArgumentListConstraint.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ public boolean isSatisfiedBy(IMockInvocation invocation) {
5050
public String describeMismatch(IMockInvocation invocation) {
5151
List<Object> args = invocation.getArguments();
5252

53+
if (argConstraints.size() != args.size() && hasExpandableVarArgs(invocation.getMethod(), args)) {
54+
args = expandVarArgs(args);
55+
}
56+
5357
if (argConstraints.isEmpty()) return "<no args expected>";
5458
int constraintsToArgs = argConstraints.size() - args.size();
5559
if (constraintsToArgs > 0) return "<too few arguments>";

spock-core/src/main/java/org/spockframework/mock/runtime/InteractionScope.java

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,29 @@ public IMockInteraction match(IMockInvocation invocation) {
101101

102102
@Override
103103
public void verifyInteractions() {
104-
List<IMockInteraction> unsatisfiedInteractions = new ArrayList<>();
105-
106-
for (IMockInteraction interaction : interactions)
107-
if (!interaction.isSatisfied()) unsatisfiedInteractions.add(interaction);
108-
104+
List<IMockInteraction> unsatisfiedInteractions = getUnsatisfiedInteractions();
109105
if (!unsatisfiedInteractions.isEmpty())
110106
throw new TooFewInvocationsError(unsatisfiedInteractions, unmatchedInvocations);
111107
}
108+
109+
@Override
110+
public List<IMockInteraction> getNonExhaustedInteractions() {
111+
List<IMockInteraction> result = new ArrayList<>();
112+
for (IMockInteraction interaction : interactions) {
113+
if (!interaction.isExhausted()) {
114+
result.add(interaction);
115+
}
116+
}
117+
return result;
118+
}
119+
120+
private List<IMockInteraction> getUnsatisfiedInteractions() {
121+
List<IMockInteraction> result = new ArrayList<>();
122+
for (IMockInteraction interaction : interactions) {
123+
if (!interaction.isSatisfied()) {
124+
result.add(interaction);
125+
}
126+
}
127+
return result;
128+
}
112129
}

0 commit comments

Comments
 (0)