Skip to content
gohacks.dev
Testing

Dependency Injection for Testability in Go Code Bases

Make Go code testable with plain dependency injection: small consumer-side interfaces, constructors that take dependencies, fakes in tests, no framework.

12 min read
Summarize in AI
Flat illustration of a dependency tree of packages with version tags.

Dependency injection sounds like a framework and, in Go, it is almost always just a function argument. A piece of code that needs a collaborator, a mailer, a clock, a database, receives it instead of creating it. That single decision is what makes the code testable: a test can hand it a fake, and production hands it the real one. This article shows the Go way of doing it on Go 1.25.6, with no framework, and says when a framework starts to earn its place.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

The problem it solves

Code that builds its own dependencies cannot be tested without them. A function that calls smtp.SendMail directly sends mail when tested. A function that calls time.Now() cannot be asked about the first of January. A constructor that opens a database connection needs a database to construct. Each of these makes the tests slow, flaky or impossible, and each is fixed the same way: take the thing as a parameter.

A service with two dependencies

notify.goGo
package main

import (
	"context"
	"fmt"
	"time"
)

// Notifier is defined by the consumer, with only the method it needs.
type Notifier interface {
	Send(ctx context.Context, to, msg string) error
}

type Clock func() time.Time

type Signup struct {
	notify Notifier
	now    Clock
	users  map[string]time.Time
}

// New takes its dependencies as arguments and returns a concrete type.
func New(n Notifier, now Clock) *Signup {
	return &Signup{notify: n, now: now, users: map[string]time.Time{}}
}

func (s *Signup) Register(ctx context.Context, email string) error {
	if _, dup := s.users[email]; dup {
		return fmt.Errorf("register %s: already registered", email)
	}
	s.users[email] = s.now()
	if err := s.notify.Send(ctx, email, "welcome"); err != nil {
		delete(s.users, email) // do not keep a user nobody was told about
		return fmt.Errorf("register %s: %w", email, err)
	}
	return nil
}

// SMTP stands for the real implementation, wired in main.
type SMTP struct{ Host string }

func (s SMTP) Send(ctx context.Context, to, msg string) error { return nil }

var _ Notifier = SMTP{} // compile time proof that SMTP satisfies the interface

func main() {
	svc := New(SMTP{Host: "smtp.example.test"}, time.Now)
	_ = svc.Register(context.Background(), "[email protected]")
}

Signup depends on two things. A Notifier sends a message, and a Clock, a function type, supplies the current time. Both are given to New, which returns a concrete *Signup. The real implementation, SMTP, is a struct with one method; main is the only place that knows about it.

Four design choices in that file are the whole technique.

The interface belongs to the consumer. Notifier is declared next to Signup, the code that uses it, and has the one method that Signup calls. It is not declared next to SMTP. This is the opposite of what Java and C# habits suggest, and it is the Go idiom. The effect is that the service does not import the mail package. The dependency arrow points from the implementation to the consumer's interface, and the consumer knows nothing about SMTP, so swapping the provider changes main and nothing else.

Keep interfaces small. One method is ideal; two or three is fine. A Notifier with ten methods forces every fake to implement ten methods, and tests stop being short. If a dependency has a large surface, define the narrow interface your code needs and let the real type satisfy it implicitly.

Accept interfaces, return concrete types. New returns *Signup, not an interface. A caller gets the real type with all its methods, and nothing is hidden behind an abstraction that has one implementation. Return an interface only when you genuinely have several implementations a caller chooses between.

Check the real type at compile time. var _ Notifier = SMTP{} is a one-line assertion that SMTP satisfies Notifier. If someone changes the method signature, the build fails at that line, in the file that owns the type, with a clear message, instead of at main.

Tests with a hand-written fake

notify_test.goGo
package main

import (
	"context"
	"errors"
	"testing"
	"time"
)

type spy struct {
	sent []string
	err  error
}

func (s *spy) Send(_ context.Context, to, msg string) error {
	s.sent = append(s.sent, to+":"+msg)
	return s.err
}

var fixed = func() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) }

func TestRegisterSendsWelcome(t *testing.T) {
	n := &spy{}
	s := New(n, fixed)
	err := s.Register(t.Context(), "[email protected]")
	t.Logf("err=%v sent=%v", err, n.sent)
}

