Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support in MockRestServiceServer to assert ALL header and queryParam values with a single Matcher #28660

Closed
mspiess opened this issue Jun 20, 2022 · 11 comments · Fixed by #29953
Assignees
Labels
in: test Issues in the test module in: web Issues in web modules (web, webmvc, webflux, websocket) status: backported An issue that has been backported to maintenance branches status: superseded An issue that has been superseded by another type: enhancement A general enhancement

Comments

@mspiess
Copy link

mspiess commented Jun 20, 2022

Affects: 5.3.21


The current API for MockRestServiceServer and MockRestRequestMatchers does not allow a test to validate that all values of a certain header key satisfy a matcher. Trying to do

.andExpect(header("Accept", endsWith("json")))

does not work because it passes if the first value for the header ends with "json". I would like to be able to do something like this:

.andExpect(header("Accept", allMatch(endsWith("json"))))

or something to that effect.

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Jun 20, 2022
@sbrannen sbrannen added in: test Issues in the test module in: web Issues in web modules (web, webmvc, webflux, websocket) type: enhancement A general enhancement labels Jun 20, 2022
@sbrannen sbrannen changed the title add support for MockRestServiceServer to verify that all header values match Add support in MockRestServiceServer to verify that all header values match Jun 20, 2022
@sbrannen sbrannen added this to the Triage Queue milestone Jun 20, 2022
@sbrannen
Copy link
Member

sbrannen commented Jun 20, 2022

Hi @mspiess,

I would like to be able to do something like this:

.andExpect(header("Accept", allMatch(endsWith("json"))))

or something to that effect.

That's a good point. In fact, it almost appears that the current API would support that. At least, the current tests in place are based on that assumption.

@Test
public void headerContains() throws Exception {
this.request.getHeaders().put("foo", Arrays.asList("bar", "baz"));
MockRestRequestMatchers.header("foo", containsString("ba")).match(this.request);
}

That headerContains() test actually does not assert what it appears to.

Since bar and baz both contain ba, the test appears to assert that both bar and baz are checked, but in fact -- as you've pointed out -- only bar is checked.

In other words, rewriting the assertion as follows would also cause the test to pass.

MockRestRequestMatchers.header("foo", containsString("bar")).match(this.request);

In order to perform an assertion on each header value, you have to provide a matcher for each value. Thus, the following fails:

MockRestRequestMatchers.header("foo", containsString("bar"), containsString("bar")).match(this.request);

Whereas, the following pass:

MockRestRequestMatchers.header("foo", containsString("bar"), containsString("baz")).match(this.request);

MockRestRequestMatchers.header("foo", containsString("ba"), containsString("ba")).match(this.request);

Thus, there is in fact support for performing assertions against all of the values for a given header, but it is awkward to use and not really documented.

Furthermore, with the current implementation of this feature you have to know exactly how many values are expected and you have to repeat the Matcher or expected value.

FYI: this applies to all variants of queryParam(...) and header(...).


One way to address this would be to introduce new variants of these methods that accept a single String or Matcher (instead of var-args) such as the following.

public static RequestMatcher header(String name, Matcher<? super String> matcher) {
	return request -> {
		List<String> headerValues = request.getHeaders().get(name);
		assertNotNull("No header values", headerValues);
		String reason = "Request header [" + name + "]";
		headerValues.forEach(value -> assertThat(reason, value, matcher));
	};
}

This would achieve what you are requesting; however, there is a downside: any invocation of header() or queryParam() with a single argument for the var-args array would now invoke this new method when recompiled, which would be a breaking change. To avoid that, we could come up with a new name for the single-argument variants.

@mspiess
Copy link
Author

mspiess commented Jun 20, 2022

Hi @sbrannen,
thanks for the quick response and the nice summary.

Perhaps a new overload would be a less of a breaking change:

public static RequestMatcher header(String name, Matcher<? super Collection<? super String>> matcher) {
	return request -> {
		List<String> headerValues = request.getHeaders().get(name);
		assertThat("Request header [" + name + "]", headerValues, matcher);
	};
}

This would allow the following:

MockRestRequestMatchers.header("foo", everyItem(containsString("ba"))).match(this.request);

This brings more control to the call site, as the caller can decide how to match the collection. E.g. one may want to assert that any value matches, which is not possible currently without knowing the amount and order of the header values.
Although I have to acknowledge that this is a strong difference in behavior for an overload and may cause some confusion.

To avoid that, we could come up with a new name for the single-argument variants.

I suggest headerValues.

@rstoyanchev
Copy link
Contributor

