skip to content

Consumer-Side Interfaces

Go interfaces are satisfied implicitly, so the test seam is a small interface declared where the dependency is used, not beside the implementation. Interviewers probe whether you keep it narrow.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

5

How do you make a Go service that reads users from a database unit-testable without one?

level: juniorimportance: must knowfreq 68%

answer

  1. the caller states what it needs
  2. one or two methods, not everything
  3. hand it in through the constructor
  4. a small struct in _test.go supplies the methods

basics

~20 s

Declare a small interface in the package that uses the dependency, naming only the one or two methods that code actually calls. Take it as a constructor parameter. In a _test.go file, pass a hand-written struct that implements those methods.

solid answer

~40 s

The seam is a small interface declared by the consumer. In the package holding the service I declare something like `type UserStore interface { UserByID(ctx context.Context, id int64) (User, error) }` with only the methods that package calls, and `NewService(store UserStore) *Service` takes it as a constructor parameter and keeps it in a field. Production passes the real database-backed type; because Go checks method sets at compile time, that type needs no changes to qualify. In `service_test.go` I write a small struct with the same method that returns whatever the case needs — a user, or an error — and pass it to `NewService`. The fake lives in the test file so it never ships in the binary, and each test constructs its own, which keeps the tests independent and free of a database.

code

go · 9 lines
go
type UserStore interface {
	UserByID(ctx context.Context, id int64) (User, error)
}

type Service struct{ store UserStore }

func NewService(store UserStore) *Service {
	return &Service{store: store}
}

go deeper

for a junior

Be ready to name the three moves: a small interface declared in the package that uses it, a constructor parameter that takes it, and a struct in a _test.go file that has the methods. Show that the real type needs no edits to qualify.

for a middle

Explain why the interface belongs to the consumer rather than the producer, and why width matters: every method you list is a method every stand-in must implement, plus an imported type dragged into your package.

for a senior

Show judgment about the seam's placement: which collaborators get an interface at all, keeping fakes honest about failures, and resisting a shared fixture package that quietly couples unrelated test suites.

for a principal

Own the convention across teams: where seams are allowed, that packages return concrete types, and that nobody is forced to import a shared interfaces package just to be testable.

