JavaRush /Курсы /Go SELF /Фейки и стабы через интерфейсы

Фейки и стабы через интерфейсы

Go SELF
29 уровень , 4 лекция
Открыта

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.

1
Задача
Go SELF, 29 уровень, 4 лекция
Недоступна
Штамп события
Штамп события
1
Задача
Go SELF, 29 уровень, 4 лекция
Недоступна
Бронирование ID
Бронирование ID
1
Задача
Go SELF, 29 уровень, 4 лекция
Недоступна
Доставка сообщений
Доставка сообщений
1
Задача
Go SELF, 29 уровень, 4 лекция
Недоступна
Заметки сервиса
Заметки сервиса
1
Опрос
Unit‑тесты, 29 уровень, 4 лекция
Недоступен
Unit‑тесты
Unit‑тесты
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