1. Contracts and service
If you are testing a function like Sum(2, 3), life is great: input → computation → result. But real code often wants to “go outside”: read a file, fetch data from a database, hit the network, find out “the current time”. And then a unit test starts getting sad, because the outside world is non-deterministic: the network blinked, the DB did not start, the “time” suddenly became more precise, and the test that expected a specific format/precision broke. By the way, stories like this really happened: changing the precision of time.Now affected programs and tests that expected the old behavior.
The main goal of a unit test is to be repeatable: to run the same way today, tomorrow, and on your colleague’s computer (who, of course, “didn’t touch anything”). That’s why in unit tests we usually do not go to the outside world directly. Instead, we make the outside world “pluggable” so that in a test we can substitute a controlled replacement.
A “seam” for tests: dependency via an interface
To replace a dependency, you must pass it in from the outside, rather than creating it inside the function via NewRealDatabase() and “come on, it’s fast”. In Go, the most common and natural way to make a dependency pluggable is to describe a minimal interface and store it inside the service struct. That is exactly what we already know how to do: interfaces, structs, methods, constructors.
Let’s continue our training app todo. Imagine a simple business idea: we want to create tasks, and when creating them, record the “creation moment”. But today this “moment” will not be calendar time; it will be just a number (int) so we don’t get distracted by time.Time.
First, let’s describe the model:
// task.go
package todo
type Task struct {
ID int
Title string
CreatedAt int // arbitrary scale, NOT time.Time
}
Now we need storage. For a unit test, it’s important that the “task service” does not know what kind of storage it is (memory, file, database) — it should only know the contract.
// store.go
package todo
type TaskStore interface {
NextID() int
Save(t Task) error
}
And the second contract is a “clock”. We intentionally name it that way so we don’t pull in time, and so it’s obvious: it’s just a source of the “current value”.
// clock.go
package todo
type Clock interface {
Now() int
}
Now the service that uses both contracts:
// service.go
package todo
import "fmt"
type Service struct {
store TaskStore
clock Clock
}
func NewService(store TaskStore, clock Clock) Service {
return Service{store: store, clock: clock}
}
Notice the effect: now Service cannot “obtain” storage and a clock by itself at all. It’s like a person without hands at a self-checkout: until you give it dependencies, it won’t do anything. But unit tests are happy: we can give it a fake world.
2. Stub and fake in tests
Stub and fake: similar, but do different things
The words “stub” and “fake” are often confused because both are “not real”. But in tests, what matters is what exactly we want to control: the result or the interaction.
Imagine you are testing pizza delivery. Stub is when you say “always return a pizza in 10 minutes” and you test the discount logic. Fake is when you want to make sure you actually called the delivery service and gave the right address (and not “Pushkin St., Knock-Knock House”).
Let’s summarize it in a small table — it helps the brain not to mix them up:
| Replacement | Main idea | What it usually stores | What we verify in the test |
|---|---|---|---|
| Stub | “Give a predefined answer” | predefined value/err | function result (got/want) |
| Fake | “Remember how you were called” | called, arguments, counters | call fact/arguments |
(Sometimes people also say “mock”, but today we intentionally avoid the terminology zoo: stub and fake are enough.)
Minimal service logic: create a task
Let’s add a method CreateTask. It does three straightforward things: gets a new ID, gets “now” from the clock, assembles a Task, saves it to storage. If there is an error, it returns an error with context.
// service_create.go
package todo
import "fmt"
func (s Service) CreateTask(title string) (Task, error) {
t := Task{ID: s.store.NextID(), Title: title, CreatedAt: s.clock.Now()}
if err := s.store.Save(t); err != nil {
return Task{}, fmt.Errorf("save task: %w", err)
}
return t, nil
}
Here we use wrapping via %w. This is convenient both in real code and in tests: you can verify the “meaning” of an error via errors.Is/As, instead of comparing text. This error-wrapping style is standard practice in Go.
Stub storage: control the scenario
Let’s start with a stub. It usually has minimal logic: return a predefined ID and a predefined error (or no error).
// service_stub_test.go
package todo
type stubStore struct {
nextID int
saveErr error
}
func (st stubStore) NextID() int { return st.nextID }
func (st stubStore) Save(t Task) error { return st.saveErr }
And a “fixed clock” is also a stub object:
// service_clock_test.go
package todo
type fixedClock int
func (c fixedClock) Now() int { return int(c) }
Now a test for successful creation. It checks the result (got/want), not “exactly how” the storage was called.
// service_create_test.go
package todo
import "testing"
func TestService_CreateTask_OK(t *testing.T) {
svc := NewService(stubStore{nextID: 7}, fixedClock(123))
got, err := svc.CreateTask("read book")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.ID != 7 || got.CreatedAt != 123 {
t.Fatalf("got %+v, want ID=7 CreatedAt=123", got)
}
}
Notice: the test does not require real storage at all. We don’t read a file, don’t spin up a database, don’t pray to the network gods. A unit test does not need that.
Fake storage: verify interaction
Sometimes the result is not enough. For example, a method may return a “nice” object but save the wrong thing to storage (or not save at all — also possible). Then we need a fake: it remembers what was passed to it.
// service_fake_test.go
package todo
type fakeStore struct {
nextID int
saved []Task
}
func (fs *fakeStore) NextID() int { return fs.nextID }
func (fs *fakeStore) Save(t Task) error {
fs.saved = append(fs.saved, t)
return nil
}
And a test that verifies “what went into storage is what we expected”:
package todo
import "testing"
func TestService_CreateTask_SavesTask(t *testing.T) {
fs := &fakeStore{nextID: 1}
svc := NewService(fs, fixedClock(50))
_, _ = svc.CreateTask("write tests")
if len(fs.saved) != 1 {
t.Fatalf("Save calls=%d, want %d", len(fs.saved), 1)
}
if fs.saved[0].Title != "write tests" {
t.Fatalf("saved title=%q, want %q", fs.saved[0].Title, "write tests")
}
}
What matters here is that fakeStore is a pointer, because it stores state (saved). If you make it a value, changes will happen on a copy, and the test will look at “emptiness” (and you will look at the test like “why are you like this”).
3. FakeClock: freezing “time” without time.Time
A very common source of flakiness is “current time”. Even if you don’t compare it directly, it can affect sorting, formatting, “expired/not expired”, and tests start “blinking”.
The right approach: time is a dependency. We already made the Clock interface with Now() int. It’s important to say again: this is not calendar time. We are not counting hours/days/time zones. We just want code to be able to say: “created at moment 123”, and the test to control that.
Sometimes a single fixed value is not enough: you want “time” to move forward, but predictably. Then you can make a counter clock.
// step_clock_test.go
package todo
type stepClock struct {
now int
step int
}
func (c *stepClock) Now() int {
v := c.now
c.now += c.step
return v
}
And a small test that shows determinism:
package todo
import "testing"
func TestStepClock(t *testing.T) {
c := &stepClock{now: 10, step: 5}
if c.Now() != 10 || c.Now() != 15 {
t.Fatalf("unexpected Now sequence")
}
}
This is a great example of a “teaching abstraction”: we got repeatability without touching real time, where formats, locales, UTC/local, monotonic time, and other grown-up joys immediately show up.
4. Table-driven test: different scenarios, one harness
Now let’s put this into a more “production-like” shape: a table-driven test for CreateTask, where we verify two scenarios: success and save error. For the error, we will substitute a stub storage that returns a predefined error.
First, let’s define a sentinel error. We already know how to do this, and it’s much more convenient than comparing strings.
// errors_test.go
package todo
import "errors"
var errDiskFull = errors.New("disk full")
Now the table-driven test:
// service_table_test.go
package todo
import (
"errors"
"testing"
)
func TestService_CreateTask_Table(t *testing.T) {
tests := []struct {
name string
store TaskStore
clock Clock
wantErr error
}{
{"ok", stubStore{nextID: 1}, fixedClock(10), nil},
{"save_error", stubStore{nextID: 1, saveErr: errDiskFull}, fixedClock(10), errDiskFull},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := NewService(tt.store, tt.clock)
_, err := svc.CreateTask("x")
if tt.wantErr == nil && err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tt.wantErr != nil && !errors.Is(err, tt.wantErr) {
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
}
})
}
}
Notice the combination of techniques from the whole session: table-driven + t.Run + controlled dependencies. As a result, the tests read like scenarios (“ok”, “save_error”), not like a “magic set of conditions”.
5. How this sticks to the architecture
It’s useful to keep a simple picture in mind. In production, Service will work with real storage and a real clock, and in unit tests — with replacements. The service logic itself remains the same.
flowchart LR
S[Service
CreateTask] --> ST[TaskStore interface]
S --> C[Clock interface]
subgraph PROD[Prod environment]
ST --> R[(Real store)]
C --> RC[(Real clock)]
end
subgraph TEST[Unit test]
ST --> FS[stub/fake store]
C --> FC[fixed/step clock]
end
This diagram is literally the reason why Go code with interfaces is usually easy to test: interfaces become “seams” where reality can be replaced with a controlled model.
6. Common mistakes
Mistake #1: creating dependencies inside the function instead of passing them in.
If inside CreateTask you do something like “open a file, create a client, find out the current time”, tests quickly turn into mini integration tests that sometimes work and sometimes don’t.
The right discipline is to pass dependencies from the outside (into the constructor or as a parameter) so the test can substitute a stub/fake.
Mistake #2: mixing up stub and fake, making the test either weak or too brittle.
When you only verify the result, a fake with argument checks may be unnecessary and will make the test brittle: you start “testing the implementation” rather than the behavior.
And conversely, when it’s important to verify interaction (that you saved exactly what you needed to save), a stub will be too “deaf”, and you’ll miss a bug.
Choose the replacement based on the test’s goal: stub — to control the answer, fake — to control calls.
Mistake #3: making a fake a value when it needs to store state.
A fake that remembers call arguments almost always must be a pointer (*fakeStore).
If you make it a value, you can easily get the situation “inside Save everything got written, but outside it’s empty”, because the test is looking at a different copy of the value.
Mistake #4: using real time in unit tests and then being surprised by “blinking” failures.
When a test depends on “now”, it starts depending on machine speed, the scheduler, random delays, and even changes in time precision/format.
So it’s better to make time a dependency and freeze it in the test.
And yes, in our training version FakeClock returns int precisely so we don’t drag calendar complexity into places where it isn’t needed.
Mistake #5: comparing error text instead of meaning.
If you wrap an error (fmt.Errorf("...": %w, err)), direct string comparison becomes brittle: change the text — break the test, even though behavior is the same.
It is much more stable to verify errors.Is(err, want)
(or errors.As if you need typed context).
This approach is directly supported by the standard library and is the baseline error-handling style in modern Go.
GO TO FULL VERSION