From 7f2c93fa1f937ca6760671122d968db8a7186caa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Basl=C3=A9?= Date: Mon, 13 Feb 2023 17:51:01 +0100 Subject: [PATCH] Allow MockRest to match header/queryParam value list with one Matcher This commit adds a `header` variant and a `queryParam` variant to the `MockRestRequestMatchers` API which take a single `Matcher` over the list of values. Contrary to the vararg variants, the whole list is evaluated and the caller can choose the desired semantics using readily-available iterable matchers like `everyItem`, `hasItems`, `hasSize`, `contains` or `containsInAnyOrder`... The fact that the previous variants don't strictly check the size of the actual list == the number of provided matchers or expected values is now documented in their respective javadocs. See gh-29953 Closes gh-29964 --- .../client/match/MockRestRequestMatchers.java | 67 ++++++++- .../match/MockRestRequestMatchersTests.java | 134 +++++++++++++++++- 2 files changed, 199 insertions(+), 2 deletions(-) diff --git a/spring-test/src/main/java/org/springframework/test/web/client/match/MockRestRequestMatchers.java b/spring-test/src/main/java/org/springframework/test/web/client/match/MockRestRequestMatchers.java index ff3f6c27a7df..170116054410 100644 --- a/spring-test/src/main/java/org/springframework/test/web/client/match/MockRestRequestMatchers.java +++ b/spring-test/src/main/java/org/springframework/test/web/client/match/MockRestRequestMatchers.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2023 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. @@ -23,6 +23,7 @@ import javax.xml.xpath.XPathExpressionException; import org.hamcrest.Matcher; +import org.hamcrest.Matchers; import org.springframework.http.HttpMethod; import org.springframework.http.client.ClientHttpRequest; @@ -114,6 +115,11 @@ public static RequestMatcher requestTo(URI uri) { /** * Assert request query parameter values with the given Hamcrest matcher(s). + *

