diff --git a/docs/release_notes.adoc b/docs/release_notes.adoc index 3241d6b7bc..9e79bbde71 100644 --- a/docs/release_notes.adoc +++ b/docs/release_notes.adoc @@ -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[] @@ -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` 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) diff --git a/docs/spock_primer.adoc b/docs/spock_primer.adoc index 74d16d7e8a..e82913e21a 100644 --- a/docs/spock_primer.adoc +++ b/docs/spock_primer.adoc @@ -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 diff --git a/spock-core/src/main/java/org/spockframework/runtime/SpockRuntime.java b/spock-core/src/main/java/org/spockframework/runtime/SpockRuntime.java index 61cda0712f..0b752255fd 100644 --- a/spock-core/src/main/java/org/spockframework/runtime/SpockRuntime.java +++ b/spock-core/src/main/java/org/spockframework/runtime/SpockRuntime.java @@ -181,24 +181,85 @@ public static 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> 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 void verifyEach( + Map things, + Function, ?> namer, + @ClosureParams(value = FromString.class, options = {"", "java.util.Map.Entry", "K, V", "K, V, int"}) + @DelegatesTo(type = "java.util.Map.Entry", 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>> failures = new ArrayList<>(); + int index = -1; + // use plain loop here, so we don't generate an extra stack element on failure + for (Map.Entry 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 void throwFailures(Function namer, List> failures) { if (failures.size() == 1) { throw getAssertionFailedError(namer, failures.get(0)); } else if (!failures.isEmpty()) { diff --git a/spock-core/src/main/java/spock/lang/Specification.java b/spock-core/src/main/java/spock/lang/Specification.java index b920228999..82a5d8bafd 100644 --- a/spock-core/src/main/java/spock/lang/Specification.java +++ b/spock-core/src/main/java/spock/lang/Specification.java @@ -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; @@ -368,4 +371,173 @@ public void verifyEach( } SpockRuntime.verifyEach(things, namer, closure); } + + /** + * Performs assertions on each item, collecting up failures instead of stopping at first. + *

+ * Exception messages will contain a toString() of the item to identify it. + *

+ * 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 type of items in things + * @since 2.5 + */ + @Beta + public 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. + *

+ * Exception messages will contain the result of calling the namer for an item to identify it. + *

+ * 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 type of items in things + * @since 2.5 + */ + @Beta + public void verifyEach( + U[] things, + Function 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. + *

+ * Exception messages will contain a toString() of the element to identify it. + *

+ * 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 type of elements in things + * @since 2.5 + */ + @Beta + public void verifyEach( + Stream 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. + *

+ * Exception messages will contain the result of calling the namer for an element to identify it. + *

+ * 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 type of elements in things + * @since 2.5 + */ + @Beta + public void verifyEach( + Stream things, + Function 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) things::iterator, namer, closure); + } + + /** + * Performs assertions on each entry of a map, collecting up failures instead of stopping at first. + *

+ * Exception messages will contain a toString() of the entry ({@code key=value}) to identify it. + *

+ * 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 type of keys in things + * @param type of values in things + * @since 2.5 + */ + @Beta + public void verifyEach( + Map things, + @ClosureParams(value = FromString.class, options = {"", "java.util.Map.Entry", "K, V", "K, V, int"}) + @DelegatesTo(type = "java.util.Map.Entry", 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. + *

+ * Exception messages will contain the result of calling the namer for an entry to identify it. + *

+ * 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 type of keys in things + * @param type of values in things + * @since 2.5 + */ + @Beta + public void verifyEach( + Map things, + Function, ?> namer, + @ClosureParams(value = FromString.class, options = {"", "java.util.Map.Entry", "K, V", "K, V, int"}) + @DelegatesTo(type = "java.util.Map.Entry", 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); + } } diff --git a/spock-specs/src/test/groovy/org/spockframework/docs/primer/VerifyEachDocSpec.groovy b/spock-specs/src/test/groovy/org/spockframework/docs/primer/VerifyEachDocSpec.groovy index a2a0d7f77a..3700ab59b0 100644 --- a/spock-specs/src/test/groovy/org/spockframework/docs/primer/VerifyEachDocSpec.groovy +++ b/spock-specs/src/test/groovy/org/spockframework/docs/primer/VerifyEachDocSpec.groovy @@ -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() + } } diff --git a/spock-specs/src/test/groovy/org/spockframework/smoke/VerifyEachBlocks.groovy b/spock-specs/src/test/groovy/org/spockframework/smoke/VerifyEachBlocks.groovy index 395b068b39..e374381548 100644 --- a/spock-specs/src/test/groovy/org/spockframework/smoke/VerifyEachBlocks.groovy +++ b/spock-specs/src/test/groovy/org/spockframework/smoke/VerifyEachBlocks.groovy @@ -18,6 +18,7 @@ package org.spockframework.smoke import org.opentest4j.MultipleFailuresError import org.spockframework.EmbeddedSpecification +import org.spockframework.runtime.InvalidSpecException import org.spockframework.runtime.SpockAssertionError import spock.lang.Snapshot import spock.lang.Snapshotter @@ -145,6 +146,257 @@ class VerifyEachBlocks extends EmbeddedSpecification { } } + def "verifyEach supports object arrays"() { + given: + Integer[] array = [1, 2, 3] + + expect: + verifyEach(array) { + it > 0 + } + + and: 'parameter is the same as the delegate' + verifyEach(array) { + it == delegate + } + } + + def "verifyEach on an array handles a failed element verification"() { + given: + Integer[] array = [1, 2, 3] + + when: + verifyEach(array) { + it < 3 + } + + then: + SpockAssertionError e = thrown() + e.message.contains('item[2] 3') + } + + def "verifyEach on an array supports the namer"() { + given: + Integer[] array = [1, 2, 3] + + when: + verifyEach(array, { "int($it)" }) { + it < 3 + } + + then: + SpockAssertionError e = thrown() + e.message.contains('int(3)') + } + + def "verifyEach on an array can have an optional index parameter"() { + given: + Integer[] array = [1, 2, 3] + + expect: + verifyEach(array) { it, index -> + it == index + 1 + } + } + + def "verifyEach on a null array fails with a clear message"() { + when: + verifyEach((Integer[]) null) { + it > 0 + } + + then: + SpockAssertionError e = thrown() + e.message == "Target of 'verifyEach' block must not be null" + } + + def "verifyEach supports streams"() { + given: + def stream = [1, 2, 3].stream() + + expect: + verifyEach(stream) { + it > 0 + } + } + + def "verifyEach on a stream handles a failed element verification"() { + given: + def stream = [1, 2, 3].stream() + + when: + verifyEach(stream) { + it < 3 + } + + then: + SpockAssertionError e = thrown() + e.message.contains('item[2] 3') + } + + def "verifyEach on a stream supports the namer"() { + given: + def stream = [1, 2, 3].stream() + + when: + verifyEach(stream, { "int($it)" }) { + it < 3 + } + + then: + SpockAssertionError e = thrown() + e.message.contains('int(3)') + } + + def "verifyEach on a stream can have an optional index parameter"() { + given: + def stream = [1, 2, 3].stream() + + expect: + verifyEach(stream) { it, index -> + it == index + 1 + } + } + + def "verifyEach on a null stream fails with a clear message"() { + when: + verifyEach((java.util.stream.Stream) null) { + it > 0 + } + + then: + SpockAssertionError e = thrown() + e.message == "Target of 'verifyEach' block must not be null" + } + + def "verifyEach supports maps with a key and value parameter"() { + given: + def map = [a: 1, b: 2, c: 3] + + expect: + verifyEach(map) { key, value -> + key instanceof String + value > 0 + } + } + + def "verifyEach supports maps with a single entry parameter"() { + given: + def map = [a: 1, b: 2, c: 3] + + expect: + verifyEach(map) { entry -> + entry.value > 0 + } + } + + def "verifyEach on a map delegates to the entry so key and value can be used directly"() { + given: + def map = [a: 1, b: 2, c: 3] + + expect: + verifyEach(map) { + value > 0 + key instanceof String + } + } + + def "verifyEach on a map can have an optional index parameter"() { + given: + def map = [a: 1, b: 2, c: 3] + + expect: + verifyEach(map) { key, value, index -> + value == index + 1 + } + } + + def "verifyEach on a map handles a failed element verification"() { + given: + def map = [a: 1, b: 2, c: 3] + + when: + verifyEach(map) { key, value -> + value < 3 + } + + then: + SpockAssertionError e = thrown() + e.message.contains('item[2] c=3') + } + + def "verifyEach on a map supports the namer"() { + given: + def map = [a: 1, b: 2, c: 3] + + when: + verifyEach(map, { "entry($it.key)" }) { key, value -> + value < 3 + } + + then: + SpockAssertionError e = thrown() + e.message.contains('entry(c)') + } + + def "verifyEach on a null map fails with a clear message"() { + when: + verifyEach((Map) null) { + value > 0 + } + + then: + SpockAssertionError e = thrown() + e.message == "Target of 'verifyEach' block must not be null" + } + + def "verifyEach on a map supports a closure with no parameters using the delegate"() { + given: + def map = [a: 1, b: 2, c: 3] + + expect: + verifyEach(map) { -> + value > 0 + } + } + + def "verifyEach rejects a closure with too many parameters for an iterable"() { + when: + verifyEach([1, 2, 3]) { a, b, c -> } + + then: + InvalidSpecException e = thrown() + e.message == "verifyEach supports closures with 0 to 2 parameters (the item, optionally followed by the index), but got 3" + } + + def "verifyEach on an iterable supports a closure with no parameters using the delegate"() { + given: + def list = ['abc', 'defg'] + + expect: + verifyEach(list) { -> + length() > 2 + } + } + + def "verifyEach rejects a closure with too many parameters for an array"() { + when: + verifyEach([1, 2, 3] as Integer[]) { a, b, c -> } + + then: + InvalidSpecException e = thrown() + e.message == "verifyEach supports closures with 0 to 2 parameters (the item, optionally followed by the index), but got 3" + } + + def "verifyEach rejects a closure with too many parameters for a map"() { + when: + verifyEach([a: 1, b: 2, c: 3]) { a, b, c, d -> } + + then: + InvalidSpecException e = thrown() + e.message == "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 4" + } + void checks(int x) { doCheck(x, this.&nestedException) doCheck(x, this.&nestedAssertion) diff --git a/spock-specs/src/test/resources/snapshots/org/spockframework/docs/primer/VerifyEachDocSpec/verifyEach_with_map_method.txt b/spock-specs/src/test/resources/snapshots/org/spockframework/docs/primer/VerifyEachDocSpec/verifyEach_with_map_method.txt new file mode 100644 index 0000000000..b934a832fd --- /dev/null +++ b/spock-specs/src/test/resources/snapshots/org/spockframework/docs/primer/VerifyEachDocSpec/verifyEach_with_map_method.txt @@ -0,0 +1,14 @@ +Multiple Failures (2 failures) + org.spockframework.runtime.SpockAssertionError: Assertions failed for item[0] a=1: +Condition not satisfied: + +value == 2 +| | +1 false + + org.spockframework.runtime.SpockAssertionError: Assertions failed for item[2] c=3: +Condition not satisfied: + +value == 2 +| | +3 false