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

=== Enhancements

* Improve `verifyEach` to also accept object arrays, not only `Iterable` spockPull:2398[]
* Improve `verifyEach` to also accept a `java.util.stream.Stream` spockPull:2398[]
* Improve `verifyEach` to accept a `Map`, iterating its entries with Groovy's key/value destructuring spockPull:2398[]
* 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[]

Expand All @@ -24,6 +27,7 @@ include::include.adoc[]

=== Breaking Changes

* `verifyEach` now validates the closure parameter count up front and throws an `InvalidSpecException` when the closure declares more parameters than supported (up to 2 for iterables, arrays, and streams; up to 3 for maps), instead of failing with a less clear error while iterating spockPull:2398[]
* 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[]

== 2.4 (2025-12-11)
Expand Down
14 changes: 14 additions & 0 deletions docs/spock_primer.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,20 @@ will be rendered as
include::{snapshotdir}/primer/VerifyEachDocSpec/verifyEach_with_index_method.txt[]
----

Since 2.5, `verifyEach` can also assert on the entries of a `Map`, mirroring Groovy's `Map.each` idiom.
The closure can use zero to three parameters: with no parameters the current `Map.Entry` is set as the delegate, so `key` and `value` can be referenced directly; a single parameter is the entry; two parameters are the key and the value; and a third optional parameter is the iteration index.

[source,groovy,indent=0]
----
include::{sourcedir}/primer/VerifyEachDocSpec.groovy[tag=verify-each-map]
----

will be rendered as

----
include::{snapshotdir}/primer/VerifyEachDocSpec/verifyEach_with_map_method.txt[]
----