When it comes to headers, I don't think the expectation that you would know how many and match each individually is all that unreasonable. I'd expect that matching across all headers in one condition is probably a less common case.

The Javadoc on the existing methods could certainly use an improvement, even if it's obvious that a vararg means, one value or Matcher for each value.

Nothing wrong with introducing a variant that accepts a Matcher<? super Collection<? super String>> but I wouldn't call it headerValues since the other one is also capable of matching all header values. Perhaps headerList would be more explicit about the difference.

@rstoyanchev rstoyanchev changed the title Add support in MockRestServiceServer to verify that all header values match Add support in MockRestServiceServer to verify header values with a Collection Matcher Jun 21, 2022
@rstoyanchev rstoyanchev removed the status: waiting-for-triage An issue we've not yet triaged or decided on label Jun 21, 2022
@rstoyanchev rstoyanchev modified the milestones: Triage Queue, 5.3.22 Jun 21, 2022
@sbrannen
Copy link
Member

I'd expect that matching across all headers in one condition is probably a less common case.

I agree.

The Javadoc on the existing methods could certainly use an improvement

Yes, let's improve the documentation for the affected methods.

Nothing wrong with introducing a variant that accepts a Matcher<? super Collection<? super String>>

I did some research on what's available in Hamcrest. Instead of Collection, they use Iterable. So let's go with Iterable for compatibility.

The interesting (disappointing?) part is that everyItem() and hasItem() have different generic signatures. everyItem() produces Matcher<Iterable<? extends U>>; whereas, hasItem() produces Matcher<Iterable<? super T>>.

This makes everyItem() incompatible with the proposed new method in MockRestRequestMatchers, but there's nothing we can do about that since it's baked into Hamcrest like that.

Though you can create a custom everyItem() implementation that ignores the generics as follows, and this will work with the new method in MockRestRequestMatchers.

@SuppressWarnings({ "rawtypes", "unchecked" })
public static <U> Matcher<Iterable<? super U>> everyItem(Matcher<U> itemMatcher) {
    return new Every(itemMatcher);
}

but I wouldn't call it headerValues since the other one is also capable of matching all header values. Perhaps headerList would be more explicit about the difference.

With the proposed signature below, we don't actually run into any issues with the compiler (in terms of source compatibility).

public static RequestMatcher header(String name, Matcher<Iterable<? super String>> matcher)

So we could choose to keep the header method name, but perhaps a different name would help to highlight the difference in behavior (and avoid binary incompatibility).

@sbrannen sbrannen changed the title Add support in MockRestServiceServer to verify header values with a Collection Matcher Add support in MockRestServiceServer to verify header values with an Iterable Matcher Jun 21, 2022
@rstoyanchev
Copy link
Contributor

Keeping the header method name sounds good.

@sbrannen
Copy link
Member

The interesting (disappointing?) part is that everyItem() and hasItem() have different generic signatures. everyItem() produces Matcher<Iterable<? extends U>>; whereas, hasItem() produces Matcher<Iterable<? super T>>.

It turns out that there are open issues for Hamcrest on this topic.

See hamcrest/JavaHamcrest#289 (comment) for details.

@sbrannen sbrannen assigned sbrannen and unassigned sbrannen Jul 4, 2022
@sbrannen sbrannen modified the milestones: 5.3.22, 5.3.x Jul 5, 2022
@rstoyanchev rstoyanchev modified the milestones: 5.3.x, 6.0.5 Feb 1, 2023
@rstoyanchev
Copy link
Contributor

rstoyanchev commented Feb 7, 2023

@sbrannen, do we go with your most recent proposal?

public static RequestMatcher header(String name, Matcher<Iterable<? super String>> matcher)

Its usefulness is reduced by the Hamcrest issue, however, that doesn't seem to be moving. Perhaps we can just go back to the earlier suggestion with a slightly different name:

public static RequestMatcher everyHeader(String name, Matcher<? super String> matcher);

@sbrannen sbrannen changed the title Add support in MockRestServiceServer to verify header values with an Iterable Matcher Add support in MockRestServiceServer to assert ALL header and queryParam values with an single Matcher Feb 8, 2023
@sbrannen sbrannen changed the title Add support in MockRestServiceServer to assert ALL header and queryParam values with an single Matcher Add support in MockRestServiceServer to assert ALL header and queryParam values with a single Matcher Feb 8, 2023
@sbrannen
Copy link
Member

sbrannen commented Feb 8, 2023

@sbrannen, do we go with your most recent proposal?

public static RequestMatcher header(String name, Matcher<Iterable<? super String>> matcher)

Yes, I believe that's the route we need to go.

Its usefulness is reduced by the Hamcrest issue, however, that doesn't seem to be moving.

Indeed, unfortunately.

Perhaps we can just go back to the earlier suggestion with a slightly different name:

public static RequestMatcher everyHeader(String name, Matcher<? super String> matcher);

I think this is too limiting. The same matcher would be applied to each queryParam/header value individually instead of acting on the collection as a whole -- effectively the same semantics as Matchers.everyItem().

In other words, if we go this route users would not be able to benefit from features like Matchers.hasItem().

Of course, if we wanted to provide a workaround for the generics issues in Hamcrest... we could introduce both methods. However, I'm not convinced we should do that; as I mentioned in #28660 (comment), users can come up with their own workarounds for the generics issues in Hamcrest.


@simonbasle has expressed in interest in taking on this issue, so I've assigned it to him for tentative inclusion in 6.0.5.


Please note that I changed the title of this issue to reflect a changed focus on both header() and queryParam() from MockRestRequestMatchers.

@rstoyanchev
Copy link
Contributor

Of course, if we wanted to provide a workaround for the generics issues in Hamcrest... we could introduce both methods. However, I'm not convinced we should do that; as I mentioned in #28660 (comment), users can come up with their own workarounds for the generics issues in Hamcrest.

Very much worth explaining and illustrating this in the Javadoc of such a new method.

@simonbasle
Copy link
Contributor

simonbasle commented Feb 8, 2023

I'm having a look and I need to better understand why, but it appears that the following signature works both for hasItem and everyItem (among others):

public static RequestMatcher header(String name, Matcher<? super List<String>> matcher) {
See test below demonstrating usage with various matchers
@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()));
}

simonbasle added a commit to simonbasle/spring-framework that referenced this issue Feb 9, 2023
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.

Closes spring-projectsgh-28660
@simonbasle
Copy link
Contributor

Superseded by gh-29953

That PR will allow to do something like this:

.andExpect(header("Accept", everyItem(endsWith("json"))))

@simonbasle simonbasle closed this as not planned Won't fix, can't repro, duplicate, stale Feb 9, 2023
@simonbasle simonbasle added the status: superseded An issue that has been superseded by another label Feb 9, 2023
@simonbasle simonbasle removed this from the 6.0.5 milestone Feb 9, 2023
@github-actions github-actions bot added status: backported An issue that has been backported to maintenance branches and removed for: backport-to-5.3.x labels Feb 13, 2023
simonbasle added a commit that referenced this issue Feb 13, 2023
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-28660
Closes gh-29953
simonbasle added a commit to simonbasle/spring-framework that referenced this issue Mar 30, 2023
This commit changes the name of two recently introduced methods in the
`MockRestRequestMatchers` class for header and queryParam. These have
been found to cause false negatives in user tests, due to the new
overload taking precedence in some cases.

Namely, using a `Matcher` factory method which can apply to both `List`
and `String` will cause the compiler to select the newest list overload,
by instantiating a `Matcher<Object>`.

This can cause false negatives in user tests, failing tests that used
to pass because the Matcher previously applied to the first String in
the header or queryParam value list. For instance, `equalsTo("a")`.

The new overloads are recent enough and this has enough potential to
cause an arbitrary number of user tests to fail that we break the API
to eliminate the ambiguity, by renaming the methods with a `*List`
suffix.

Closes spring-projectsgh-30220
Close spring-projectsgh-30238
See spring-projectsgh-29953
See spring-projectsgh-28660
simonbasle added a commit that referenced this issue Apr 4, 2023
This commit changes the name of two recently introduced methods in the
`MockRestRequestMatchers` class for header and queryParam. These have
been found to cause false negatives in user tests, due to the new
overload taking precedence in some cases.

Namely, using a `Matcher` factory method which can apply to both `List`
and `String` will cause the compiler to select the newest list overload,
by instantiating a `Matcher<Object>`.

This can cause false negatives in user tests, failing tests that used
to pass because the Matcher previously applied to the first String in
the header or queryParam value list. For instance, `equalsTo("a")`.

The new overloads are recent enough and this has enough potential to
cause an arbitrary number of user tests to fail that we break the API
to eliminate the ambiguity, by renaming the methods with a `*List`
suffix.

Closes gh-30220
Closes gh-30238
See gh-29953
See gh-28660
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: test Issues in the test module in: web Issues in web modules (web, webmvc, webflux, websocket) status: backported An issue that has been backported to maintenance branches status: superseded An issue that has been superseded by another type: enhancement A general enhancement
Projects
None yet
Development

Successfully merging a pull request may close this issue.

5 participants