golang / mock

GoMock is a mocking framework for the Go programming language.
Apache License 2.0
9.28k stars 610 forks source link

Automatic call to Controller.Finish breaks "stub" style tests that worked on prior version #584

Closed snargleplax closed 2 years ago

snargleplax commented 2 years ago

Actual behavior

Test that passed on previous minor version 1.5.0 fails with errors about missing expected calls.

Expected behavior

Tests that passed on previous minor version pass on 1.6.0 without code changes.

To Reproduce

  1. Write a test that uses gomock and sets an expectation on some mock, but omits a call to Controller.Finish
  2. Run the test against gomock 1.6.0

Additional Information

Discussion

The change was introduced here in response to this issue.

On that issue, there was some brief discussion of whether omitting a call to Controller.Finish was a valid use case. @prashantv, who opened the issue, described the use case as "arcane." @cvgw responded with the assertion that if this happens, it is probably always a mistake.

There doesn't seem to have been anyone at the time giving any serious consideration to the possibility that someone might be legitimately using it this way, or that changing the behavior in this way would violate semantic versioning by breaking large numbers of existing tests. My team, though, makes extensive and intentional use of a testing pattern that relies on exactly this. I'll talk a bit about how we got there, because I think it speaks effectively to the legitimatacy of this use case.

From an earlier version of our team, we had inherited a decent-sized body of tests that made heavy use of mocks, and those all called Controller.Finish in the expected fashion. We found, partly due to unrelated design issues that caused high dependency fan-out, that these tests were brittle and laborious to maintain. We'd make a change, and spend a bunch of time fixing up expectations we didn't actually care about, just to provide "life support" for the actual test scenario.

I got sick of this and wondered whether we really wanted mocks at all, or just stubs -- the crucial difference being that stubs provide only behavior, not verification. In probably over 90% of cases, we have no interest in verifying that calls have been made. That verification stands outside the intentionality we desire, and interferes with the test design ideal of one logical assertion (one reason to fail) per test. Verifying mocks also meant adding extra control flow to avoid setting expectations in error cases, etc. So we started experimenting with omitting the call to Controller.Finish(), and right away found that our tests started to become simpler and more focused on our actual intent. We've been very happy with the reduction in boilerplate, irrelevant breakage, etc.

So, I think it's an extremely reasonable use case. The question is, did this change break semantic versioning as defined for Go modules? And whether or not it did, should something be done to accommodate cases like mine? We got this version bump as a side-effect of upgrading another package, and did not expect to suddenly have to go update dozens and dozens of tests and abandon a test design approach we've been migrating in the direction of for multiple years.

The Go modules documentation states:

The major version must be incremented and the minor and patch versions must be set to zero after a backwards incompatible change is made to the module’s public interface or documented functionality, for example, after a package is removed.

Is the change backwards-incompatible? Judging by all my broken tests, I'd say so. Is it a change to the module's public interface or documented functionality? That's a little bit more of a question.

As of at least 1.3.1 (the version we had been on), the package GoDoc does state that Finish should be called:

Each test should create a new Controller and invoke Finish via defer.

The GoDoc for Finish itself says:

Finish checks to see if all the methods that were expected to be called were called. It should be invoked for each Controller. It is not idempotent and therefore can only be invoked once.

More recently, this was added:

New in go1.14+, if you are passing a *testing.T into NewController function you no longer need to call ctrl.Finish() in your test methods.

Frankly, I think there's an issue here on the face of it, even without reference to my use case. If Finish is not idempotent and can only be invoked once, then according to this documentation it would be incorrect to invoke it anywhere in the presence of other code that implicitly does so. The current README.md states:

If you are using a Go version of 1.14+, a mockgen version of 1.5.0+, and are passing a *testing.T into gomock.NewController(t) you no longer need to call ctrl.Finish() explicitly. It will be called for you automatically from a self registered Cleanup function.

So if one goes by that, it would seem to follow that any previously existing code became "incorrect" (for Go 1.14+) on mockgen 1.5.0 (shouldn't that be gomock 1.6.0+? Not sure why it refers to "mockgen" there instead -- seems like a matter for the runtime library, not the code generator). I'm guessing that that's not the case, and that these docs just aren't quite self-consistent, but I think it underscores the fact that the documentation does not provide adequate clarity to justify going by a strict interpretation that any omission of call to Controller.Finish() is incorrect and not intended to be supported, even though it has worked fine that way for quite some time.

I should acknowledge that the README.md does include an example (with no particular discussion) of how to do stubs. What I take from that example is that the intended approach is to append .AnyTimes() to every single expectation. For usage like ours, that's an awful lot of boilerplate compared to simply not calling Finish. I won't dispute that it can be used to get that behavior, but it seems manifestly undesirable and we'd have to edit hundreds of lines of code to fix that up everywhere now, not to mention committing to do so in perpetuity and abandon our longstanding lighter-weight approach.

So, what to do? Unfortunately, since this version has been released, the bell has to some extent been rung. Even if one accepts my point of view that this change violated semantic versioning for a reasonable use case based on a reasonable read of the docs, it's now out in the wild and there are presumably many teams already relying on the new behavior, who would be similarly disrupted by reversing it.

One option would be to retroactively deem this change to be backwards-incompatible, and handle this strictly through the Go module mechanisms. I guess in those terms the least disruptive thing would actually involve two major version bumps -- you'd introduce v2, which reinstates the old behavior so that someone in my position can use that without requiring changes to v1. And then you'd have v3, which operates the same as v1 now does, as a basis for the future (so that anyone on the latest version is getting the latest behavior). I can readily imagine that introducing two new major versions for this one issue is not an attractive option. Or perhaps the more appropriate thing would be to use a retract directive; but I still anticipate reluctance.

Another option I could see, and perhaps the most viable one to propose here, is the addition of some kind of stronger first-class support for stub behavior within gomock. I could see this being an alternative to NewController (NewStubController?), an adapter to wrap around a *testing.T, or some other kind of thing less boilerplate-heavy than sprinkling .AnyTimes() all over the place. There are probably a number of workable approaches; I'd be glad if we could discuss concrete options here in this thread.

Failing either of those, we'll have to default to sorting it out ourselves -- either by accepting the push to add all that boilerplate and abandon our current heading, or by coming up with some kind of adapter pattern ourselves. Or forking, I suppose. I really think that our approach to testing is reasonable enough to merit a form of support unburdened by pervasive boilerplate, though.

codyoss commented 2 years ago

Hey @snargleplax thank you for providing a detailed report. This was really helpful in understanding your use-case. I am sorry this change seemed to break some of your existing workflows.

I think this is kind of a case of Hyrum's Law. The intended behavior of using this library has always been that the user should be calling ctrl.Finish(), as without it nothing is verified. For this reason I think this is a case of unspecified behavior and does not constitute this being a breaking change.

That said I would like to work with you to see if there are any changes to the library we could make to directly support use-cases like yours. I don't think that something like a stub controller is out of the question. There has also been suggestions in that past that maybe we should support a version of NewController that takes functional options to configure behavior (see #238). I think something like something like one of these solutions sounds doable. What do you think? If we wanted to move forward with something like that I suggest a new issue be created for discussion or we start talking more seriously on the linked issue above.

snargleplax commented 2 years ago

Thank you @codyoss, I certainly see where you're coming from and am happy to provide input on an approach for supporting verification-free usage more directly.

Since there are potentially other implementations that would provide the behavior I'm after, and since #238 as written is for a general mechanism that would include unspecified other options, I'll write it up as a new issue for the sake of narrower focus. I'll link it to this and #238 for reference.