Note that if the queryParam value list is larger than the number of provided + * {@code matchers}, extra values are considered acceptable. + * See {@link #queryParam(String, Matcher)} for a variant that takes a + * {@code Matcher} over the whole list of values. + * @see #queryParam(String, Matcher) */ @SafeVarargs public static RequestMatcher queryParam(String name, Matcher... matchers) { @@ -128,6 +134,11 @@ public static RequestMatcher queryParam(String name, Matcher... /** * Assert request query parameter values. + *

Note that if the queryParam value list is larger than {@code expectedValues}, + * extra values are considered acceptable. + * See {@link #queryParam(String, Matcher)} for a variant that takes a + * {@code Matcher} over the whole list of values. + * @see #queryParam(String, Matcher) */ public static RequestMatcher queryParam(String name, String... expectedValues) { return request -> { @@ -139,6 +150,29 @@ public static RequestMatcher queryParam(String name, String... expectedValues) { }; } + /** + * Assert request query parameter, matching on the whole {@code List} of values. + *

This can be used to check that the list has at least one value matching a + * criteria ({@link Matchers#hasItem(Matcher)}), or that every value in the list + * matches a common criteria ({@link Matchers#everyItem(Matcher)}), or that each + * value in the list matches its corresponding dedicated criteria + * ({@link Matchers#contains(Matcher[])}, and more. + * @param name the name of the queryParam to consider + * @param matcher the matcher to apply to the whole list of values for that header + * @since 6.0.5 + */ + public static RequestMatcher queryParam(String name, Matcher> matcher) { + return request -> { + MultiValueMap params = getQueryParams(request); + List paramValues = params.get(name); + if (paramValues == null) { + fail("No queryParam [" + name + "]"); + } + assertThat("Request queryParam values for [" + name + "]", paramValues, matcher); + }; + } + + private static MultiValueMap getQueryParams(ClientHttpRequest request) { return UriComponentsBuilder.fromUri(request.getURI()).build().getQueryParams(); } @@ -158,6 +192,11 @@ private static void assertValueCount( /** * Assert request header values with the given Hamcrest matcher(s). + *

Note that if the header's value list is larger than the number of provided + * {@code matchers}, extra values are considered acceptable. + * See {@link #header(String, Matcher)} for a variant that takes a {@code Matcher} + * over the whole list of values. + * @see #header(String, Matcher) */ @SafeVarargs public static RequestMatcher header(String name, Matcher... matchers) { @@ -173,6 +212,11 @@ public static RequestMatcher header(String name, Matcher... matc /** * Assert request header values. + *

Note that if the header's value list is larger than {@code expectedValues}, + * extra values are considered acceptable. + * See {@link #header(String, Matcher)} for a variant that takes a {@code Matcher} + * over the whole list of values. + * @see #header(String, Matcher) */ public static RequestMatcher header(String name, String... expectedValues) { return request -> { @@ -185,6 +229,27 @@ public static RequestMatcher header(String name, String... expectedValues) { }; } + /** + * Assert request header, matching on the whole {@code List} of values. + *

This can be used to check that the list has at least one value matching a + * criteria ({@link Matchers#hasItem(Matcher)}), or that every value in the list + * matches a common criteria ({@link Matchers#everyItem(Matcher)}), or that each + * value in the list matches its corresponding dedicated criteria + * ({@link Matchers#contains(Matcher[])}, and more. + * @param name the name of the header to consider + * @param matcher the matcher to apply to the whole list of values for that header + * @since 6.0.5 + */ + public static RequestMatcher header(String name, Matcher> matcher) { + return request -> { + List headerValues = request.getHeaders().get(name); + if (headerValues == null) { + fail("No header values for header [" + name + "]"); + } + assertThat("Request header values for [" + name + "]", headerValues, matcher); + }; + } + /** * Assert that the given request header does not exist. * @since 5.2 diff --git a/spring-test/src/test/java/org/springframework/test/web/client/match/MockRestRequestMatchersTests.java b/spring-test/src/test/java/org/springframework/test/web/client/match/MockRestRequestMatchersTests.java index 6481a3313bd7..ec99b23a996a 100644 --- a/spring-test/src/test/java/org/springframework/test/web/client/match/MockRestRequestMatchersTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/client/match/MockRestRequestMatchersTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2023 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. @@ -16,18 +16,35 @@ package org.springframework.test.web.client.match; +import java.io.IOException; import java.net.URI; import java.util.Arrays; import java.util.Collections; import java.util.List; +import org.hamcrest.CoreMatchers; +import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; import org.springframework.http.HttpMethod; import org.springframework.mock.http.client.MockClientHttpRequest; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.any; +import static org.hamcrest.Matchers.anything; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.everyItem; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.startsWith; /** * Unit tests for {@link MockRestRequestMatchers}. @@ -146,6 +163,63 @@ public void headerContainsWithMissingValue() throws Exception { .hasMessageContaining("was \"bar\""); } + @Test + void headerListMissing() { + assertThatThrownBy(() -> MockRestRequestMatchers.header("foo", hasSize(2)).match(this.request)) + .isInstanceOf(AssertionError.class) + .hasMessage("No header values for header [foo]"); + } + + @Test + void headerListMatchers() throws IOException { + this.request.getHeaders().put("foo", Arrays.asList("bar", "baz")); + + MockRestRequestMatchers.header("foo", containsInAnyOrder(endsWith("baz"), endsWith("bar"))).match(this.request); + MockRestRequestMatchers.header("foo", contains(is("bar"), is("baz"))).match(this.request); + MockRestRequestMatchers.header("foo", contains(is("bar"), Matchers.anything())).match(this.request); + MockRestRequestMatchers.header("foo", hasItem(endsWith("baz"))).match(this.request); + MockRestRequestMatchers.header("foo", everyItem(startsWith("ba"))).match(this.request); + MockRestRequestMatchers.header("foo", hasSize(2)).match(this.request); + + //these can be a bit ambiguous when reading the test (the compiler selects the list matcher): + MockRestRequestMatchers.header("foo", notNullValue()).match(this.request); + MockRestRequestMatchers.header("foo", is(anything())).match(this.request); + MockRestRequestMatchers.header("foo", allOf(notNullValue(), notNullValue())).match(this.request); + + //these are not as ambiguous thanks to an inner matcher that is either obviously list-oriented, + //string-oriented or obviously a vararg of matchers + //list matcher version + MockRestRequestMatchers.header("foo", allOf(notNullValue(), hasSize(2))).match(this.request); + //vararg version + MockRestRequestMatchers.header("foo", allOf(notNullValue(), endsWith("ar"))).match(this.request); + MockRestRequestMatchers.header("foo", is((any(String.class)))).match(this.request); + MockRestRequestMatchers.header("foo", CoreMatchers.either(is("bar")).or(is(nullValue()))).match(this.request); + MockRestRequestMatchers.header("foo", is(notNullValue()), is(notNullValue())).match(this.request); + } + + @Test + void headerListContainsMismatch() { + this.request.getHeaders().put("foo", Arrays.asList("bar", "baz")); + + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> MockRestRequestMatchers + .header("foo", contains(containsString("ba"))).match(this.request)) + .withMessage("Request header values for [foo]\n" + + "Expected: iterable containing [a string containing \"ba\"]\n" + + " but: not matched: \"baz\""); + + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> MockRestRequestMatchers + .header("foo", hasItem(endsWith("ba"))).match(this.request)) + .withMessage("Request header values for [foo]\n" + + "Expected: a collection containing a string ending with \"ba\"\n" + + " but: mismatches were: [was \"bar\", was \"baz\"]"); + + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> MockRestRequestMatchers + .header("foo", everyItem(endsWith("ar"))).match(this.request)) + .withMessage("Request header values for [foo]\n" + + "Expected: every item is a string ending with \"ar\"\n" + + " but: an item was \"baz\""); + } + @Test public void headers() throws Exception { this.request.getHeaders().put("foo", Arrays.asList("bar", "baz")); @@ -210,4 +284,62 @@ public void queryParamContainsWithMissingValue() throws Exception { .hasMessageContaining("was \"bar\""); } + + @Test + void queryParamListMissing() { + assertThatThrownBy(() -> MockRestRequestMatchers.queryParam("foo", hasSize(2)).match(this.request)) + .isInstanceOf(AssertionError.class) + .hasMessage("No queryParam [foo]"); + } + + @Test + void queryParamListMatchers() throws IOException { + this.request.setURI(URI.create("http://www.foo.example/a?foo=bar&foo=baz")); + + MockRestRequestMatchers.queryParam("foo", containsInAnyOrder(endsWith("baz"), endsWith("bar"))).match(this.request); + MockRestRequestMatchers.queryParam("foo", contains(is("bar"), is("baz"))).match(this.request); + MockRestRequestMatchers.queryParam("foo", contains(is("bar"), Matchers.anything())).match(this.request); + MockRestRequestMatchers.queryParam("foo", hasItem(endsWith("baz"))).match(this.request); + MockRestRequestMatchers.queryParam("foo", everyItem(startsWith("ba"))).match(this.request); + MockRestRequestMatchers.queryParam("foo", hasSize(2)).match(this.request); + + //these can be a bit ambiguous when reading the test (the compiler selects the list matcher): + MockRestRequestMatchers.queryParam("foo", notNullValue()).match(this.request); + MockRestRequestMatchers.queryParam("foo", is(anything())).match(this.request); + MockRestRequestMatchers.queryParam("foo", allOf(notNullValue(), notNullValue())).match(this.request); + + //these are not as ambiguous thanks to an inner matcher that is either obviously list-oriented, + //string-oriented or obviously a vararg of matchers + //list matcher version + MockRestRequestMatchers.queryParam("foo", allOf(notNullValue(), hasSize(2))).match(this.request); + //vararg version + MockRestRequestMatchers.queryParam("foo", allOf(notNullValue(), endsWith("ar"))).match(this.request); + MockRestRequestMatchers.queryParam("foo", is((any(String.class)))).match(this.request); + MockRestRequestMatchers.queryParam("foo", CoreMatchers.either(is("bar")).or(is(nullValue()))).match(this.request); + MockRestRequestMatchers.queryParam("foo", is(notNullValue()), is(notNullValue())).match(this.request); + } + + @Test + void queryParamListContainsMismatch() { + this.request.setURI(URI.create("http://www.foo.example/a?foo=bar&foo=baz")); + + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> MockRestRequestMatchers + .queryParam("foo", contains(containsString("ba"))).match(this.request)) + .withMessage("Request queryParam values for [foo]\n" + + "Expected: iterable containing [a string containing \"ba\"]\n" + + " but: not matched: \"baz\""); + + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> MockRestRequestMatchers + .queryParam("foo", hasItem(endsWith("ba"))).match(this.request)) + .withMessage("Request queryParam values for [foo]\n" + + "Expected: a collection containing a string ending with \"ba\"\n" + + " but: mismatches were: [was \"bar\", was \"baz\"]"); + + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> MockRestRequestMatchers + .queryParam("foo", everyItem(endsWith("ar"))).match(this.request)) + .withMessage("Request queryParam values for [foo]\n" + + "Expected: every item is a string ending with \"ar\"\n" + + " but: an item was \"baz\""); + } + }