Your team must decide whether to generate mocks from interfaces or hand-write doubles. How do you make that call?
answer
- it is a dependency decision
- count the interfaces and their churn
- somebody must pin a version
- committed output can drift silently
- leaving is cheaper than joining
basics
~20 sDecide on scale and churn, not taste. Generation pays when many wide interfaces change often; it costs a pinned tool dependency every contributor and CI must have, plus a CI check that regenerated output matches what is committed. Few narrow interfaces do not repay that.
solid answer
~50 sFrame it as a dependency and process decision rather than a style preference. Generation earns its keep when you have many interfaces, several methods wide, that change regularly — then adding a method is a regenerate rather than an edit in twenty files. What it drags in: a generator pinned for the whole team (a `tools.go` blank import historically, or since Go 1.24 a `tool` directive in `go.mod` invoked with `go tool`), a CI step running `go generate ./...` followed by `git diff --exit-code` so stale output cannot merge, review noise from large machine-written files, and an upgrade that rewrites every generated file at once. Against a handful of two-method interfaces, hand-written doubles cost less than that machinery. The decision is reversible in one direction only, and cheaply: generated files are committed source, so you can stop running the generator and keep them. I would pilot on the widest interface, measure regeneration frequency and CI time, and name an owner for the pin and its upgrades.
code
text · 2 linesgo generate ./...
git diff --exit-code # non-zero if any generated file was out of datego deeper
Know that generated mocks come from a tool that must be installed and run, and that the resulting files are committed to the repository like any other source.
Explain the concrete costs: pinning the tool version for the whole team, a CI step that regenerates and diffs, and review noise from large machine-written files.
Show how you would keep it healthy in practice — the pin, the regenerate-and-diff gate, a plan for the upgrade that rewrites every generated file at once, and where you would still hand-write.
Own the decision and its reversal: the numbers that justify it, who holds the pin and its upgrades, how a mixed policy is written down, and why committed output makes leaving cheap.
## Why this is an ownership decision Choosing to generate mocks is not a testing-style choice made per test. It adds a build-time tool that every contributor and every CI runner must have, at one agreed version, forever. That is dependency policy, and it belongs to whoever owns the module — which also means whoever owns it can overrule the team that wanted it. ## The costs, stated concretely **Pinning the tool.** A generator invoked from `//go:generate` must resolve to the same version everywhere, or two engineers regenerating the same file produce different bytes and the diff churns. Historically Go teams pinned this with a `tools.go` file that blank-imports the tool's package behind a build constraint, so the module graph records its version. Since Go 1.24 there is a first-class mechanism: a `tool` directive in `go.mod`, added with `go get -tool`, invoked as `go tool <name>`. Either way you now have a dependency whose upgrades you must schedule. **Keeping output current.** Because `go generate` never runs during `go build` or `go test`, a committed mock can drift. The mitigation is a CI step: ```text go generate ./... git diff --exit-code ``` It fails when the committed files do not match what the current interfaces produce. That step needs the generator available in CI, adds runtime to every pipeline, and produces a failure mode contributors must learn to read ("CI says regenerate"). **Review load.** Generated files are large, mechanical and appear in diffs. Teams either mark them so reviewers skip them or spend attention on noise. **Upgrade blast radius.** When the generator changes its output format, every generated file in the repository changes in one commit. Somebody owns landing that, and owns the risk that a behavioural difference in the generated doubles hides inside a ten-thousand-line diff. **Coupling.** The tests are now written against a generator's expectation vocabulary. Migrating away later means rewriting tests, not just deleting files. ## The benefits, stated just as concretely - **Cost per interface approaches zero.** Adding a method to a widely-mocked interface is a regenerate, not an edit in every test package. - **Uniformity.** Every double behaves the same way, so a reviewer moving between packages reads one idiom. - **Call recording for free.** Argument capture and call counting come with the generated type instead of being re-implemented. - **Wide interfaces become tractable.** A twelve-method dependency is miserable to satisfy by hand, and the hand-written version rots. ## The inputs I would actually measure 1. **How many interfaces are doubled**, and how wide are they? Ten one- and two-method interfaces is a different world from thirty six-method ones. 2. **Churn.** How often did those interfaces change signature in the last six months? Low churn removes most of the benefit. 3. **Team size and turnover.** More contributors means more value in uniformity and more cost in onboarding a tool. 4. **CI budget.** The regenerate-and-diff step multiplied by pipeline count. 5. **Who is on the hook** for the pin, its upgrades and the day the generator's maintenance stops. ## The recommendation shape Most Go codebases land on a mix, and that is a defensible answer rather than a fence-sit: generate for the few wide, churning dependencies; leave the narrow ones alone. What makes it a decision rather than drift is writing down where the line sits, so the next contributor does not have to guess. I would pilot on the single widest interface, keep the regenerate-and-diff step from day one (without it the whole thing rots quietly), and revisit after a quarter with the churn numbers. ## The exit, which is the part that de-risks the call Because Go requires generated code to be committed, the output is ordinary source. If the generator is abandoned upstream, changes its licence, or simply stops earning its keep, you delete the `//go:generate` lines and the tool dependency and keep the files that exist, editing them by hand from then on. That asymmetry — easy to leave, moderately expensive to adopt — is the strongest argument for trying it on a subset rather than debating it in the abstract. ## What I would not accept as an argument "It is what we did at my last company", and "hand-writing is boilerplate" without a count of how much boilerplate actually exists. Both are answerable with numbers from the repository, and the numbers are what make the decision reviewable by whoever can overrule it.
- How do you keep a contributor's locally installed generator from producing a different diff?Pin it in the module rather than relying on `$PATH`. Historically that meant a `tools.go` blank import so the version is in the module graph; since Go 1.24 a `tool` directive in `go.mod` invoked with `go tool` does it directly. Then the CI regenerate-and-diff step enforces that everyone's output agrees.
- The generator is abandoned upstream two years in. What is your exposure?Low, because Go requires generated code to be committed. The existing files keep compiling and stay editable by hand. You delete the `//go:generate` lines and the tool dependency and move on. The real exposure is the expectation vocabulary the tests are written against, which is only worth rewriting if you need behaviour the frozen files cannot provide.
- Would you accept a mixed policy, generating some doubles and hand-writing others?Yes, provided the line is written down. Generate for the few wide dependencies that churn, where regeneration is the whole point; hand-write the one- and two-method ones, where the tool costs more than it saves. Undocumented, the same mix is just drift and every new contributor re-litigates it.
saying these in an interview costs you the question
- Treats it as a style preference rather than a dependency decision
- Adopts generation with no CI check that output is current
- Relies on whatever generator version is on each contributor's PATH
- Ignores the one-commit blast radius of a generator upgrade
- Argues from habit rather than counting interfaces and churn
- Assumes the choice is irreversible once tests depend on it