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

Interaction argument-matching treats all Mocks/Stubs of Comparables as equivalent #1322

Closed
jdpopkin opened this issue May 11, 2021 · 6 comments · Fixed by #1323
Closed

Interaction argument-matching treats all Mocks/Stubs of Comparables as equivalent #1322

jdpopkin opened this issue May 11, 2021 · 6 comments · Fixed by #1323

Comments

@jdpopkin
Copy link
Contributor

jdpopkin commented May 11, 2021

Issue description

Hi! It looks like interaction argument-matching treats all Mocks/Stubs of classes that implement Comparable as equivalent to one another.

How to reproduce

Here's a test case to repro this:

  @FailsWith(TooFewInvocationsError)
  def "expect non-matching Comparables"() {
    ComparableExample bar = Mock()
    Comparable baz = Mock()
    Comparable qux = Mock()

    when:
    bar.foo(baz)
    then:
    1 * bar.foo(qux)
  }

  interface ComparableExample {
    void foo(Comparable arg)
  }

This test fails because bar.foo(baz) actually matches bar.foo(qux). This wouldn't be true if baz, qux, and the argument to foo weren't Comparable.

This probably has something to do with the way that Groovy == aliases .equals for all objects except for Comparables - == means compareTo when used with Comparables. (I don't know much about Groovy, so this was a big surprise for me; if you're in that boat, see this issue for more information.) More specifically, it seems to me that EqualArgumentConstraint's call to GroovyRuntimeUtil.equals ends up calling the same logic that Groovy uses when it sees a ==. Generally speaking, I think that's intuitive, but in this case you end up with broken-looking default behavior.

I have a commit that fixes this and will make a PR soon. I ended up adding another DefaultInteraction to DefaultJavaLangObjectInteractions that makes compareTo compare System.identityHashcode for any mocks that are instanceof Comparable. That felt cleaner than adding more complexity to EqualArgumentConstraint in order to handle the special case where expected is a mock of a Comparable.

Link to a gist or similar (optional)

PR to follow with a test that reproduces the bug.

Additional Environment information

I used IntelliJ IDEA on macOS. My global Java version is openjdk 1.8.0_232. The versions of the other tools are whatever the Spock project defaults to after a ./gradlew build and ./gradlew cleanIdea idea run - I don't think I have them installed globally.

Java/JDK

java -version

openjdk version "1.8.0_232"
OpenJDK Runtime Environment (AdoptOpenJDK)(build 1.8.0_232-b09)
OpenJDK 64-Bit Server VM (AdoptOpenJDK)(build 25.232-b09, mixed mode)

Groovy version

groovy -version
I don't have groovy installed globally.

Build tool version

Gradle

gradle -version

$ ./gradlew -version
 
------------------------------------------------------------
Gradle 6.8.3
------------------------------------------------------------
 
Build time:   2021-02-22 16:13:28 UTC
Revision:     9e26b4a9ebb910eaa1b8da8ff8575e514bc61c78
 
Kotlin:       1.4.20
Groovy:       2.5.12
Ant:          Apache Ant(TM) version 1.10.9 compiled on September 27 2020
JVM:          1.8.0_232 (AdoptOpenJDK 25.232-b09)
OS:           Mac OS X 10.15.2 x86_64

Apache Maven

mvn -version
I don't have this installed globally.

Operating System

Linux, Windows, Mac etc.
Mac

IDE

IntelliJ, Eclipse etc.
IntelliJ

Build-tool dependencies used

Whatever's in this project's build.gradle? I don't use Gradle for work, so I don't really know how to fill out this section.

jdpopkin added a commit to jdpopkin/spock that referenced this issue May 11, 2021
`EqualArgumentConstraint` uses `DefaultTypeTransformation.compareEqual`
to check arguments' equality in interactions. For most objects, this is
equivalent to calling `.equals`, so Java Mocks come with an intuitive
default implemention of `.equals`. But for objects that are `Comparable`
(including Mocks of types that implement `Comparable`),
`DefaultTypeTransformation.compareEqual(x, y)` is equivalent to calling
`x.compareTo(y) == 0`. As a result, all Mocks of Comparable types are
treated as equal by `EqualArgumentConstraint`.

This commit fixes that by adding a default implementation of
`.compareTo` to `Comparable` Mocks.
@leonard84
Copy link
Member

The contract of Comparable is that if the result of compareTo is 0 then equals should also be true and vice-versa.
Spocks logic for a plain argument is to use equals instead of identity, as this is what you want most of the time. If you explicitly need the identity, then you need to write it as code argument constraint {it === qux} (groovy 3) { qux.is(it) } (groovy 2).

The underlying problem is that you've used a mock without also mocking the method you are using, so by default it returns 0 or null. The change you are proposing would break the code of people who rely on that behavior.

If you add 1 * qux.compareTo(_) >> 1 the test will also pass.