func TestNotifierFailureRollsBack(t *testing.T) {
	n := &spy{err: errors.New("smtp down")}
	s := New(n, fixed)
	err := s.Register(t.Context(), "[email protected]")
	t.Logf("err=%v users=%d", err, len(s.users))
	// The retry works because the failed registration was undone.
	n.err = nil
	t.Logf("retry err=%v users=%d", s.Register(t.Context(), "[email protected]"), len(s.users))
}

func TestDuplicate(t *testing.T) {
	n := &spy{}
	s := New(n, fixed)
	_ = s.Register(t.Context(), "[email protected]")
	err := s.Register(t.Context(), "[email protected]")
	t.Logf("err=%v sent=%d", err, len(n.sent))
}
Shell
go test -count=1 -v . 2>&1 | grep -vE "^(=== RUN|=== PAUSE|=== CONT|PASS|ok)" | sed -E "s/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: #    #"
Text
    err=<nil> sent=[[email protected]:welcome]
--- PASS: TestRegisterSendsWelcome
    err=register [email protected]: smtp down users=0
    retry err=<nil> users=1
--- PASS: TestNotifierFailureRollsBack
    err=register [email protected]: already registered sent=1
--- PASS: TestDuplicate

The fake, spy, is eight lines: it records each message and returns a configurable error. There is no mock framework. Because the interface has one method, the fake is as small as the dependency is simple, which is the dividend of keeping interfaces narrow. The broader treatment of when to use a hand-written fake, a stub or a generated mock is in the fakes, stubs and mocks article.

The three tests show what injection buys.

  1. The happy path. Register sends one message: the log shows sent=[[email protected]:welcome]. The test did not need a mail server.
  2. A failing dependency. The spy returns smtp down. Register returns register [email protected]: smtp down, wrapping the cause with %w, and users=0: the registration was rolled back because nobody was told. The test then clears the spy's error and registers again, and this time users=1. That whole scenario, including the retry, would be awkward to produce with real SMTP and is three lines here.
  3. A duplicate. The second registration fails with already registered, and the notifier received one message. The test checks a negative: that nothing extra was sent.

The clock is injected as a function value, fixed, so the time stored for the user is known. A test can assert on it without tolerance.

Wiring in main

All construction happens at the edge. main builds SMTP, passes it to New with time.Now, and starts the program. In a larger program this is the composition root: one place, usually main or a function called by it, that creates every long-lived object and connects them. Nothing else calls a constructor for a collaborator.

Go
func main() {
	db := mustOpenDB(os.Getenv("DATABASE_URL"))
	mailer := SMTP{Host: os.Getenv("SMTP_HOST")}
	users := NewUserStore(db)
	signup := New(mailer, time.Now)
	srv := NewServer(signup, users)
	log.Fatal(srv.ListenAndServe())
}

The code above is illustrative wiring and is not part of the lab's run; the lab's main does the same with two objects. A composition root reads top to bottom like a bill of materials, and a reader can see every dependency in the program on one screen.

Options for the how

Constructor arguments, as above, are the default. They make dependencies required and visible.

Functional options suit optional dependencies and settings: New(n, WithClock(c), WithLogger(l)). The required collaborator stays a positional argument; the options carry defaults you rarely change. Use them when a constructor would otherwise take six parameters.

A config struct works when there are many fields: New(Deps{Notifier: n, Clock: c}). It is the clearest for many dependencies, and the struct documents them. Check for nil in New and fail early.

Method injection and setters are a last resort. A field set after construction makes the object usable in a half-built state, and nothing stops a test from forgetting to set it.

Package variables (var Mailer Notifier = SMTP{}) are the shortcut to avoid. Tests replace them, parallel tests collide, and nothing in a function's signature says it needs a mailer. See the determinism article for the same trap with clocks.

Retrofitting an existing function

Most code arrives without injection, so the practical skill is adding it in small safe steps. Take a function that sends mail directly and reads the time:

  1. Find the calls to the outside world. In the function body, list each call that reaches something you cannot control in a test: the mail library, time.Now, a global database handle.
  2. Define the narrowest interface for each at the point of use, with only the methods the function calls. For time.Now, a function type is enough.
  3. Turn the function into a method on a struct whose fields hold those dependencies, or add them as parameters if it is a single function.
  4. Add the constructor. New takes the dependencies and returns the struct. Keep the old entry point as a thin wrapper that builds the real ones, so existing callers compile.
  5. Write the first test with a fake for the case that was untestable, and watch it pass.
  6. Move the construction to the edge one caller at a time.

Each step compiles and the tests stay green. The refactor that changes everything at once is the one that is reverted.

Fakes that implement part of an interface

An interface with five methods and a test that calls one is a common annoyance. Go has a clean answer: embed the interface in the fake struct. The embedded field is nil, which satisfies the full method set, and the struct overrides only the method the test uses. Calling an unimplemented method panics with a nil pointer, which is also a feature: it tells you the code under test touched something the test did not expect. This is worth knowing for hand-written fakes. Use it when the interface is not yours to shrink, such as one from a library.

The alternative is to narrow the interface in your own package, as above. Prefer that when you control the consumer, because it also documents what the code actually needs.

What not to inject

Not every value is a dependency. The following are better left alone.

  • Pure functions and values. strings.ToUpper, a constant, a tax rate in a struct. They are deterministic, and injecting them adds ceremony for no test gain.
  • Request-scoped data. User identity, trace IDs and deadlines belong in context.Context, passed as the first argument, and not in a constructor. A service object is shared across requests; the user is not.
  • The standard library when it is already testable. bytes.Buffer, strings.Reader, io.Writer are interfaces already. Take an io.Writer and tests pass a buffer.
  • Loggers, debated. A package-level slog.Default() is acceptable in many programs; if you want to assert on logs, take a *slog.Logger and use a capture handler, as in the slog article.

Testing the wiring itself

The composition root is code, and it can be wrong: a dependency passed in the wrong order, a nil that reaches a method. A small test that calls the constructor function with test configuration and makes one request or one call through the assembled graph catches most of these. It is a smoke test in the integration tier, not a unit test, and one per service is enough. Failing fast at start-up helps too: New should return an error or panic on a nil dependency instead of letting it fail on the first use in production.

Common mistakes

  • Interfaces defined next to the implementation, so that the consumer imports the provider's package for its type, which brings the dependency back.
  • A mock for everything. If the fake knows the order of calls on its collaborators, the test is checking the implementation, and it breaks on refactors that change nothing observable.
  • Constructors that do work. New that opens connections or starts goroutines cannot be called in a test without side effects. Keep constructors to assignment and validation, and put start-up in a separate Start or Run.
  • Hidden global access inside an injected object. A service that takes a Notifier and also calls os.Getenv has a dependency you cannot replace. Pass the setting in.
  • Optional dependencies defaulting to the real thing, so that a test forgets to pass one and sends real mail. A nil check that fails loudly is safer than a silent default.

Do you need a framework

Go has DI tools: google/wire generates the wiring code at build time, and uber-go/dig and fx resolve it at run time with reflection. They are described here, not run. Wire is the closer fit for Go's taste, since it produces the plain constructor calls you would have written, and checks the graph at build time. Consider one when the composition root exceeds a few hundred lines and keeping it correct becomes work, when many services share the same dependency graph, or when the order of construction is a source of bugs. Before that point, hand-written wiring is more readable than a generator's output, and the first sign that you need a tool is a main.go you dread editing.

Runtime reflection containers trade compile-time safety for convenience: a missing dependency fails at start-up, not at build. That is acceptable to some teams and surprising to those who expect the compiler to find wiring errors.

When to introduce an interface

Not for every type. An interface earns its place at a boundary: where your code meets something slow, nondeterministic or external, such as a network call, a database, the clock or the file system. Inside the core of the program, between your own pure functions and structs, direct calls are simpler and the tests are just ordinary tests. A codebase with an interface for every struct has a second copy of its API to maintain and no tests that were not possible before.

A useful test of an interface: could you describe two different implementations, one of which is a test double? If the only second implementation would be a mock written for the sake of testing one function, consider a different seam, such as a function parameter.

Limits

Injection makes a dependency replaceable; it does not make the behaviour right. A fake that always succeeds will not tell you what happens when the real mailer is slow, and a spy cannot find a mismatch between the interface and the provider's behaviour. Back the fakes with a few tests against the real thing, as in testcontainers for databases. Watch for interfaces that grow methods until the fake is as large as the real type, which means the boundary is in the wrong place. And remember that DI is a technique for the seams, not a goal: the best-tested Go code often has few interfaces, each in the right place.

For the language side, the Go FAQ explains why interfaces are satisfied implicitly, and the Go code review comments on interfaces give the "define it where it is used" guidance. For the consumer-side view of tests, see the testing guide.

Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.