5. Subtests и t.Run
В предыдущей главе 4. Table driven tests мы увидели, насколько выразительно и компактно можно формулировать десятки тестовых сценариев в рамках единого цикла. Однако настоящий архитектурный прорыв в управляемости и читаемости тестов произошел с появлением метода t.Run().
До исторического релиза Go 1.7 для группировки тестов, создания иерархий Setup/Teardown и выборочного прогона упавших кейсов разработчикам приходилось тащить в проект сторонние надстройки вроде testify/suite или библиотеки gocheck. С добавлением нативного метода t.Run стандартный пакет testing получил первоклассную поддержку иерархических подтестов (Subtests), навсегда изменив культуру тестирования в экосистеме Go.
Что такое t.Run?
Метод t.Run связывает человекочитаемое имя сценария с изолированным блоком кода. Он принимает два аргумента: строку с названием подтеста и функцию-замыкание с канонической сигнатурой func(t *testing.T):
package processor_test
import (
"testing"
)
func TestProcessor(t *testing.T) {
// Общая инициализация инфраструктуры для группы подтестов
db := setupDatabase()
t.Cleanup(func() { db.Close() })
t.Run("valid input", func(t *testing.T) {
// Вложенный контекст: параметр t — это НОВЫЙ изолированный указатель на testing.T!
err := process(db, "good_data")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
t.Run("invalid input", func(t *testing.T) {
err := process(db, "bad_data")
if err == nil {
t.Errorf("expected error, got nil")
}
})
}
Главные преимущества паттерна:
- Изоляция сбоев: Если сценарий
"valid input"упадет с паникойpanic()или прервется вызовомt.Fatal(), следующий подтест"invalid input"всё равно будет гарантированно выполнен. - Иерархия имен: Имя каждого подтеста компонуется через слэш с именем родительской тестовой функции. В диагностических отчетах терминала это отображается в виде кристально понятного пути:
TestProcessor/valid_input. - Гранулярный контроль из CLI: Вы можете запускать изолированные сценарии или целые группы подтестов прямо из консоли, не тратя время на повторный прогон всего тяжелого сьюта.
sequenceDiagram
autonumber
participant Main as "Родительская горутина (TestProcessor)"
participant Sub1 as "Подтест 1 (valid input)"
participant Sub2 as "Подтест 2 (invalid input)"
Main->>Main: Инициализация общих ресурсов (setupDatabase)
Main->>Sub1: t.Run("valid input") -> запуск tRunner
Note over Sub1: Исполнение проверок
Sub1-->>Sub1: panic("nil pointer")!
Note over Sub1: tRunner ловит defer recover() -> child_t.Fail()
Sub1->>Main: Возврат управления (Статус FAIL)
Main->>Sub2: t.Run("invalid input") -> запуск tRunner
Note over Sub2: Успешное выполнение проверок
Sub2->>Main: Возврат управления (Статус PASS)
Main->>Main: Выполнение t.Cleanup() -> db.Close()
Выборочный запуск тестов (CLI)
Возможность выборочного запуска тестов через флаг -run — ключевой инструмент продуктивности бэкенд-инженера.
Представьте тяжелый интеграционный тест TestAPI, содержащий 50 подтестов для CRUD-операций с базой данных и занимающий 15 секунд. Если один из сценариев упал при разработке новой фичи, ждать полного прогона всех 50 тестов на каждой итерации отладки неэффективно.
Флаг командной строки -run принимает регулярное выражение (regexp), которое сопоставляется с полным составным именем теста:
go test -run="regexp"
Практические сценарии фильтрации:
-
Запуск строго одного подтеста:
При фильтрации пробелы в именах подтестов заменяются на подчеркивания:Bash # Запуск ровно одного сценария внутри TestAPI: go test -v -run "^TestAPI/invalid_input$" -
Запуск группы подтестов по общему префиксу:
Применяя структурированные имена (например,create_success,create_fail,delete_success), вы можете отфильтровать семейство операций:Bash go test -v -run "TestAPI/create_.*" -
Многоуровневая иерархическая фильтрация:
Подтестыt.Runможно вкладывать друг в друга на любую глубину (Test/Group/Case). Вы можете запускать целые логические ветви дерева:Bash # Запуск всех тестов валидации внутри сценария TestUsers: go test -v -run "TestUsers/validation/"
Иерархический Setup и Teardown
Метод t.Run позволяет формировать элегантные деревья инициализации ресурсов, где каждый уровень абстракции подготавливает строго свое окружение:
func TestSystem(t *testing.T) {
// Уровень 1: Поднимаем тяжелый контейнер PostgreSQL (1 раз на весь сьют)
db := startDatabase(t)
t.Run("users logic", func(t *testing.T) {
// Уровень 2: Накатываем схемы и таблицы пользователей
setupUserTables(t, db)
t.Run("create user", func(t *testing.T) {
// Уровень 3: Точечная проверка бизнес-логики создания
createUser(t, db)
})
})
t.Run("payments logic", func(t *testing.T) {
// Уровень 2: Полностью изолированная ветка биллинга
setupPaymentTables(t, db)
// ...
})
}
Итог
t.Runпревращает плоские сценарии в гранулярные иерархические деревья подтестов (Subtests).- Любая паника внутри подтеста изолируется механизмом
tRunnerиdefer recover(), не прерывая выполнение соседних проверок. - Полные пути тестов имеют канонический формат
TestName/subtest_name, поддерживая гибкую regex-фильтрацию через флаг командной строки-run. - В связке с
t.Cleanup, подтесты формируют надежный каркас для управления зависимостями интеграционных стендов.
До сих пор мы рассматривали синхронное исполнение подтестов. Но настоящее ускорение тестов в Go достигается параллелизмом. Давайте перейдем к глубокому исследованию многопоточного выполнения проверок в следующей главе: 6. Параллельные тесты t.Parallel.