JavaRush /Courses /Go SELF /Fakes and stubs via interfaces

Fakes and stubs via interfaces

Go SELF
Level 29, Lesson 4
Available

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.

1
Task
Go SELF, level 29, lesson 4
Locked
Event stamp
Event stamp
1
Task
Go SELF, level 29, lesson 4
Locked
ID Reservation
ID Reservation
1
Task
Go SELF, level 29, lesson 4
Locked
Message delivery
Message delivery
1
Task
Go SELF, level 29, lesson 4
Locked
Notes service
Notes service
1
Survey/quiz
Unit tests, 29 level, 4 lesson
Unavailable
Unit tests
Unit tests
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION