---
title: "Итоговый проект: тестовый комплект для пакета"
description: "Итоговый проект testify: соберите тестовый комплект go для сервиса заказа — табличные тесты цены, сьют с моками Store, httptest и прогон с -race."
course: "Testify: тестирование Go с assert и require"
url: "https://golangmaster.ru/courses/testify/lessons/25-itogovyj-proekt/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 24
---

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

*Курс «Testify: тестирование Go с assert и require», урок 25 из 25. ~24 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/testify/lessons/25-itogovyj-proekt/*

Итоговый проект testify — это сборка, а не новая теория: из приёмов предыдущих уроков собирается полный тестовый комплект go для одного сервиса заказа. Табличные тесты, сьют с хуками, мок Store, 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. Новая ветка цены — новая строка, и за месяц таблица превращается в живую спецификацию расчёта. Механику срезов кейсов и сабтестов разбирал [урок про табличные тесты](/courses/testify/lessons/13-tablichnye-testy/) — тут она работает без изменений; добавляется только дисциплина: имя кейса — первая строка отчёта при провале, и «скидка больше цены» говорит больше, чем 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 с говорящим именем кейса?

- ✅ Провал виден по имени: TestCalcTotal/скидка_больше_цены — сразу ясно, какая ветка сломалась
- Ускоряет прогон: сабтесты кэшируются отдельно друг от друга
- Меняет поведение assert: в сабтесте Equal становится строже
- Обязателен для testify: без t.Run ассерты не работают

Имя сабтеста попадает в отчёт целиком, и при провале видно, какой кейс сломался, без открытия файла. assert.Equal в сабтесте работает так же, как и без него; кэш и скорость ни при чём, а t.Run — механизм testing, testify от него не зависит.

### Слой 2: сьют с SetupTest для сервиса с состоянием

Дальше Cancel — метод с состоянием: сервису нужен Store, и каждый тест хочет свежий экземпляр, а не наследство от предыдущего. Сьют решает это одним движением: SetupTest собирает сервис и мок перед каждым тестом, TearDownTest прибирает после. Регистрация — suite.Run в обычной тест-функции, имена методов становятся сабтестами. Хуки и порядок их вызова разбирал [урок про хуки сьютов](/courses/testify/lessons/12-suites-hooks/); здесь к ним добавляется главная привычка комплекта: сверка мока уезжает в 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](/courses/testify/lessons/17-mock-expectations/). И мера важна: мокайте только Store, а внутренние хелперы пакета оставляйте настоящими, иначе тесты начнут проверять вашу память о внутренней кухне, а не контракт.

### Проверка: сверка мока

**Вопрос:** Что именно проверяет store.AssertExpectations(t) в TearDownTest?

- ✅ Что каждое объявленное On(...) реально сыграло нужное число раз
- Что мок создан через new(mock.Mock), а не struct literal
- Что Return вернул не нулевое значение
- Что тест не паниковал до этого момента

AssertExpectations сверяет два списка: что задано через On и что реально случилось. Ожидание без вызова — провал теста, вызов без ожидания — паника ещё раньше, на месте вызова. Именно эта сверка отличает мок от заглушки: заглушке всё равно, мок фиксирует контракт взаимодействия.

### Что случится на третьем вызове?

```go
store.On("Get", "o-1").Return(Order{}, nil).Times(2)

_ = svc.Cancel("o-1")
_ = svc.Cancel("o-1")
_ = svc.Cancel("o-1") // третий — сверх плана
```

**Что выведет программа?**

- ✅ Паника mock: вызовов больше, чем разрешено .Times(2)
- Тест падает позже, только на AssertExpectations
- Третий вызов вернёт нулевой Order без ошибки
- Паники нет: Times после второго вызова игнорируется

После второго вызова лимит .Times(2) исчерпан: третий вызов совпадает по аргументам, но разрешения больше нет, и mock паникует с сообщением о превышении числа вызовов. Это падение теста, а не тихий возврат: лишний вызов зависимости — тоже нарушение контракта. Нужен ещё вызов — добавьте On или снимите ограничение.

> **Живое место: комплект, который врёт.** Первый черновик service_test.go я написал, проверяя хореографию: On("Get"), следом On("Save"), assert на порядок вызовов. Через месяц рефакторинг: Cancel начал кэшировать заказ внутри запроса — Get зовётся один раз вместо двух, поведение то же, а комплект красный. Моки, повторяющие реализацию, ломаются от каждого движения кода и при этом не ловят ничего. Как избегали: мокаем только границу Store, проверяем результат (ошибка, статус, ответ) и факты («Save не звался для оплаченного»), а последовательность внутренних шагов не фиксируем вообще. Тест, который падает от перестановки вызовов, — это тест о вашей памяти, не о сервисе.

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

Хендлер — самый внешний слой, и проверять его надо как границу: запрос пришёл, ответ ушёл. Поднятие настоящего сервера не нужно: httptest.NewRequest собирает запрос, NewRecorder подставляет записывающий ResponseWriter, и после вызова метода в рекордере лежат статус и тело. Store по-прежнему мок — хендлер-тест заодно показывает, как комплект переиспользует одну зависимость в двух файлах. Форму запрос-рекордер и разбор ответа дал [урок про HTTP-тесты](/courses/testify/lessons/19-http-testy/); здесь она встраивается в общий каркас без изменений. Нюанс комплектов: в хендлер-тесте проверяйте статус и тело, а бизнес-логику не дублируйте — за неё отвечает 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.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 и проверка ошибки отмены.

```go
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)
}
```

Подсказка: Мок — это mock.Mock, сценарий ставится через .Return, а отмена оплаченного обязана вернуть ошибку — ждём require.Error.

Правильные ответы в пропусках: `Mock`, `Return`, `Error`

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

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

Правильный порядок строк:

1. `store := new(MockStore)`
2. `store.On("Get", "o-9").Return(Order{ID: "o-9"}, nil)`
3. `req := httptest.NewRequest(http.MethodGet, "/orders/o-9", nil)`
4. `rec := httptest.NewRecorder()`
5. `h.GetOrder(rec, req)`
6. `store.AssertExpectations(t)`

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

- **Табличный тест** — Срез кейсов с полями name и want плюс t.Run по имени кейса: новая ветка — новая строка таблицы, а не новая копипаста.
- **SetupTest и TearDownTest** — Хуки сьюта: свежий сервис и мок перед каждым тестом, сверка AssertExpectations после каждого. Регистрация — suite.Run(t, new(...)).
- **mock.Mock** — Ручной мок зависимости: On задаёт сценарий, Return — ответ, Times — лимит вызовов, Anything ослабляет сверку аргументов.
- **AssertExpectations** — Сверка плана и факта: несыгравшее On роняет тест. Ловит случай, когда код перестал звать зависимость, а комплект остался зелёным.
- **assert.Eventually** — Ожидание факта вместо sleep: условие опрашивается с фиксированным тиком до истечения дедлайна; в сьюте берёт s.T().
- **httptest** — NewRequest собирает запрос без сети, NewRecorder пишет ответ: после вызова хендлера проверяются rec.Code и rec.Body напрямую.

## Коротко: что запомнить

- Итоговый проект 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 это лишний шаг сборки. Начните руками, а генерацию добавляйте, когда моков станет больше пяти.

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

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