Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 25 из 25 ~24 мин +20 XP

Итоговый проект: тестовый комплект для пакета

Итоговый проект testify — это сборка, а не новая теория: из приёмов предыдущих уроков собирается полный тестовый комплект go для одного сервиса заказа. Табличные тесты, сьют с хуками, мок Store, httptest и прогон с -race ложатся в три файла. Сначала — что собираем, потом — по файлу за раз.

Содержание урока · 10 разделов

Пятница, ревью: коллега слил в 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.

Табличный тест расчёта цены

GO
func 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

GO
type 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") // третий — сверх плана

Слой 3: HTTP-хендлер через httptest#

Хендлер — самый внешний слой, и проверять его надо как границу: запрос пришёл, ответ ушёл. Поднятие настоящего сервера не нужно: httptest.NewRequest собирает запрос, NewRecorder подставляет записывающий ResponseWriter, и после вызова метода в рекордере лежат статус и тело. Store по-прежнему мок — хендлер-тест заодно показывает, как комплект переиспользует одну зависимость в двух файлах. Форму запрос-рекордер и разбор ответа дал урок про HTTP-тесты; здесь она встраивается в общий каркас без изменений. Нюанс комплектов: в хендлер-тесте проверяйте статус и тело, а бизнес-логику не дублируйте — за неё отвечает service_test.go. Продублируете — получите двойную работу при каждом изменении контракта и два места, где чинится одно и то же.

Хендлер-тест через httptest

GO
func 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.gosuite, SetupTest и TearDownTest
Изоляция от хранилищаservice_test.gomock.Mock: On, Return, Times
Сверка обращений к Storeservice_test.goAssertExpectations в TearDownTest
Асинхронное подтверждениеservice_test.goassert.Eventually
HTTP-слойhandler_test.gohttptest.NewRequest и NewRecorder
Общий прогонтерминал и CIgo test -race ./...

Чек-лист: что должно быть в комплекте#

  • Табличный тест на каждую чистую функцию: граничные значения и ветки ошибок — строками в таблице, а не копипастой.
  • Сьют для сервиса с состоянием: SetupTest собирает свежий экземпляр, TearDownTest сверяет моки.
  • Мок на каждый внешний интерфейс: Store закрыт mock.Mock, сети и базы в юнит-тестах нет.
  • Сверка ожиданий после каждого теста: несыгравшее On — красный тест, а не тишина.
  • Eventually для всего асинхронного: подтверждения, фоновые горутины, очередь; sleep в тестах — будущий flaky.
  • Хендлеры через httptest: без поднятия сервера, статус и тело проверяются напрямую.
  • Один прогон для всего: go test -race ./... в CI с -count=1, красный — стоп слиянию.

Соберите мок-тест отмены

Допишите каркас: создание мока Store, ответ на Get и проверка ошибки отмены.

func TestCancelPaidOrder(t *testing.T) { store := new(mock.) store.On("Get", "o-1").(Order{Status: "paid"}, nil) svc := NewOrderService(store) err := svc.Cancel("o-1") require.(t, err) store.AssertNotCalled(t, "Save", mock.Anything) }

Соберите хендлер-тест по шагам

Расставьте строки: зависимость и сценарий, потом запрос-рекордер и вызов, в конце сверка мока.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: комплект целиком

Термин

Табличный тест

нажмите, чтобы перевернуть
Определение

Срез кейсов с полями name и want плюс t.Run по имени кейса: новая ветка — новая строка таблицы, а не новая копипаста.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 ./... и выпишите, какой слой остался непокрытым — хендлер, асинхронность или ветки ошибок.

Личные заметкиПусто
Хранятся только в вашем браузере (Testify · Итоговый проект: тестовый комплект для пакета)

Вопросы и ответы по уроку

С какого слоя начинать итоговый проект?

С самого дешёвого: чистая функция и табличные тесты дают быстрый зелёный и приучают к формату кейсов. Дальше интерфейс зависимости и мок, потом сьют для сервиса. Хендлер и асинхронность — последними: они дороже в отладке и обычно зависят от уже готовых слоёв.

Нужен ли mockery, чтобы собрать комплект?

Нет: mock.Mock руками покрывает все потребности комплекта — On, Return, Times, AssertExpectations. Генератор mockery полезен, когда интерфейсов много и ручные моки разрастаются; для одного Store это лишний шаг сборки. Начните руками, а генерацию добавляйте, когда моков станет больше пяти.

Комплект зелёный, а баг в проде всё равно нашёлся. Что делать?

Сначала ищите дыру, а не добавляйте процент: чаще всего непокрыта ветка ошибки — стор вернул не то, хендлер получил мусор, подтверждение не дошло. Добавьте кейс в таблицу или сценарий в мок под реальный сбой. Такой красный тест стоит дороже любого роста покрытия.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Viper25 уроков~9 чпрактика25 уроков уже доступны

Viper: конфигурация приложений на Go

Интерактивный курс Viper для Go: YAML и env-переменные, приоритеты источников, Unmarshal, WatchConfig, профили окружений и секреты. 25 уроков с примерами.

  • Собирать конфиг из файлов, окружения и флагов в один реестр
  • Читать значения типизированно: Get, GetString, Unmarshal в структуры
  • Задавать приоритеты источников: флаг > env > файл > default
  • Перечитывать конфиг на лету через WatchConfig и OnConfigChange