## The problem A service that talks straight to a `*sql.DB` is welded to a database: to run one assertion you need a running server, a schema, fixtures and cleanup. That test is slow, flaky and tells you little about your own logic. What you want is a **seam** — a place where the real collaborator can be lifted out and a stand-in put in, without the code under test knowing the difference. In Go that seam is an interface, and the important part is **who declares it**. ## Step 1 — declare the interface where it is used The package that *calls* the dependency declares the interface, listing only the methods it calls: ```go type UserStore interface { UserByID(ctx context.Context, id int64) (User, error) } ``` This is often one or two methods. It is not a mirror of everything the database layer can do; it is a statement of need. A wide interface makes every stand-in expensive to write, and it drags method signatures — and their imported types — into a package that never uses them. ## Step 2 — take it as a constructor parameter ```go type Service struct{ store UserStore } func NewService(store UserStore) *Service { return &Service{store: store} } ``` The dependency arrives from outside, through the constructor, and is stored in an unexported field. That is what makes substitution possible. If instead the service reaches for a package-level variable, or opens its own connection inside a method, there is nothing for a test to replace and no way to run two cases with different behaviour at the same time. ## Step 3 — satisfy it with a hand-written struct in _test.go ```go type fakeStore struct { user User err error } func (f fakeStore) UserByID(ctx context.Context, id int64) (User, error) { return f.user, f.err } ``` Nothing declares that `fakeStore` implements `UserStore`; it simply has the method, and the compiler accepts it where a `UserStore` is wanted. That is why the real database type also qualifies without being edited or annotated: satisfaction is structural, checked once at compile time against the method set. Putting the fake in a `_test.go` file matters for three reasons. It is compiled only for tests, so it never reaches the production binary. It sits next to the assertions that depend on its behaviour, so a reader sees the whole setup at once. And because it lives in the same package, it can use that package's unexported types freely. ## Using it ```go func TestLookupPropagatesStoreError(t *testing.T) { svc := NewService(fakeStore{err: errNotFound}) if _, err := svc.Lookup(context.Background(), 7); err == nil { t.Fatal("want an error, got nil") } } ``` Each test constructs its own fake with the return values that case needs, so tests share no state and can run in parallel. Adding a case means adding a struct literal, not editing shared setup. ## Why this shape is the Go default Go has no `implements` keyword and no runtime type registry, so an interface costs almost nothing to declare and consumers can declare as many as they like. The idiom that falls out of that is: the *consumer* names the abstraction, the *producer* ships a concrete type. The producing package does not have to anticipate anybody's tests, and two different consumers of the same database type can each describe it in the two methods they care about, differently, without coordinating. It also keeps the dependency direction sane. The service package imports nothing from the database package in order to be testable — the interface is its own declaration — so the test binary for that package pulls in no driver, no connection pool and no network. ## What to watch for A hand-written fake is code, and code can lie. If its method returns the zero value and a `nil` error for every call, every test takes the happy path and the error handling in the service is never executed. Give the fake fields (or function fields) so each test says exactly what the collaborator does, and write at least one case where it fails. Finally, keep the interface where the *use* is. Declaring it in the database package, or in a shared `interfaces` package that everybody imports, recreates the coupling you were trying to remove and makes every change a negotiation.

  • Why put the fake struct in a _test.go file rather than in a shared helper package?
    A _test.go file is compiled only for that package's tests, so the fake never ships in the production binary, and it can use the package's unexported types. It also stays beside the assertions that depend on it. A shared fake becomes a second implementation everyone must keep general, and changing it for one test breaks others.
  • Two packages both call the same database type. Should they share one interface declaration?
    Usually not. Each declares the one or two methods it calls, in its own package. The duplication is a signature line, not behaviour, and the concrete type satisfies both without knowing either exists. A shared declaration means any widening for one consumer lands on the other, and on every stand-in written against it.
  • Where does the constructor parameter get its real value in production?
    From the caller that builds the object graph — typically the code that opens the database and constructs the concrete store, then passes it into NewService. The service itself never constructs its collaborators, which is precisely what leaves the seam open.

The appliance specifies the plug shape it needs; the power station does not dictate it. Your package declares the two-pin socket, and anything with those pins fits — the real database type, or a fake you write for one test.

saying these in an interview costs you the question

  • Claims Go needs a mocking framework to substitute a collaborator
  • Declares the interface in the package that implements it
  • Writes one wide Repository interface for every consumer to share
  • Has the service open its own database connection inside a method
  • Adds an if-testing flag to production code instead of a parameter
  • Puts the fake in a shipped package instead of a _test.go file
open as a page

A Go test's hand-written fake must implement six methods though the code calls two. What went wrong?

level: middleimportance: should knowfreq 55%

basics

~20 s

The interface was taken from the implementation's whole surface instead of being declared by the code that uses it. Redeclare it in the consuming package with just the two methods called; the real type still satisfies it, and the fake shrinks to two methods.

open as a page

A Go handler's tests all pass with a hand-written fake, yet production fails on lookup errors. Why?

level: seniorimportance: should knowfreq 44%

basics

~20 s

The fake's method returns the zero value and a nil error for every call, so the code's error branch never executes in any test. Give the fake per-case return values, add cases where it fails, and make an unconfigured call fail the test loudly.

open as a page

You own a Go package many teams import. Should you export an interface for their fakes, or a concrete type?

level: principalimportance: should knowfreq 38%

basics

~20 s

Default to returning the concrete type and letting each consumer declare the one or two methods it needs. An exported interface that downstream teams implement in tests is frozen API: every method you add breaks their stand-ins, and you cannot withdraw it.

open as a page

In Go, what do you trade away when a test fake embeds the interface it is meant to satisfy?

level: middleimportance: nice to knowfreq 30%

basics

~20 s

You trade a compile error for a runtime panic. The embedded interface field is nil but supplies every method, so the fake always compiles; calling a method you did not override dereferences that nil interface and panics instead of failing to build.

open as a page