[[specifications-as-documentation]]
== Specifications as Documentation

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,24 +181,85 @@ public static <T> void verifyEach(
@DelegatesTo(type = "T", strategy = Closure.DELEGATE_FIRST)
Closure<?> closure
) {
int parameterCount = closure.getMaximumNumberOfParameters();
// validate up front so the error is reported once instead of being collected per item
if (parameterCount > 2) {
throw new InvalidSpecException(
"verifyEach supports closures with 0 to 2 parameters (the item, optionally followed by the index), but got %d")
.withArgs(parameterCount);
}

List<InternalItemFailure<T>> failures = new ArrayList<>();
int index = -1;
// use plain loop here, so we don't generate an extra stack element on failure
for (T thing : things) {
index++;
try {
closure.setDelegate(thing); // for conditions
closure.setDelegate(thing); // for conditions, so the item can be used directly
closure.setResolveStrategy(Closure.DELEGATE_FIRST);
if (closure.getMaximumNumberOfParameters() == 1) {
GroovyRuntimeUtil.invokeClosure(closure, thing);
} else {
GroovyRuntimeUtil.invokeClosure(closure, thing, index);
switch (parameterCount) {
case 0:
GroovyRuntimeUtil.invokeClosure(closure);
break;
case 1:
GroovyRuntimeUtil.invokeClosure(closure, thing);
break;
default:
GroovyRuntimeUtil.invokeClosure(closure, thing, index);
}
} catch (Throwable throwable) {
failures.add(new InternalItemFailure<>(thing, index, throwable));
}
}

throwFailures(namer, failures);
}

public static <K, V> void verifyEach(
Map<K, V> things,
Function<? super Map.Entry<K, V>, ?> namer,
@ClosureParams(value = FromString.class, options = {"", "java.util.Map.Entry<K, V>", "K, V", "K, V, int"})
@DelegatesTo(type = "java.util.Map.Entry<K, V>", strategy = Closure.DELEGATE_FIRST)
Closure<?> closure
) {
int parameterCount = closure.getMaximumNumberOfParameters();
// validate up front so the error is reported once instead of being collected per entry
if (parameterCount > 3) {
throw new InvalidSpecException(
"verifyEach on a map supports closures with 0 to 3 parameters (the entry, or the key and value, optionally followed by the index), but got %d")
.withArgs(parameterCount);
}

List<InternalItemFailure<Map.Entry<K, V>>> failures = new ArrayList<>();
int index = -1;
// use plain loop here, so we don't generate an extra stack element on failure
for (Map.Entry<K, V> entry : things.entrySet()) {
index++;
try {
closure.setDelegate(entry); // for conditions, so key and value can be used directly
closure.setResolveStrategy(Closure.DELEGATE_FIRST);
switch (parameterCount) {
case 0:
GroovyRuntimeUtil.invokeClosure(closure);
break;
case 1:
GroovyRuntimeUtil.invokeClosure(closure, entry);
break;
case 2:
GroovyRuntimeUtil.invokeClosure(closure, entry.getKey(), entry.getValue());
break;
default:
GroovyRuntimeUtil.invokeClosure(closure, entry.getKey(), entry.getValue(), index);
}
} catch (Throwable throwable) {
failures.add(new InternalItemFailure<>(entry, index, throwable));
}
}

throwFailures(namer, failures);
}

private static <T> void throwFailures(Function<? super T, ?> namer, List<InternalItemFailure<T>> failures) {
if (failures.size() == 1) {
throw getAssertionFailedError(namer, failures.get(0));
} else if (!failures.isEmpty()) {
Expand Down
172 changes: 172 additions & 0 deletions spock-core/src/main/java/spock/lang/Specification.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@
import org.junit.platform.commons.annotation.Testable;
import spock.mock.MockingApi;

import java.util.Arrays;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Stream;

import org.spockframework.lang.ISpecificationContext;
import org.spockframework.lang.Wildcard;
Expand Down Expand Up @@ -368,4 +371,173 @@ public <U> void verifyEach(
}
SpockRuntime.verifyEach(things, namer, closure);
}

/**
* Performs assertions on each item, collecting up failures instead of stopping at first.
* <p>
* Exception messages will contain a toString() of the item to identify it.
* <p>
* The closure can either use one or two parameters.
* The first parameter will always be the item.
* The second optional parameter will be the iteration index of the item.
*
* @param things the array to inspect
* @param closure a code block containing top-level conditions
* @param <U> type of items in things
* @since 2.5
*/
@Beta
public <U> void verifyEach(
U[] things,
@ClosureParams(value = FromString.class, options = {"U", "U, int"})
@DelegatesTo(type = "U", strategy = Closure.DELEGATE_FIRST)
Closure<?> closure
) {
verifyEach(things, Objects::toString, closure);
}

/**
* Performs assertions on each item, collecting up failures instead of stopping at first.
* <p>
* Exception messages will contain the result of calling the namer for an item to identify it.
* <p>
* The closure can either use one or two parameters.
* The first parameter will always be the item.
* The second optional parameter will be the iteration index of the item.
*
* @param things the array to inspect
* @param namer the namer function to use when rendering the exception
* @param closure a code block containing top-level conditions
* @param <U> type of items in things
* @since 2.5
*/
@Beta
public <U> void verifyEach(
U[] things,
Function<? super U, ?> namer,
@ClosureParams(value = FromString.class, options = {"U", "U, int"})
@DelegatesTo(type = "U", strategy = Closure.DELEGATE_FIRST)
Closure<?> closure
) {
if (things == null) {
throw new SpockAssertionError("Target of 'verifyEach' block must not be null");
}
verifyEach(Arrays.asList(things), namer, closure);
}

/**
* Performs assertions on each element of a stream, collecting up failures instead of stopping at first.
* <p>
* Exception messages will contain a toString() of the element to identify it.
* <p>
* The closure can either use one or two parameters.
* The first parameter will always be the element.
* The second optional parameter will be the iteration index of the element.
*
* @param things the stream to inspect
* @param closure a code block containing top-level conditions
* @param <U> type of elements in things
* @since 2.5
*/
@Beta
public <U> void verifyEach(
Stream<U> things,
@ClosureParams(value = FromString.class, options = {"U", "U, int"})
@DelegatesTo(type = "U", strategy = Closure.DELEGATE_FIRST)
Closure<?> closure
) {
verifyEach(things, Objects::toString, closure);
}

/**
* Performs assertions on each element of a stream, collecting up failures instead of stopping at first.
* <p>
* Exception messages will contain the result of calling the namer for an element to identify it.
* <p>
* The closure can either use one or two parameters.
* The first parameter will always be the element.
* The second optional parameter will be the iteration index of the element.
*
* @param things the stream to inspect
* @param namer the namer function to use when rendering the exception
* @param closure a code block containing top-level conditions
* @param <U> type of elements in things
* @since 2.5
*/
@Beta
public <U> void verifyEach(
Stream<U> things,
Function<? super U, ?> namer,
@ClosureParams(value = FromString.class, options = {"U", "U, int"})
@DelegatesTo(type = "U", strategy = Closure.DELEGATE_FIRST)
Closure<?> closure
) {
if (things == null) {
throw new SpockAssertionError("Target of 'verifyEach' block must not be null");
}
// a Stream is single-use, so adapt it to an Iterable that iterates it exactly once
verifyEach((Iterable<U>) things::iterator, namer, closure);
}

/**
* Performs assertions on each entry of a map, collecting up failures instead of stopping at first.
* <p>
* Exception messages will contain a toString() of the entry ({@code key=value}) to identify it.
* <p>
* The closure can use zero to three parameters, mirroring Groovy's {@code Map.each} idiom.
* With no parameters, the delegate is the current {@link java.util.Map.Entry}, so {@code key}
* and {@code value} can be referenced directly.
* A single parameter is the entry, two parameters are the key and the value, and a third
* optional parameter is the iteration index of the entry.
*
* @param things the map to inspect
* @param closure a code block containing top-level conditions
* @param <K> type of keys in things
* @param <V> type of values in things
* @since 2.5
*/
@Beta
public <K, V> void verifyEach(
Map<K, V> things,
@ClosureParams(value = FromString.class, options = {"", "java.util.Map.Entry<K, V>", "K, V", "K, V, int"})
@DelegatesTo(type = "java.util.Map.Entry<K, V>", strategy = Closure.DELEGATE_FIRST)
Closure<?> closure
) {
verifyEach(things, Objects::toString, closure);
}

/**
* Performs assertions on each entry of a map, collecting up failures instead of stopping at first.
* <p>
* Exception messages will contain the result of calling the namer for an entry to identify it.
* <p>
* The closure can use zero to three parameters, mirroring Groovy's {@code Map.each} idiom.
* With no parameters, the delegate is the current {@link java.util.Map.Entry}, so {@code key}
* and {@code value} can be referenced directly.
* A single parameter is the entry, two parameters are the key and the value, and a third
* optional parameter is the iteration index of the entry.
*
* @param things the map to inspect
* @param namer the namer function to use when rendering the exception
* @param closure a code block containing top-level conditions
* @param <K> type of keys in things
* @param <V> type of values in things
* @since 2.5
*/
@Beta
public <K, V> void verifyEach(
Map<K, V> things,
Function<? super Map.Entry<K, V>, ?> namer,
@ClosureParams(value = FromString.class, options = {"", "java.util.Map.Entry<K, V>", "K, V", "K, V, int"})
@DelegatesTo(type = "java.util.Map.Entry<K, V>", strategy = Closure.DELEGATE_FIRST)
Closure<?> closure
) {
if (things == null) {
throw new SpockAssertionError("Target of 'verifyEach' block must not be null");
}
if (namer == null) {
throw new SpockAssertionError("Namer for a 'verifyEach' block must not be null");
}
SpockRuntime.verifyEach(things, namer, closure);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,15 @@ list.every { it == 2 }
MultipleFailuresError e = thrown()
snap.assertThat(e.message).matchesSnapshot()
}

def "verifyEach with map method"(@Snapshot Snapshotter snap) {
when:
// tag::verify-each-map[]
def map = [a: 1, b: 2, c: 3]
verifyEach(map) { key, value -> value == 2 }
// end::verify-each-map[]
then:
MultipleFailuresError e = thrown()
snap.assertThat(e.message).matchesSnapshot()
}
}
Loading
Loading