Содержание урока · 10 разделов
- Тестовый комплект для OrderService: три файла и один прогон
- Слой 1: табличные тесты чистой функции цены
- Слой 2: сьют с SetupTest для сервиса с состоянием
- Слой 3: HTTP-хендлер через httptest
- Асинхронное подтверждение и общий прогон с -race
- Слой комплекта — файл — инструмент
- Чек-лист: что должно быть в комплекте
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Пятница, ревью: коллега слил в main пакет заказов с фразой «тесты добавлю потом». Потом наступило: в проекте восемь файлов продакшен-кода и один тест на happy path. Отдельные приёмы вы уже знаете — ассерты, табличные тесты, сьюты, моки, httptest. Но между «знаю приём» и «есть комплект, которому я верю» лежит разрыв: непонятно, что куда класть, тесты копируются друг из друга, и при первом же рефакторинге краснеет всё разом. Итоговый проект закрывает именно этот разрыв: берём один сервис заказа и собираем вокруг него полный тестовый комплект — по слою на файл, с моками, хуками и общим прогоном в CI. Каждый элемент уже собран руками в предыдущих уроках; здесь они впервые встанут в один каркас.
Тестовый комплект для OrderService: три файла и один прогон#
Пакет простой, как витрина. OrderService умеет считать цену и отменять заказы. Цена — чистая функция CalcTotal(items, discount): сумма позиций минус процент скидки, в минус не уходит. Отмена — метод Cancel(id): сервис берёт заказ у зависимости Store (интерфейс с Get и Save), проверяет статус и не даёт отменить оплаченный. Плюс HTTP-хендлер, который заворачивает запросы к этому сервису. Тестовый комплект раскладывается по трём файлам: price_test.go держит таблицу кейсов для цены, service_test.go — сьют сервиса с моком Store и проверкой асинхронного подтверждения, handler_test.go — хендлер через httptest. Правило деления — файл повторяет слой. Чистая функция не зависит ни от чего, и её тест не тянет ни моков, ни сьютов; сервис с состоянием получает каркас с хуками; хендлер живёт на границе и проверяется как чёрный ящик.
Слой 1: табличные тесты чистой функции цены#
Начинаем с самого дешёвого слоя. CalcTotal ничего не зовёт — ни сети, ни базы, ни часов, поэтому моки здесь не нужны в принципе: кейс — это вход и ожидаемая сумма, третьего не дано. Табличный тест закрывает ветки сразу: пустая корзина, позиция без скидки, скидка десять процентов на две позиции, скидка больше цены — функция обязана выдать ноль, а не отрицательную сумму. Направление важное: пишете функцию — рядом пишете строку в таблице, а не отдельный TestCalcTotalWithDiscount150. Новая ветка цены — новая строка, и за месяц таблица превращается в живую спецификацию расчёта. Механику срезов кейсов и сабтестов разбирал урок про табличные тесты — тут она работает без изменений; добавляется только дисциплина: имя кейса — первая строка отчёта при провале, и «скидка больше цены» говорит больше, чем case 4.
Табличный тест расчёта цены
GOfunc TestCalcTotal(t *testing.T) {
cases := []struct {
name string
items []Item
discount int // процент
want int // копейки
}{
{"пустая корзина", nil, 0, 0},
{"без скидки", []Item{{Price: 2500}}, 0, 2500},
{"скидка 10 процентов", []Item{{Price: 2000}, {Price: 2000}}, 10, 3600},
{"скидка больше цены", []Item{{Price: 100}}, 150, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := CalcTotal(tc.items, tc.discount)
assert.Equal(t, tc.want, got)
})
}
}Таблица — это данные, а тест один: цикл, сабтест с говорящим именем и один assert.Equal. Кейс «скидка больше цены» держит контракт «не уходим в минус»: без него кто-нибудь через полгода уберёт ограничение, и комплект промолчит. Прогон — через локальный Go: go test ./internal/order -run TestCalcTotal, песочница модуль с testify-зависимостью не исполняет.
Проверка: имя кейса
В табличном тесте четыре кейса на цену. Что даёт t.Run с говорящим именем кейса?
Слой 2: сьют с SetupTest для сервиса с состоянием#
Дальше Cancel — метод с состоянием: сервису нужен Store, и каждый тест хочет свежий экземпляр, а не наследство от предыдущего. Сьют решает это одним движением: SetupTest собирает сервис и мок перед каждым тестом, TearDownTest прибирает после. Регистрация — suite.Run в обычной тест-функции, имена методов становятся сабтестами. Хуки и порядок их вызова разбирал урок про хуки сьютов; здесь к ним добавляется главная привычка комплекта: сверка мока уезжает в TearDownTest, чтобы ни один тест не забыл её сделать. Забытая сверка — самая частая дыра мок-тестов: сценарий задан, вызова не было, тест зелёный. В сьюте такая забывчивость исключена по построению.
Сьют сервиса с моком Store
GOtype OrderServiceSuite struct {
suite.Suite
svc *OrderService
store *MockStore
}
func (s *OrderServiceSuite) SetupTest() {
s.store = new(MockStore)
s.svc = NewOrderService(s.store)
}
func (s *OrderServiceSuite) TearDownTest() {
s.store.AssertExpectations(s.T())
}
func (s *OrderServiceSuite) TestCancelPaidOrder() {
s.store.On("Get", "o-1").Return(Order{Status: "paid"}, nil)
err := s.svc.Cancel("o-1")
s.Require().Error(err)
s.store.AssertNotCalled(s.T(), "Save", mock.Anything)
}
func TestOrderServiceSuite(t *testing.T) {
suite.Run(t, new(OrderServiceSuite))
}SetupTest собирает пару сервис-мок заново перед каждым тестом: состояние не перетекает между кейсами. AssertExpectations в TearDownTest — страховка комплекта: сверка срабатывает, даже если тест упал раньше. В кейсе важен AssertNotCalled: для оплаченного заказа Save запрещён, и это проверено явно. suite.Run превращает методы сьюта в обычный прогон go test.
Store в тестах сервиса не настоящий — это mock.Mock. Сценарий задаётся декларативно: On("Get", "o-1") с Return возвращает оплаченный заказ, и сервис можно дёргать без базы. Нюанс, на котором валятся половины комплектов: мок без сверки — заглушка-односторонка. Задали On("Save"), а метод его так и не позвал? Тест зелёный, баг живёт. Поэтому каждая проверка заканчивается сверкой AssertExpectations: несыгравшее ожидание роняет тест. Число вызовов ставьте через Times там, где контракт про «сколько раз»: кэш, ретраи, идемпотентность. Подробно про On, Return и сверку — в уроке про AssertExpectations. И мера важна: мокайте только Store, а внутренние хелперы пакета оставляйте настоящими, иначе тесты начнут проверять вашу память о внутренней кухне, а не контракт.
Проверка: сверка мока
Что именно проверяет store.AssertExpectations(t) в TearDownTest?
Что случится на третьем вызове?
Прочитайте код и выберите, что он выведет:
store.On("Get", "o-1").Return(Order{}, nil).Times(2)
_ = svc.Cancel("o-1")
_ = svc.Cancel("o-1")
_ = svc.Cancel("o-1") // третий — сверх планаПервый черновик service_test.go я написал, проверяя хореографию: On("Get"), следом On("Save"), assert на порядок вызовов. Через месяц рефакторинг: Cancel начал кэшировать заказ внутри запроса — Get зовётся один раз вместо двух, поведение то же, а комплект красный. Моки, повторяющие реализацию, ломаются от каждого движения кода и при этом не ловят ничего. Как избегали: мокаем только границу Store, проверяем результат (ошибка, статус, ответ) и факты («Save не звался для оплаченного»), а последовательность внутренних шагов не фиксируем вообще. Тест, который падает от перестановки вызовов, — это тест о вашей памяти, не о сервисе.
Слой 3: HTTP-хендлер через httptest#
Хендлер — самый внешний слой, и проверять его надо как границу: запрос пришёл, ответ ушёл. Поднятие настоящего сервера не нужно: httptest.NewRequest собирает запрос, NewRecorder подставляет записывающий ResponseWriter, и после вызова метода в рекордере лежат статус и тело. Store по-прежнему мок — хендлер-тест заодно показывает, как комплект переиспользует одну зависимость в двух файлах. Форму запрос-рекордер и разбор ответа дал урок про HTTP-тесты; здесь она встраивается в общий каркас без изменений. Нюанс комплектов: в хендлер-тесте проверяйте статус и тело, а бизнес-логику не дублируйте — за неё отвечает service_test.go. Продублируете — получите двойную работу при каждом изменении контракта и два места, где чинится одно и то же.
Хендлер-тест через httptest
GOfunc TestGetOrderHandler(t *testing.T) {
store := new(MockStore)
store.On("Get", "o-9").Return(Order{ID: "o-9", Total: 3600}, nil)
h := NewHandler(store)
req := httptest.NewRequest(http.MethodGet, "/orders/o-9", nil)
rec := httptest.NewRecorder()
h.GetOrder(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "o-9")
store.AssertExpectations(t)
}Тест читается сверху вниз: сценарий Store, сборка хендлера, запрос без сети, три проверки — статус, тело, сверка мока. AssertExpectations здесь не украшение: если хендлер перестал ходить в Store, тест обязан закраснеть, даже если тело случайно совпало. Запуск — локальным Go: go test ./internal/http -run TestGetOrderHandler, песочница HTTP и testify-модуль не исполняет.
Асинхронное подтверждение и общий прогон с -race#
Остаётся асинхронность. После успешной отмены сервис шлёт подтверждение фоново, и тест, который спит секунду и проверяет флаг, будет и медленным, и flaky. В комплекте — Eventually: условие опрашивается каждые десять миллисекунд с дедлайном в секунду, тест ждёт факт, а не таймер. Приём из урока двадцатого, только теперь он живёт в сьюте: s.T() отдаёт Eventually нужный testing.T. Асинхронность означает горутины, а горутины означают -race: общий прогон комплекта go test -race ./... обязателен и локально, и в CI — та же команда из урока про покрытие и гонки. В пайплайне добавляйте -count=1 против кэша: кэшированное зелёное доказывает меньше честного прогона. На этом курс закрывается: двадцать пять уроков сложились в каркас, который можно унести в свой репозиторий целиком.
Слой комплекта — файл — инструмент#
| Слой комплекта | Файл | Инструмент |
|---|---|---|
| Чистая функция цены | price_test.go | табличные тесты, assert.Equal |
| Сервис с состоянием | service_test.go | suite, SetupTest и TearDownTest |
| Изоляция от хранилища | service_test.go | mock.Mock: On, Return, Times |
| Сверка обращений к Store | service_test.go | AssertExpectations в TearDownTest |
| Асинхронное подтверждение | service_test.go | assert.Eventually |
| HTTP-слой | handler_test.go | httptest.NewRequest и NewRecorder |
| Общий прогон | терминал и CI | go test -race ./... |
Одна школа собирает комплект за выделенный день: садитесь и закрываете пакет тестами целиком, дальше живёте спокойно. Другая против: комплект, написанный залпом после релиза, делается для галочки — проверки пустые, ассерты на не-нулевость, и верить ему нельзя. Моя граница такая: день за комплект оправдан для уже стабильного кода с пользователями — там задача зафиксировать текущее поведение и поймать регресс, и залпом это даже честнее. Растущий код покрывается постепенно: каждая фича идёт в комплект сразу, а задним числом дописываются только ветки ошибок под реальные инциденты. В обоих случаях чек-лист ниже один и тот же — меняется только порядок, в котором строки чек-листа появляются в репозитории.
Чек-лист: что должно быть в комплекте#
- Табличный тест на каждую чистую функцию: граничные значения и ветки ошибок — строками в таблице, а не копипастой.
- Сьют для сервиса с состоянием: SetupTest собирает свежий экземпляр, TearDownTest сверяет моки.
- Мок на каждый внешний интерфейс: Store закрыт mock.Mock, сети и базы в юнит-тестах нет.
- Сверка ожиданий после каждого теста: несыгравшее On — красный тест, а не тишина.
- Eventually для всего асинхронного: подтверждения, фоновые горутины, очередь; sleep в тестах — будущий flaky.
- Хендлеры через httptest: без поднятия сервера, статус и тело проверяются напрямую.
- Один прогон для всего: go test -race ./... в CI с -count=1, красный — стоп слиянию.
Соберите мок-тест отмены
Допишите каркас: создание мока Store, ответ на Get и проверка ошибки отмены.
Соберите хендлер-тест по шагам
Расставьте строки: зависимость и сценарий, потом запрос-рекордер и вызов, в конце сверка мока.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: комплект целиком
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Итоговый проект testify — сборка приёмов: табличные тесты, сьют, моки, httptest и Eventually в одном комплекте.
- Комплект живёт в трёх файлах: price_test.go, service_test.go, handler_test.go — файл повторяет слой.
- Чистая функция закрывается таблицей кейсов; сервис с состоянием — сьютом с SetupTest и TearDownTest.
- Store закрыт mock.Mock: On и Return задают сценарий, AssertExpectations сверяет план с фактом.
- Асинхронное подтверждение проверяется Eventually, хендлер — httptest без сети.
- Общий прогон: go test -race ./... с -count=1 в CI — гонки и регресс ловятся до слияния.
- Мок не повторяет реализацию: проверяйте результат и факты, а не порядок внутренних шагов.
Домашнее задание
Соберите комплект для своего пакета: выберите чистую функцию и накройте её таблицей кейсов, заверните зависимость в интерфейс и мок, добавьте сьют с SetupTest и AssertExpectations. Прогоните go test -race ./... и выпишите, какой слой остался непокрытым — хендлер, асинхронность или ветки ошибок.
Личные заметкиПусто
Вопросы и ответы по уроку
С какого слоя начинать итоговый проект?
С самого дешёвого: чистая функция и табличные тесты дают быстрый зелёный и приучают к формату кейсов. Дальше интерфейс зависимости и мок, потом сьют для сервиса. Хендлер и асинхронность — последними: они дороже в отладке и обычно зависят от уже готовых слоёв.
Нужен ли mockery, чтобы собрать комплект?
Нет: mock.Mock руками покрывает все потребности комплекта — On, Return, Times, AssertExpectations. Генератор mockery полезен, когда интерфейсов много и ручные моки разрастаются; для одного Store это лишний шаг сборки. Начните руками, а генерацию добавляйте, когда моков станет больше пяти.
Комплект зелёный, а баг в проде всё равно нашёлся. Что делать?
Сначала ищите дыру, а не добавляйте процент: чаще всего непокрыта ветка ошибки — стор вернул не то, хендлер получил мусор, подтверждение не дошло. Добавьте кейс в таблицу или сценарий в мок под реальный сбой. Такой красный тест стоит дороже любого роста покрытия.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Viper: конфигурация приложений на Go
Интерактивный курс Viper для Go: YAML и env-переменные, приоритеты источников, Unmarshal, WatchConfig, профили окружений и секреты. 25 уроков с примерами.
- Собирать конфиг из файлов, окружения и флагов в один реестр
- Читать значения типизированно: Get, GetString, Unmarshal в структуры
- Задавать приоритеты источников: флаг > env > файл > default
- Перечитывать конфиг на лету через WatchConfig и OnConfigChange