Your payments package exports a 14-method interface purely so tests can fake it, the fake has drifted, and every test still passes. What do you change?
answer
- the compiler checked shape, not behaviour
- fourteen methods, fourteen chances to drift
- narrow the seam to what is called
- one suite, run against both sides
basics
~20 sShrink the seam and verify it. An interface pins method signatures, never behaviour. Let each consumer declare the one or two methods it calls, then run one shared behaviour suite against both the stand-in and the real client.
solid answer
~50 sThe interface bought exactly one guarantee -- that the fake has the same method signatures -- and the tests then asserted against the fake's behaviour, which nothing checks. Two changes fix it. First, shrink the seam: delete the 14-method producer-side interface and let each consuming package declare the one or two methods it actually calls, so the stand-in is small enough to be obviously right. Second, verify the behaviour that remains: export one test suite from the package that owns the real behaviour and run it twice, against the fake and against the real client, so a change in the real implementation fails the fake's build. `testing/fstest.TestFS` is the standard library's version of that idea. Where the seam is really HTTP, prefer `net/http/httptest` and the real client over a hand-written stand-in, because then there is nothing to drift.
code
go · 8 lines// package payments -- one suite, exported so both sides run it
func TestAuthorizer(t *testing.T, a Authorizer) {
t.Helper()
_, err := a.Authorize(context.Background(), "card_declined", 100)
if !errors.Is(err, ErrDeclined) {
t.Fatalf("declined card: got %v, want ErrDeclined", err)
}
}go deeper
Understand what satisfying an interface proves: the methods exist with the right signatures. It says nothing about whether a stand-in returns what the real implementation would.
Explain why a wide, producer-declared interface makes fakes fragile, and how moving the interface to the consumer and narrowing it to the methods actually called shrinks what a fake has to get right.
Show how you would catch the drift: one behaviour suite exported from the owning package and run against both implementations, an integration test against the real dependency, and a preference for httptest over a hand-written stand-in when the boundary is HTTP.
Own the policy that keeps test doubles honest across teams -- who writes the shared suite, where fakes live so they move with the code they imitate, and how much integration coverage the organisation pays for.
## Why every test can pass while production is broken Go checks that a type satisfies an interface by comparing method sets. That is the entire guarantee. It says nothing about what the methods return, which errors they produce, which arguments they reject, or what they do concurrently. A hand-written stand-in for a payments client that returns a successful authorisation for every card compiles perfectly against a 14-method interface and will keep compiling after the real client starts rejecting expired cards, changes an error value, or begins requiring an idempotency key. The test suite is then measuring the stand-in. Green means "the code under test works against my model of the payments backend", and the model is a file nobody has read in eight months. ## The design defect underneath The 14-method interface exists because someone needed to substitute the client in tests, and reached for the shape they knew: mirror the concrete type, method for method, and export it from the package that implements it. That produces the worst of both worlds. - **It abstracts nothing.** An interface with the same method set as one struct is that struct with the types removed. - **It maximises drift surface.** Every method is a behaviour the stand-in must get right, including the twelve the consumer never calls. - **It couples importers.** Other packages now name the interface, so it cannot be narrowed without touching them. - **It hides who needs what.** Reading a consumer no longer tells you which capabilities it actually depends on. ## The first change: shrink the seam Delete the producer-side interface and return the concrete `*Client`. In each consuming package, declare an interface containing only the methods that package calls -- frequently one. A checkout package that calls `Authorize` declares a one-method interface and takes it as a constructor parameter; a reconciliation job that calls `List` declares a different one-method interface. Nothing else in the codebase needs to know. The payoff is proportional: a one-method stand-in is a struct with a function field, small enough that its behaviour is visible at a glance, and there is no longer any way for it to be wrong about the thirteen methods it does not have. ## The second change: verify the behaviour, not the shape Narrowing reduces drift; it does not eliminate it. The remaining method still has behaviour -- what it returns for a declined card, which error it wraps, whether it honours a cancelled `context.Context`. Pin that with one suite that both implementations must pass: ```go // package payments func TestAuthorizer(t *testing.T, a Authorizer) { t.Helper() _, err := a.Authorize(context.Background(), "card_declined", 100) if !errors.Is(err, ErrDeclined) { t.Fatalf("declined card: got %v, want ErrDeclined", err) } } ``` Run it from the fake's own test file and from an integration test that builds the real client. The moment the real behaviour changes, the run against the real implementation fails, and the fake's failure to keep up becomes a build failure rather than a production incident. `testing/fstest.TestFS` is precisely this pattern in the standard library: a suite exported so that any implementation of an interface can be checked against the behaviour the interface is supposed to mean. The suite also belongs to the package that owns the real behaviour, not to the consumer. That is what makes the fake move when the implementation moves. ## The third option: do not fake it at all When the boundary is HTTP, the highest-fidelity stand-in is the real client pointed at `httptest.NewServer` with a handler that returns recorded responses. The client's own parsing, header handling, retry and error mapping are then exercised for real, and there is no parallel implementation to keep honest. For a database-backed store the equivalent is a real database in an integration suite, with the fast unit tests confined to the logic above the seam. ## What to say in the review The test suite passing is not evidence about the payments backend; it is evidence about a file in your repository. Ask two questions of any stand-in: how many methods can it be wrong about, and what would fail if it were wrong. If the answer to the second is "nothing", the seam is not tested, it is merely mocked.
- How does narrowing the interface to one method actually reduce the risk?It removes behaviour the stand-in has to imitate. Thirteen methods it no longer has are thirteen behaviours it can no longer be wrong about, and the one that remains is small enough to review honestly. Narrowing also exposes which capability each consumer really depends on, which is information the wide mirror interface was hiding.
- Where should the shared suite and the fake live?Beside the real implementation, in the package that owns the behaviour, and exported so consumers can run them. Then a change to the real client sits in the same review as the change to the fake and the suite, and the person editing the behaviour is the person who notices the stand-in is now wrong.
- When would you skip the fake entirely?When the seam is a protocol you can stand up cheaply. Point the real client at an `httptest` server, and its parsing, error mapping and header handling are all exercised for real. There is no second implementation, so there is nothing to drift; the cost is a slightly slower and slightly more setup-heavy test.
- Does deleting the exported interface make the code untestable?No. Satisfaction is implicit, so each consumer declares the narrow interface it needs and both the real `*Client` and a small stand-in satisfy it without the payments package participating. The only thing lost is the producer's opinion about which methods consumers should abstract over.
A key cut to the right outline still opens nothing; the interface guarantees the outline, and only running the same lock test on both keys tells you they behave alike.
saying these in an interview costs you the question
- The tests pass, so the integration works
- The interface guarantees the fake behaves like the real client
- Mirror every method so the fake is a drop-in replacement
- Generated doubles cannot drift because they follow the interface
- Testing against the real dependency is always too slow to consider