@jdpopkin
Copy link
Contributor Author

jdpopkin commented May 11, 2021

The contract of Comparable is that if the result of compareTo is 0 then equals should also be true and vice-versa.

This is "strongly recommended" but not a requirement. For example, BigDecimal has a compareTo that's inconsistent with its equals method (equals checks for matching scale, compareTo does not). That's the reason why Groovy uses compareTo instead of equals when == is used on Comparables - see this comment.

Anyway, in Spock's current form, Mocks of Comparables do not meet the requirement you're describing - if foo and bar are both Mock(Comparable), foo.compareTo(bar) will return 0 (because compareTo's behavior was never explicitly mocked) and foo.equals(bar) will return false (because DefaultEqualsInteraction intercepts .equals and checks system-identity). This is what my pull request changes (by adding a DefaultCompareToInteraction that behaves equivalently to DefaultEqualsInteraction).

Spocks logic for a plain argument is to use equals instead of identity, as this is what you want most of the time.

That's what the documentation says, but in practice that's not what Spock is doing. EqualArgumentConstraint actually calls DefaultTypeTransformation.compareEqual here. For most objects, that's going to resolve to a call to .equals, but not for Comparables. If Spock actually were using equals here, DefaultEqualsInteraction would save us, and the test would pass.

The underlying problem is that you've used a mock without also mocking the method you are using, so by default it returns 0 or null.

I'm not "using" compareTo within my test other than implicitly in the sense that Spock is actually calling compareTo instead of equals to decide whether or not baz and qux are the same object. I think there are good reasons for Spock to do that that (at least, messing with EqualArgumentConstraint to explicitly call .equals led to a stack overflow when I tested it). So rather than messing with EqualArgumentConstraint to special-case out Mock(Comparable), I just added default behavior for compareTo in new Mocks to make it consistent with equals.

The change you are proposing would break the code of people who rely on that behavior.

Yeah, that's the painful tradeoff here. I think that the current behavior of Mock(Comparable) is pretty bad - as-is, there's no difference between 1 * foo.bar(baz) and 1 * foo.bar(_ as Comparable) if baz is Comparable, because EqualArgumentConstraint cannot distinguish between different Mock(Comparable)s - and Spock does stub out some common-sense behavior for a couple of other methods (like equals), so I think there's some precedent for providing default behavior for compareTo. But it's definitely possible that there are people who rely on compareTo always returning 0 when called on a Mock.

I haven't looked too deeply into the possibility of messing with EqualArgumentConstraint itself, but maybe that's what needs to be done?

@leonard84
Copy link
Member

Anyway, in Spock's current form, Mocks of Comparables do not meet the requirement you're describing

Yes, mocks don't really adhere to any contract unless you tell them to. You actually will get a NPE if you replace Comparable by List as the equality check will try to iterate over the lists to to compare them, and then get null when calling iterator(). And I'm sure there are more cases which will lead to errors. The question always is, how far can we sensibly support special cases and how confused are the users, when this special handling goes against their expectations.

Spocks logic for a plain argument is to use equals instead of identity, as this is what you want most of the time.

That's what the documentation says, but in practice that's not what Spock is doing. EqualArgumentConstraint actually calls DefaultTypeTransformation.compareEqual here. For most objects, that's going to resolve to a call to .equals, but not for Comparables. If Spock actually were using equals here, DefaultEqualsInteraction would save us, and the test would pass.

Let me rephrase, Spocks EqualArgumentConstraint behaves consistent to you having written {it == other}.

The PR #1323 would change the default behavior of all mocks, not just in the EqualArgumentConstraint case to treat compareTo differently, with arbitrary results as Integer.compare(System.identityHashCode(invocation.getMockObject().getInstance()), System.identityHashCode(invocation.getArguments().get(0))) will result in different results between runs.

Any thoughts @spockframework/supporter ?

@kiview
Copy link
Member

kiview commented May 21, 2021

While I understand the problem with the provided test case, I fail to imagine the real-world examples, in which this becomes an issue and this might be a smell with regards to test design. However, of course, Spock should adhere to some kind of reasonable contract.

Since this behaviour is reported upstream and consistent with Groovy (and it does not look like it will change there), I don't think Spock should diverge from this. Maybe one could clarify in the docs, that Spock relies on the GroovyRuntimeUtil.equals logic and adheres to its contract.

@leonard84
Copy link
Member

leonard84 commented May 22, 2021

I checked the documentation and I think it already clearly states:

The equality constraint uses groovy equality to check the argument, i.e, argument == constraint.

We could add a note for the Comparable behavior.

@leonard84 leonard84 linked a pull request Sep 29, 2022 that will close this issue
@erdi
Copy link
Member

erdi commented Nov 23, 2022

FWIW, I've just hit this issue. Good to see it fixed in 2.3!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants