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

Wrong MockRestRequestMatchers.header() method in spring-test being invoked (JDK issue?) #30220

Closed
quiram opened this issue Mar 28, 2023 · 3 comments · Fixed by #30238
Closed
Assignees
Labels
status: backported An issue that has been backported to maintenance branches type: regression A bug that is also a regression
Milestone

Comments

@quiram
Copy link

quiram commented Mar 28, 2023

Affects: 2.7.10


Hello everyone.

I'm uncertain if this is an issue with spring-test or with Java compiler itself but, from Spring Boot 2.7.10 (which incorporates spring-test 5.3.26), we have been having an odd issue with tests calling the wrong MockRestRequestMatchers.header() method.

In spring-test 5.3.25 there were a couple of header() methods, but the one that interest us is the one with this signature:

public static RequestMatcher header(String name, Matcher<? super String>... matchers)

this was used in testing like this:

.andExpect(header("X-MY-HEADER", equalTo("myValue")))

where equalTo() is defined in Hamcrest as

public static <T> Matcher<T> equalTo(T operand)

this worked normally.

From Spring Boot 2.7.10 (spring-test 5.3.26) a new header() method has been added with this signature:

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

and this is where things are breaking: the same call above is now entering this new method, as opposed to the previous. I find this surprising because the signature doesn't match, equalTo("myValue") returns Matcher<String>, which doesn't align to Matcher<? super List<String>>, and yet it's coming in. If I change the call to header() to explicitly call out the type like this:

.andExpect(header("X-MY-HEADER", CoreMatchers.<String>equalTo("myValue")))

then things go back to normal and the call is again directed to the old header() method.

I'm guessing that, via type erasure, the signatures of the two header() methods are being simplified to just header(String, Matcher) and, there being two possible candidates that can match the call, Java is just picking the first one. I'm not sure what could be done from spring-framework's perspective, though.

Thoughts?

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Mar 28, 2023
@simonbasle simonbasle self-assigned this Mar 30, 2023
@simonbasle
Copy link
Contributor

That's a bit unfortunate indeed 😞
When these "base" Matchers that can conceptually apply to either String or List will cause the compiler to select the header(String, Matcher<List>) signature. I unfortunately didn't realize that equalTo(T) would fall in this category.

To be precise, this is because the signature is really:

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

So, to the compiler, CoreMatchers.equalTo("myValue") fits that signature as a Matcher<Object> 😮‍💨

Why was the new variant introduced?

See initial context in #28660.

Note that the vararg based signature, which takes Matcher<String>, has a blind spot: if you provide N matchers, it only validates that the first N values in the header values list do match. This means that in your example the call would match even if the header values was in reality [ "myValue", "otherValue" ]... The intent of introducing a variant which accepts a List-wide Matcher was to alleviate this limitation and enable usage of collection matchers like:

  • contains
  • hasItems
  • everyItem
  • hasSize
  • empty

The signature has to use ? super List because the Hamcrest collection matchers are inconsistent in the generic wildcards they use 😞

A better workaround

Your workaround of calling CoreMatchers.<String>equalTo("myValue") does unambiguously select the aforementioned string-based signature, but a better workaround would be to use:

.andExpect(header("X-MY-HEADER", 
    Machers.contains("myValue")
   //equivalent to Matchers.contains(CoreMatchers.equalTo("myValue"))
));

This is unambiguously a Matcher<Iterable<String>> AND it will validate that there are no more than one header value on top of checking for equality.

Can this be fixed in Framework?

I have toyed with modifying the list-based overload implementation to try to detect such "unexpected" matchers and apply the passed Matcher directly to the first element of the List in addition to the whole List itself, but I think that is a slippery slope of trying to be too smart.

This would result in the following puzzling statements all passing in tests:

		request.getHeaders().put("twoElements", List.of("a", "b"));

		MockRestRequestMatchers.header("twoElements", Matchers.equalTo("a")).match(this.request);
		MockRestRequestMatchers.header("twoElements", Matchers.equalTo(List.of("a", "b"))).match(this.request);

		MockRestRequestMatchers.header("twoElements", Matchers.hasToString("[a, b]")).match(this.request);
		MockRestRequestMatchers.header("twoElements", Matchers.hasToString("a")).match(this.request);

This could also short-circuit null checks (although this example is a bit contrived, nonetheless null is an acceptable value for a HttpHeaders...):

		request.getHeaders().add("m", null);
		request.getHeaders().add("m", "b");

		MockRestRequestMatchers.header("m", Matchers.notNullValue()).match(this.request);
		MockRestRequestMatchers.header("m", Matchers.nullValue()).match(this.request);

(it passes because the List is not null, even though the first element of the list is null)

@simonbasle
Copy link
Contributor

This can be considered a form of regression and the feature is recent enough that we might consider hot-fixing the API, renaming the list-based variant to something unambiguously different like headerList. Note that a similar change is necessary for queryParam which had the same new API.

@simonbasle simonbasle added type: regression A bug that is also a regression and removed status: waiting-for-triage An issue we've not yet triaged or decided on labels Mar 30, 2023
@simonbasle simonbasle added this to the 6.0.8 milestone Mar 30, 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 Mar 30, 2023
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
@quiram
Copy link
Author

quiram commented Mar 30, 2023

Hi @simonbasle.

Thanks a lot for the detailed explanation, that is very informative: I had missed the possibility that type inference could use Matcher<Object> as the type for equalTo(String), but now that you mention it it makes perfect sense.

I can see that you've opted to rename the new methods to add the *List suffix, I agree that this can be useful for the wider community (there may be many people scratching their heads right now). On the other hand, I really like your suggestion to use contains() as opposed to equalsTo(), it is a stricter assertion (in our case our headers usually only have one value but you never know!); I think we will adopt it anyway (using the new *List() variant as it becomes available).

Once again, thanks for addressing this so quickly.

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
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.

See gh-30220
See gh-30238
Closes gh-30235
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
status: backported An issue that has been backported to maintenance branches type: regression A bug that is also a regression
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants