У пакета store десять тестов, и в каждом одни и те же первые строки: поднять клиента, открыть соединение, залить демо-данные. Плоские функции TestXxx(t *testing.T) из первых уроков такого не прощают: общего состояния между функциями нет, и подготовка либо копируется десять раз, либо уползает в глобальные переменные, которые каждый тест чистит руками. Сьют — фигура из арсенала testify ровно под эту боль: одна структура, тесты становятся её методами, общий контекст — её полями. Определение выглядит непривычно, механика спрятана в рефлексию, и вокруг сьютов годами идут споры. Здесь соберём сьют целиком: определение со встраиванием, доступ к проверкам, точку входа suite.Run, подтесты s.Run — и честно поговорим о том, когда сьют скорее мешает. Порядок хуков подготовки, таблицу вызовов и TestMain оставим следующему уроку — хукам сьюта.
Test suite: структура со встраиванием suite.Suite#
Сьют — обычная структура Go, в которую встроен тип suite.Suite из пакета github.com/stretchr/testify/suite: type StoreSuite struct { suite.Suite }. Встраивание — тот же механизм, что у структур из базового курса: методы suite.Suite, включая T, Require и Assert, становятся доступны у StoreSuite как свои. Полей сьютовой структуры никто не ограничивает — сюда складывают общее состояние: соединение с базой, настроенный клиент, каталог с фикстурами. Тесты объявляются методами с префиксом Test: func (s StoreSuite) TestCreate() — без testing.T в сигнатуре, это главное внешнее отличие от плоских функций. Testify собирает такие методы рефлексией: пробегает по типу сьюта, отбирает экспортированные методы с префиксом Test, у которых нет входных параметров и возвращаемых значений, и запускает каждый через t.Run. Метод с другим префиксом или с параметром — не тест: рефлексия его либо не заметит вовсе, либо пометит как сломанную сигнатуру.
Минимальный сьют
GOtype StoreSuite struct {
suite.Suite // встраивание из github.com/stretchr/testify/suite
items map[string]int // общее состояние всех методов
}
func (s *StoreSuite) TestCreate() {
s.items["apple"] = 3
s.Len(s.items, 1)
s.Equal(3, s.items["apple"])
}
func (s *StoreSuite) TestDelete() {
s.items["apple"] = 3
delete(s.items, "apple")
s.Empty(s.items)
}Два метода-теста без единого t: проверки зовутся прямо на сьюте — s.Len, s.Equal и s.Empty достались от встроенного поля *assert.Assertions, которое testify подключает в момент suite.Run. Поле items одно на оба метода, и кто его наполняет и чистит — тема урока про хуки. Полный прогон такого файла требует локального go test: тестовые пакеты браузерная песочница не исполняет.
Параметра t у метода нет, но он доступен: s.T() возвращает текущий *testing.T — пригодится для Deadline, TempDir и всего, что ждёт t напрямую. Проверки живут в трёх местах, и разница между ними та же, что у assert и require из урока про выбор пакета. Прямые вызовы вроде s.Equal или s.NoError — это assert: провал помечает тест, но метод продолжает работать. s.Require() возвращает require-версию: s.Require().NoError(err) при провале останавливает текущий метод через FailNow — то, что нужно в подготовке. s.Assert() — явный способ добраться до assert-контекста; он выручает, когда имя метода на сьюте чем-то занято. Нюанс из исходников: до suite.Run этих полей не существует — Require(), вызванный раньше Run, паникует с сообщением, что сьют ещё не подключён к go test. Правило прежнее: require — для подготовки, assert — для содержательных проверок.
Проверка: сигнатура метода-теста
В сьюте объявлен метод func (s *StoreSuite) TestCreate(t *testing.T). Что случится при прогоне?
Запуск: suite.Run и одна точка входа#
Плоские тесты go test находит сам: каждая функция TestXxx в пакете — отдельный тест. У сьюта точка входа одна: func TestStoreSuite(t *testing.T) { suite.Run(t, new(StoreSuite)) } — обычная тестовая функция, видимая и компилятору, и go test. suite.Run создаёт экземпляр сьюта, подключает к нему t, рефлексией собирает методы-тесты и прогоняет каждый как подтест через t.Run. Для go test весь сьют — один тест верхнего уровня; вывод -v разворачивает вложенность: сначала RUN TestStoreSuite, под ним RUN TestStoreSuite/TestCreate и так по методам. Фильтрация остаётся в силе: go test -run 'TestStoreSuite/TestDelete' запустит сьют, но выполнит только метод TestDelete — фильтр идёт по пути подтеста. У testify есть и собственный флаг -testify.m: регулярное выражение для отбора методов сьюта, аналог -run с сьютовым скоупом.
Точка входа и вывод go test -v
GOfunc TestStoreSuite(t *testing.T) {
suite.Run(t, new(StoreSuite))
}
// $ go test -v
// === RUN TestStoreSuite
// === RUN TestStoreSuite/TestCreate
// === RUN TestStoreSuite/TestDelete
// --- PASS: TestStoreSuite (0.00s)
// --- PASS: TestStoreSuite/TestCreate (0.00s)
// --- PASS: TestStoreSuite/TestDelete (0.00s)Точка входа одна, и только она видна go test как тест верхнего уровня. Каждый метод сьюта suite.Run прогоняет через t.Run, поэтому в -v методы выглядят подтестами с именем TestStoreSuite/TestXxx — а падение любого из них красит общий тест TestStoreSuite.
Сколько тестов видит go test?
Прочитайте код и выберите, что он выведет:
type CalcSuite struct {
suite.Suite
}
func (s *CalcSuite) prepare() {
// общая подготовка методов
}
func (s *CalcSuite) TestAdd() {
s.Equal(4, 2+2)
}
func (s *CalcSuite) TestSub() {
s.Equal(0, 2-2)
}
func TestCalcSuite(t *testing.T) {
suite.Run(t, new(CalcSuite))
}s.Run: подтесты внутри сьюта#
Внутри метода-теста сценарии можно дробить дальше: s.Run(name, func()) — сьютовый аналог t.Run. Замыкание не принимает параметров: текущий t подменяется внутри сьюта, а проверки вроде s.Equal продолжают работать с новым контекстом. Вокруг каждого подтеста testify вызывает пару хуков SetupSubTest и TearDownSubTest, если они объявлены, — расписание этих вызовов разбирает следующий урок. Имена вкладываются в путь: TestParserSuite/TestParse/валидная строка — и фильтр go test -run 'TestParserSuite/TestParse/валидная' доберётся до одного конкретного подтеста. Паника внутри подтеста перехватывается: testify превращает её в провал этого подтеста с трассировкой, остальные подтесты и методы доживают до конца, а после s.Run — suite возвращает прежний t, так что состояние контекста не течёт между подтестами.
Подтесты s.Run в одном методе
GOfunc (s *ParserSuite) TestParse() {
s.Run("валидная строка", func() {
pairs, err := Parse("a=1&b=2")
s.Require().NoError(err)
s.Len(pairs, 2)
})
s.Run("мусор на входе", func() {
_, err := Parse("%%")
s.Error(err)
})
}Оба подтеста живут внутри одного метода TestParse, и Require().NoError останавливает только свой подтест: падение валидного сценария не отменяет сценарий с мусором. Замыкания не принимают t — testify подменяет контекст сам, а имена подтестов попадают в -v и в фильтры -run.
Проверка: require внутри подтеста
В подтесте s.Run("скидка", ...) проверка s.Require().NotNil(total) провалилась. Что произойдёт?
Самый частый аргумент против: методы-тесты собираются рефлексией, и часть тестирования становится неявной. Точка входа одна, порядок методов задаёт алфавит, а не глаз разработчика, хуки прячут подготовку — и тест, который «просто зелёный», требует чтения родительской структуры. Плоские функции честнее: каждая — самодостаточный тест с явным t. Я всё же держу сьюты там, где общий контекст дорогой: соединение, контейнер, тестовый сервер поднимаются один раз на десяток методов, и экономия реальна. Граница простая: чистые функции и перебор кейсов — плоские тесты; модуль с состоянием и дорогой подготовкой — сьют. Спорить о вкусе можно бесконечно, а рабочий критерий один: сколько раз пришлось скопировать подготовку.
Когда сьют вместо плоских функций#
Сьют окупается, когда методам нужен один и тот же дорогой контекст: соединение с базой, запущенный тестовый сервер, каталог с распакованными фикстурами. Такое создаётся один раз, живёт полями структуры, и каждый метод получает готовый мир вместо десяти строк подготовки. Второй случай — группа тестов одного модуля: пакет store с набором операций удобнее читать одним файлом-сьютом, где методы сгруппированы и оформлены одинаково. Третий — постепенный переход: глобальные переменные с ручной очисткой переносятся в поля с хуками, и тесты перестают зависеть от того, в каком порядке их запустили. Не нужен сьют чистым функциям: там табличный кейс в слайсе короче любого метода, а общего контекста нет вовсе. И не спасает сьют от бессистемности: структура без хуков с полями-свалками — те же глобальные переменные, только с методами.
Рабочее правило выбора#
- Подготовка копируется три и больше раз — выносите её в поля сьюта и хуки.
- Контекст дорогой: БД, контейнер, сервер — сьют с общим полем и SetupSuite.
- Чистые функции и перебор кейсов — плоские тесты, сьют не добавит ясности.
- Глобальные переменные в тестах с ручной очисткой — кандидат на поля сьюта.
- Один модуль — один файл-сьют; соседние модули — отдельные сьюты, а не один общий.
История из ревью: метод TestCreate переименовали в prepareCreate, пакет пересобрался, go test ответил ok — и никто не заметил, что сьют больше не содержит тестов. Рефлексия отбирает только экспортированные методы с префиксом Test; всё остальное для неё невидимо. А suite.Run, не нашедший ни одного метода, завершается молча — даже SetupSuite в этом случае не вызовется, библиотека возвращает управление раньше хуков. Лечится гигиеной: после переименований смотреть в вывод go test -v и проверять, что строки подтестов на месте, а не только общий PASS.
Плоские функции и сьют рядом#
| Критерий | Плоские TestXxx | Сьют suite.Suite |
|---|---|---|
| Точка входа | каждая функция — отдельный тест | одна функция с suite.Run, методы — подтесты |
| Подготовка | копия в каждой функции или глобальные переменные | поля структуры и хуки |
| Состояние между тестами | пакетные переменные, ноль гарантий | поля сьюта, порядок задаёт алфавит методов |
| Сигнатура теста | func TestX(t *testing.T) | func (s *Suite) TestX() без параметров |
| Провал проверки | assert и require с t | те же проверки на s, t доступен через s.T() |
| Подтесты | t.Run | s.Run с хуками подтеста |
Соберите каркас сьюта
Допишите встраивание, запуск и require-проверку в методе.
Соберите метод-тест сьюта
Расставьте строки метода: сигнатура, подготовка, первый шаг, проверка, второй шаг, проверка корзины.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: основы сьютов
Что дальше#
Каркас собран: структура со встраиванием, методы без t, одна точка входа, подтесты s.Run. Это половина сьютовой механики — вторая половина в хуках: SetupSuite раз на сьют, SetupTest на каждый метод, SetupSubTest на каждый подтест. Без них поля сьюты — просто красивые глобальные переменные: кто их создаёт, кто чистит и в каком порядке это происходит — тема следующего урока, там же таблица хуков и разбор TestMain против SetupSuite. Полный прогон примеров этого урока — локальный go test: сборка пакета с -v покажет ровно ту картину подтестов, что в примере выше. Если прямые вызовы s.Equal и require-вызовы s.Require().NoError ещё путаются, перечитайте правило выбора двух пакетов — на сьюты оно переносится один в один. Карта всех уроков — на странице testify-курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Сьют — структура со встраиванием suite.Suite из github.com/stretchr/testify/suite; тесты — методы с префиксом Test без *testing.T в сигнатуре.
- Проверки доступны прямо на сьюте (s.Equal) — от встроенного *assert.Assertions; s.Require() даёт require-семантику, s.T() — текущий *testing.T.
- Точка входа одна: func TestXxx(t *testing.T) { suite.Run(t, new(XxxSuite)) }; методы выполняются подтестами и раскрываются в go test -v.
- Фильтрация сохраняется: go test -run 'TestXxx/TestMethod' выполняет отдельный метод; у testify есть и флаг -testify.m.
- s.Run — подтест сьюта: замыкание без параметров, вокруг работают SetupSubTest и TearDownSubTest, паника перехватывается.
- Метод с параметром не запустится — testify пометит сломанную сигнатуру; метод без префикса Test рефлексия не находит вовсе.
- Сьют оправдан дорогим общим контекстом и группой тестов одного модуля; чистым функциям ближе плоские тесты.
Домашнее задание
Возьмите пакет с тремя плоскими тестами и одинаковой подготовкой: соберите сьют со встраиванием suite.Suite, перенесите подготовку в поле, добавьте точку входа с suite.Run. Раздробите один метод на два s.Run и прогоните go test -v: убедитесь, что методы видны подтестами и фильтр -run добирается до одного из них.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли объявить метод-тест с параметром t, как у плоской функции?
Метод скомпилируется, но testify откажется его запускать: рефлексия проверяет сигнатуру и помечает методы с параметрами и возвращаемыми значениями ошибкой invalid signature. Внутри методов t не приходит параметром — берите его через s.T(), а проверки зовите на сьюте.
Почему s.Equal есть сразу, а require только через s.Require()?
suite.Suite встраивает *assert.Assertions напрямую — assert-методы видны как свои. require-версию так же встроить нельзя: у обоих пакетов одинаковые имена методов, и встраивание обоих создало бы конфликт. Поэтому testify хранит require в закрытом поле и отдаёт его методом s.Require().
Зачем сьют, если подтесты умеет и t.Run в плоских тестах?
t.Run даёт подтесты, но не общий контекст: подготовка остаётся внутри каждой функции. Сьют добавляет поля структуры для состояния и хуки подготовки: дорогое и общее создаётся один раз, методы получают готовый мир. Для чистых функций разницы почти нет — там плоские тесты проще.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Хуки сьюта: SetupSuite, TearDownTest и компания
- Табличные тесты с testify
- Знакомство с mock.Mock: первый мок
- On и Return: настраиваем поведение заглушек
- msgAndArgs: свои сообщения в проверках
- Проверка паник: Panics и PanicsWithValue
- True, False, Nil, NotNil и Empty: простые проверки
- InDelta и InEpsilon: числа с плавающей точкой
- Все курсы по Go
Следующая ступень учебного пути
Viper: конфигурация приложений на Go
Интерактивный курс Viper для Go: YAML и env-переменные, приоритеты источников, Unmarshal, WatchConfig, профили окружений и секреты. 25 уроков с примерами.
- Собирать конфиг из файлов, окружения и флагов в один реестр
- Читать значения типизированно: Get, GetString, Unmarshal в структуры
- Задавать приоритеты источников: флаг > env > файл > default
- Перечитывать конфиг на лету через WatchConfig и OnConfigChange