Содержание урока · 10 разделов
Две команды в одном проекте. Первая принципиально держится стандартного тестирования go: только testing, ни одной лишней зависимости — и гордо приносит на ревью таблицу кейсов с if !reflect.DeepEqual и копипастой t.Errorf на каждый чих. Вторая поставила testify и за полгода забыла, что под ним есть пакет testing: тесты у неё запускает магия, t.Helper в хелперах не отмечен, а в бенчмарке кто-то пытался проверять результаты assert.Equal внутри цикла. Обе крайности дорого стоят. testify — надстройка над testing, а не замена: движок остаётся тем же, и граница проходит не между библиотеками, а между задачами. Ниже — карта этой границы: где stdlib сильнее, где testify окупается и почему гибрид в одном файле — норма, а не компромисс.
Testify vs testing: кто за что отвечает#
Снимем налёт магии. Файлы с тестами компилирует и запускает go test из стандартного пакета testing — testify в этом не участвует: он находит функции TestXxx(t testing.T), строит дерево сабтестов через t.Run — ровно так устроены [табличные тесты](/courses/testify/lessons/13-tablichnye-testy/) — и фильтрует их флагом -run по имени, с наклонной чертой для вложенности. t.Helper помечает функцию помощником, чтобы при провале в отчёте стояла строка места вызова, а не внутренности хелпера. Ассерты testify принимают первым аргументом TestingT — интерфейс самой testify с единственным методом Errorf, и testing.T ему послушно удовлетворяет. На этом роль testify кончается: он не умеет запускать тесты, не знает про кэш, профили и -race, отчёт собирает не он. Зато он экономит то, ради чего его берут, — строки проверок.
Бенчмарки: b.N вместо ассертов#
Первый район, где testify не нужен вообще: бенчмарки. Функция BenchmarkXxx(b testing.B) обязана гонять целевой код b.N раз, а само число b.N подбирает движок: начинает с одной итерации и увеличивает от прогона к прогону, пока замер не станет надёжным. Результат бенчмарка — наносекунды на операцию, аллокации, пропускная способность; проверять там нечего, кроме того, что код не паникует. Вставить assert технически можно — testing.B тоже имеет Errorf и формально подходит под TestingT, — но каждая проверка попадёт внутрь замера и соврёт в отчёте. Провал в бенчмарке честнее выражать b.Fatal: упал, зафиксировал, замер не состоялся. Запускается всё это не testify, а go test -bench=. с -benchmem для аллокаций — та же стандартная машина, никаких новых зависимостей.
BenchmarkParse: цикл ровно по b.N
GOfunc BenchmarkParse(b *testing.B) {
b.ResetTimer() // подготовка ниже не попадает в замер
data := loadSample()
for i := 0; i < b.N; i++ {
_, err := Parse(data)
if err != nil {
b.Fatal(err)
}
}
}Цикл обязан крутиться ровно b.N раз — число подставит движок, закладываться на конкретное значение нельзя. ResetTimer отсекает подготовку: loadSample и чтение файла не должны попадать в замер. Ошибка — b.Fatal: замер прекращается с осмысленным сообщением, никаких ассертов внутри горячего цикла. Это тестовый пакет: запускается локальным go test -bench=., песочница замеры не исполняет.
Проверка: кто выбирает b.N
Что подставит движок вместо b.N в бенчмарке?
Фаззинг: FuzzXxx и ловушка новичка#
Фаззинг живёт в стандартном тестировании go с версии 1.18: функция FuzzXxx(f testing.F), f.Add сеет стартовые входы, а f.Fuzz принимает цель вида func(t testing.T, in []byte). Дальше движок генерирует входы сам и валит цель, пока не найдёт провал. Ловушка новичка — тащить сюда привычный паттерн testify «ожидаю конкретный результат»: у фаззинга нет фиксированного want, каждый вход свой, и оракул приходится выводить из самого входа — инварианты, симметрии, отсутствие паники. Assert внутри цели компилируется: t там настоящий *testing.T. Но провал assert.Equal на сгенерированном входе — это простыня diff, по которой ничего не понять: эталона-то не было. Найденный контрпример движок сохраняет в testdata/fuzz/FuzzXxx/, и с тех пор он гоняется обычным go test как регрессия.
Фазз-цель с оракулом-инвариантом
GOfunc FuzzDecode(f *testing.F) {
f.Add([]byte("v1:42")) // сеем осмысленные входы
f.Fuzz(func(t *testing.T, in []byte) {
v, err := Decode(in)
if err == nil && v.ID < 0 {
t.Errorf("Decode вернул отрицательный ID: %d", v.ID)
}
})
}Оракул — инвариант: если вход декодировался, идентификатор не может быть отрицательным. Одна строка stdlib выражает свойство точнее, чем сравнение с эталоном, которого у фаззинга нет. Сеялка f.Add даёт движку осмысленный старт, а найденный контрпример он сам спустит в тестдату — и тот станет вечным регрессионным кейсом.
Проверка: провал фазз-цели
Assert внутри фазз-цели провалился. Что сделает движок?
t.Cleanup и t.Parallel: что умеет testing сам#
Второй район — жизненный цикл теста, где testify просто не участвует. t.Cleanup регистрирует функцию, которая отработает по завершении теста и всех его сабтестов, причём в порядке LIFO: последний зарегистрированный — первый на выходе. Это удобнее defer, когда ресурс открывает функция-помощник: она же вешает очистку, а вызывающий ничего не обязан помнить. t.Parallel помечает сабтест параллельным: такие тесты приостанавливаются, дожидаются конца последовательных и возобновляются уже вместе, каждый в своей горутине. Какие гонки это порождает — тема урока про горутины и живого места ниже. testify на оба механизма не влияет: cleanup и параллельность работают в любом проекте, включая проект без единой внешней зависимости.
Что напечатает тест?
Прочитайте код и выберите, что он выведет:
func TestOrder(t *testing.T) {
t.Cleanup(func() { fmt.Println("первый") })
t.Cleanup(func() { fmt.Println("второй") })
fmt.Println("тело")
}Гибрид: t.Run, t.Cleanup и assert.Equal
GOfunc TestUserRepo(t *testing.T) {
db := openTestDB(t)
t.Cleanup(func() { db.Close() })
cases := []struct {
id int
name string
}{
{1, "anna"},
{2, "boris"},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("user-%d", tc.id), func(t *testing.T) {
t.Parallel()
u, err := db.UserByID(tc.id)
require.NoError(t, err)
assert.Equal(t, tc.name, u.Name)
})
}
}Скелет — чистый testing: t.Run строит сабтесты, t.Cleanup закрывает базу по завершении теста целиком, вместе со всеми параллельными кейсами. Проверки — testify: require рвёт кейс при ошибке запроса, assert.Equal даёт читаемый diff по имени. Два слоя спокойно живут в одном файле. Прогон — локальный go test: песочница базу и горутины не исполняет.
Задача — инструмент#
| Задача | Инструмент | Почему |
|---|---|---|
| Проверка значений и ошибок | assert / require (testify) | Читаемо, diff в отчёте, минимум копипасты |
| Табличные кейсы и фильтрация | t.Run и флаг -run (testing) | Дерево сабтестов — зона stdlib |
| Замер производительности | testing.B, b.N, -benchmem | Ассерты попали бы в замер и соврали |
| Генеративный поиск багов | FuzzXxx и свой оракул | Фиксированного want нет — diff Equal бесполезен |
| Очистка ресурсов | t.Cleanup / defer (testing) | testify на жизненный цикл не влияет |
| Ускорение независимых кейсов | t.Parallel (testing) | Горутины и гонки — не территория testify |
| Моки и общий сетап | mock.Mock и suite.Run (testify) | Здесь библиотека реально экономит код |
Где testify окупается: diff, моки, suite#
Там, где проверок много, testify платит за себя строками и отчётом. assert.Equal вместо связки if !reflect.DeepEqual плюс t.Errorf — одна строка вместо четырёх, а в отчёте при провале появляется diff: testify печатает ожидаемое и фактическое построчно, пока stdlib отдаст два слепка структуры подряд. Ошибки сравниваются ErrorsIs и ErrorAs — механика ранних уроков курса. Дальше идёт то, чего в stdlib нет вовсе: моки на mock.Mock с ожиданиями и сверкой вызовов и suite с общим SetupTest на группу тестов — это разобрано в уроке про suite. Стандартное тестирование go закрывает запуск, жизненный цикл и замеры; testify закрывает формулировку проверок и подмену зависимостей. Ссорить их — ошибка планирования, а не вкуса.
История с ревью: табличный тест ускорили t.Parallel, а результаты решили собирать в общий срез — каждый сабтест делал append в одну переменную, проверка шла после цикла. На ноутбуке зелёный, через неделю -race в пайплайне краснит: WARNING: DATA RACE на append. Горутины сабтестов растят один заголовок среза вразнобой — классическая гонка записи, а не логики. Чинится по-разному: assert внутри каждого сабтеста, канал результатов, мьютекс. Механика — из урока про горутины; почему гонку увидел именно CI, а не рабочая машина, — следующий урок, там же и рецепт с -race.
Коллега-минималист утверждает: без зависимостей тесты живут дольше, и для маленькой библиотеки со стабами это правда — десяток проверок, пара if с t.Errorf, зависимость не окупается. Граница, за которой я ставлю testify: сервис, а не библиотека; десятки тестов со структурами и ошибками; моки внешних систем; команда, которая читает diff в отчётах CI чаще, чем код. Между крайностями — гибрид из этого урока: жизненный цикл и сабтесты всегда testing, формулировки проверок — testify. Писать собственную обёртку над t.Errorf ради принципа, когда есть assert.Equal, — тоже зависимость, только своя и без документации.
Правила гибрида#
- go test, t.Run, t.Helper, t.Cleanup, t.Parallel — всегда stdlib: testify их не заменяет.
- Бенчмарки — testing.B без ассертов: проверки попали бы в замер; провал — через b.Fatal.
- Фаззингу нужен свой оракул-инвариант: фиксированного want у сгенерированного входа нет.
- Провал фазз-цели останавливает поиск, а вход уезжает в testdata/fuzz как регрессия.
- Проверки значений, diff в отчёте, моки и suite — территория testify.
- Гибрид в одном файле — норма: скелет testing, формулировки testify.
Соберите бенчмарк и фазз-цель
Допишите: чьё поле N в бенчмарке, какой тип у конструктора фаззинга и какой ассерт проверит коммутативность суммы.
Соберите тест с очисткой
Расставьте строки теста: хелпер, очистка через t.Cleanup и две проверки testify.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: границы testify и testing
Граница проведена. Стандартное тестирование go отвечает за запуск, дерево сабтестов, очистку, параллельность, замеры и генеративный поиск багов; testify — за читаемость проверок, diff в отчёте, моки и suite. Спорить о замене бессмысленно, выбирать одного — тоже: в одном файле спокойно соседствуют t.Cleanup и assert.Equal, и это не компромисс, а разделение труда. Дальше по курсу — тесты в CI: там t.Parallel из этого урока встречается с -race, а вывод assert уезжает в пайплайн. Полная карта уроков — на странице testify-курса. Домашняя проверка на пять минут: найдите у себя бенчмарк или фазз-цель и убедитесь, что ассерт туда не забрался.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- testify — надстройка над testing: go test, t.Run, t.Helper остаются стандартными.
- Бенчмарки — территория testing.B: цикл по b.N, провал через b.Fatal, ассерты в замер не тащим.
- Фаззингу нужен свой оракул-инвариант: фиксированного want у сгенерированного входа нет.
- Провал фазз-цели сохраняет вход в testdata/fuzz — дальше он гоняется обычным go test.
- t.Cleanup держит LIFO-стек очистки, t.Parallel возобновляет сабтесты вместе, каждый в своей горутине.
- Testify окупается на проверках: diff в отчёте, ErrorsIs, моки и suite.
- Гибрид в одном файле — норма: скелет testing, формулировки testify.
Домашнее задание
Найдите в проекте тест с if !reflect.DeepEqual и перепишите его на assert.Equal, сравнив отчёты о провале до и после. Один параллельный сабтест добавьте через t.Parallel, а ресурс в хелпере закройте через t.Cleanup. Затем запустите go test -bench=. и посмотрите, сколько итераций движок подобрал сам.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли вызывать assert с *testing.B в бенчмарке?
Технически да: *testing.B имеет Errorf и удовлетворяет интерфейсу TestingT, поэтому код скомпилируется. Но каждая проверка внутри цикла попадает в замер и искажает результат, а провал через assert не останавливает бенчмарк. Идиоматично — b.Fatal или b.Error: замер прекращается честно, отчёт остаётся читаемым.
Сработает ли t.Parallel внутри suite testify?
Suite построен поверх обычных методов testing: t в методах сюиты — настоящий *testing.T, и t.Parallel формально доступен. Но Suite.Run управляет выполнением через свои хуки и параллельность методов не разруливает, так что выгода обычно иллюзорна. Параллельте сабтесты через t.Run с t.Parallel, а suite оставьте для общего сетапа.
Можно ли писать assert внутри фазз-цели?
Компилируется: цель получает настоящий *testing.T, и провал ассерта честно роняет выполнение входа. Но привычного сценария «want против got» здесь нет: у сгенерированного входа не существует эталона, а Equal на большом входе даст нечитаемую простыню. Оракул для фаззинга — инвариант или свойство; часто хватает чистой проверки средствами stdlib.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Viper: конфигурация приложений на Go
Интерактивный курс Viper для Go: YAML и env-переменные, приоритеты источников, Unmarshal, WatchConfig, профили окружений и секреты. 25 уроков с примерами.
- Собирать конфиг из файлов, окружения и флагов в один реестр
- Читать значения типизированно: Get, GetString, Unmarshal в структуры
- Задавать приоритеты источников: флаг > env > файл > default
- Перечитывать конфиг на лету через WatchConfig и OnConfigChange