1. Контракты и сервис
Если вы тестируете функцию вроде Sum(2, 3), жизнь прекрасна: вход → вычисление → результат. Но реальный код часто хочет «сходить наружу»: прочитать файл, взять данные из базы, дёрнуть сеть, узнать «текущее время». И тут unit‑тест начинает грустить, потому что внешний мир недетерминированный: то сеть моргнула, то база не поднялась, то «время» внезапно стало точнее, и тест, который ожидал конкретный формат/точность, сломался. Кстати, такие истории реально происходили: изменение точности time.Now влияло на программы и тесты, которые ожидали старое поведение.
Главная цель unit‑теста — быть повторяемым: запускаться одинаково сегодня, завтра и на компьютере коллеги (который, конечно, «ничего не трогал»). Поэтому в unit‑тестах мы обычно не ходим во внешний мир напрямую. Вместо этого мы делаем внешний мир «подключаемым», чтобы в тесте подставить контролируемую замену.
«Шов» для тестов: зависимость через интерфейс
Чтобы подменять зависимость, её нужно передавать снаружи, а не создавать внутри функции через NewRealDatabase() и «да ладно, оно же быстро». В Go самый распространённый и естественный способ сделать зависимость подключаемой — описать минимальный интерфейс и хранить его в структуре сервиса. Это ровно то, что мы уже умеем: интерфейсы, структуры, методы, конструкторы.
Давайте продолжим наше учебное приложение todo. Представим простую бизнес‑идею: мы хотим создавать задачи, и при создании фиксировать «момент создания». Но сегодня этот «момент» будет не календарным временем, а просто числом (int), чтобы не отвлекаться на time.Time.
Сначала опишем модель:
// task.go
package todo
type Task struct {
ID int
Title string
CreatedAt int // условная шкала, НЕ time.Time
}
Теперь нам нужно хранилище. Для unit‑теста важно, чтобы «сервис задач» не знал, какое это хранилище (память, файл, база) — он должен знать только контракт.
// store.go
package todo
type TaskStore interface {
NextID() int
Save(t Task) error
}
И второй контракт — «часы». Мы специально назовём его так, чтобы не тянуть time, и чтобы было очевидно: это просто источник «текущего значения».
// clock.go
package todo
type Clock interface {
Now() int
}
Теперь сервис, который использует оба контракта:
// 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}
}
Обратите внимание на эффект: теперь Service вообще не умеет «сам добывать» хранилище и часы. Он как человек без рук на кассе самообслуживания: пока вы не дадите ему dependencies — ничего не сделает. Зато unit‑тесты счастливы: мы можем дать ему фейковый мир.
2. Stub и fake в тестах
Stub и fake: похожи, но делают разное
Слова «стаб» и «фейк» часто путают, потому что оба — «не настоящее». Но в тестах важно, что именно мы хотим контролировать: результат или взаимодействие.
Представьте, что вы тестируете доставку пиццы. Stub — это когда вы говорите «всегда возвращай пиццу за 10 минут» и проверяете логику скидки. Fake — это когда вы хотите убедиться, что вы вообще позвонили в доставку и назвали правильный адрес (а не «ул. Пушкина, дом Колотушкина»).
Сведём в небольшую таблицу — она помогает мозгу не путаться:
| Замена | Главная идея | Что обычно хранит | Что мы проверяем в тесте |
|---|---|---|---|
| Stub | «Дай заранее заданный ответ» | заранее заданные value/err | результат функции (got/want) |
| Fake | «Запомни, как тебя вызывали» | called, параметры, счётчики | факт/параметры вызова |
(Иногда ещё говорят «mock», но сегодня мы сознательно без зоопарка терминов: нам достаточно stub и fake.)
Мини‑логика сервиса: создаём задачу
Добавим метод CreateTask. Он делает три понятные вещи: берёт новый ID, берёт «сейчас» от часов, собирает Task, сохраняет в хранилище. В случае ошибки — возвращает ошибку с контекстом.
// 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
}
Здесь мы используем wrapping через %w. Это удобно и в реальном коде, и в тестах: можно проверять «смысл» ошибки через errors.Is/As, а не сравнивать текст. Такой стиль оборачивания ошибок — стандартная практика Go.
Stub‑хранилище: управляем сценарием
Начнём со stub. В нём обычно минимум логики: вернуть заранее заданный ID и заранее заданную ошибку (или отсутствие ошибки).
// 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 }
И «фиксированные часы» — тоже stub‑объект:
// service_clock_test.go
package todo
type fixedClock int
func (c fixedClock) Now() int { return int(c) }
Теперь тест на успешное создание. Он проверяет результат (got/want), а не «как именно» вызывали хранилище.
// 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)
}
}
Обратите внимание: тест вообще не требует настоящего хранилища. Мы не читаем файл, не поднимаем базу, не молимся сетевым богам. Unit‑тесту это и не нужно.
Fake‑хранилище: проверяем взаимодействие
Иногда результата недостаточно. Например, метод может возвращать «красивый» объект, но в хранилище сохранять не то (или не сохранять вовсе — тоже вариант). Тогда нам нужен fake: он запоминает, что в него передали.
// 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
}
И тест, который проверяет «в хранилище ушло то, что мы ожидали»:
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")
}
}
Здесь важно, что fakeStore — указатель, потому что он хранит состояние (saved). Если сделать его значением, изменения будут происходить в копии, и тест будет смотреть на «пустоту» (а вы будете смотреть на тест с выражением «ну почему ты такой»).
3. FakeClock: фиксируем «время» без time.Time
Очень частый источник нестабильности — «текущее время». Даже если вы не сравниваете его напрямую, оно может влиять на сортировку, форматирование, «просрочено/не просрочено», и тесты начинают «мигать».
Нормальный путь: время — это зависимость. Мы уже сделали интерфейс Clock с Now() int. Важно ещё раз проговорить: это не календарное время. Мы не считаем часы/дни/таймзоны. Мы просто хотим, чтобы код мог сказать: «создано в момент 123», а тест мог это контролировать.
Иногда одного фиксированного значения мало: хочется, чтобы «время» шло вперёд, но предсказуемо. Тогда можно сделать часы‑счётчик.
// 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
}
И маленький тест, который показывает детерминизм:
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")
}
}
Это отличный пример «учебной абстракции»: мы получили повторяемость, не лезя в настоящий time, где сразу появляются форматы, локации, UTC/local, монотонность и прочая радость взрослой жизни.
4. Table‑driven тест: разные сценарии, один каркас
Теперь соберём это в более «боевой» вид: table‑driven тест для CreateTask, где мы проверим два сценария: успех и ошибка сохранения. Для ошибки мы подставим stub‑хранилище, которое возвращает заранее заданную ошибку.
Сначала заведём маркерную ошибку (sentinel). Мы уже умеем так делать, и это сильно удобнее, чем сравнивать строки.
// errors_test.go
package todo
import "errors"
var errDiskFull = errors.New("disk full")
Теперь table‑driven тест:
// 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 {
tt := tt
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)
}
})
}
}
Заметьте сочетание техник из всего дня: table‑driven + t.Run + управляемые зависимости. В итоге тесты читаются как сценарии («ok», «save_error»), а не как «магический набор условий».
5. Как это приклеивается к архитектуре
Полезно мысленно держать простую картинку. В продакшене Service будет работать с реальным хранилищем и реальными часами, а в unit‑тестах — с заменами. Сама логика сервиса при этом одна и та же.
flowchart LR
S[Service
CreateTask] --> ST[TaskStore interface]
S --> C[Clock interface]
subgraph PROD[Прод окружение]
ST --> R[(Real store)]
C --> RC[(Real clock)]
end
subgraph TEST[Unit-тест]
ST --> FS[stub/fake store]
C --> FC[fixed/step clock]
end
Эта схема — буквально причина, почему Go‑код с интерфейсами обычно хорошо тестируется: интерфейсы становятся «швами», где реальность можно заменить на контролируемую модель.
6. Типичные ошибки
Ошибка №1: создавать зависимости внутри функции, а не передавать их.
Если внутри CreateTask вы делаете что-то вроде «открою файл, создам клиент, узнаю текущее время», тесты быстро превращаются в мини‑интеграционные, которые то работают, то нет. Правильная дисциплина — передавать зависимости извне (в конструктор или параметром), чтобы тест мог подставить stub/fake.
Ошибка №2: путать stub и fake, из-за чего тест становится либо слабым, либо слишком хрупким.
Когда вы проверяете только результат, fake с проверкой параметров может быть лишним и сделает тест хрупким: вы начнёте «тестировать реализацию», а не поведение. И наоборот, когда важно проверить взаимодействие (что сохранили именно то, что надо), stub будет слишком «глухим», и вы пропустите баг. Выбирайте замену под цель теста: stub — для управления ответом, fake — для контроля вызовов.
Ошибка №3: делать fake значением, когда он должен хранить состояние.
Fake, который запоминает параметры вызова, почти всегда должен быть указателем (*fakeStore). Если сделать его значением, вы легко получите ситуацию «внутри Save всё записалось, а снаружи пусто», потому что тест смотрит на другую копию значения.
Ошибка №4: использовать реальное время в unit‑тестах и потом удивляться «мигающим» падениям.
Когда тест зависит от «сейчас», он начинает зависеть от скорости машины, планировщика, случайных задержек и даже изменений в точности/формате времени. Поэтому время лучше сделать зависимостью и фиксировать его в тесте. И, да, в нашей учебной версии FakeClock возвращает int именно для того, чтобы не тащить календарную сложность туда, где она не нужна.
Ошибка №5: сравнивать текст ошибок вместо смысла.
Если вы оборачиваете ошибку (fmt.Errorf("...": %w, err)), то прямое сравнение строк становится ломким: поменяли текст — сломали тест, хотя поведение то же. Гораздо стабильнее проверять errors.Is(err, want) (или errors.As, если нужен типизированный контекст). Такой подход прямо поддержан стандартной библиотекой и является базовым стилем работы с ошибками в современном Go.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