1. Введение
Когда тестов мало, самый частый сценарий такой: тест упал, вы открыли файл, глазами пробежались — и всё ясно. Но как только у вас появляется «десяток кейсов на одну функцию», возникает неприятный эффект: тест упал, вы видите сообщение, но оно не всегда мгновенно отвечает на вопрос «какой именно сценарий сломался?». Особенно если сообщение выводится без имени кейса, а только с индексом или входными данными, которые ещё нужно мысленно интерпретировать.
Subtests — это способ превратить один «большой тест с циклом» в дерево тестов: основной тест TestXxx становится контейнером, а каждый кейс запускается как дочерний тест с собственным именем. В отчёте тест‑раннера вы увидите не просто «упал TestXxx», а, например, «упал TestParsePriority/negative» — и это уже почти диагноз.
В Go subtests делаются через метод t.Run(name, func(t *testing.T) { ... }). Важно, что внутрь передаётся новый t, который относится именно к этому кейсу. То есть ошибки, фейлы и сообщения будут привязаны к конкретному под‑тесту, а не к «общей куче».
Проблема без subtests: индекс вместо сценария
Начнём с честного table‑driven теста, но без t.Run. Формально он корректный, и он даже может быть удобным на маленьком количестве кейсов. Но как только кейсов становится много, отчёт становится менее дружелюбным.
// priority_test.go
package todo
import "testing"
func TestParsePriority_Table_NoSubtests(t *testing.T) {
tests := []struct {
in string
want int
wantErr bool
}{
{in: "0", want: 0, wantErr: false},
{in: "5", want: 5, wantErr: false},
{in: "-1", want: 0, wantErr: true},
{in: "x", want: 0, wantErr: true},
}
for i, tt := range tests {
got, err := ParsePriority(tt.in)
if tt.wantErr {
if err == nil {
t.Fatalf("case %d: ParsePriority(%q): expected error, got nil", i, tt.in)
}
continue
}
if err != nil {
t.Fatalf("case %d: ParsePriority(%q): unexpected error: %v", i, tt.in, err)
}
if got != tt.want {
t.Fatalf("case %d: ParsePriority(%q)=%d, want %d", i, tt.in, got, tt.want)
}
}
}
Проблема тут не в логике, а в «UX тестов». Если тест упал, вы увидите case 2, и вам нужно либо помнить, что это за кейс, либо открывать таблицу и искать. Да, мы уже выводим вход tt.in, и это помогает — но всё равно отчёт не говорит «negative» или «invalid number».
2. Мини‑контекст: что тестируем в todo
Чтобы примеры выглядели как развитие одного приложения, договоримся, что у нас есть пакет todo с базовой логикой. Пусть в нём есть две функции: ValidateTitle и ParsePriority. Они простые, но идеально подходят для демонстрации subtests: много сценариев, одинаковая структура проверки.
// title.go
package todo
import "errors"
var ErrEmptyTitle = errors.New("empty title")
func ValidateTitle(s string) error {
if s == "" {
return ErrEmptyTitle
}
return nil
}
// priority.go
package todo
import (
"fmt"
"strconv"
)
func ParsePriority(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("parse priority: %v", err)
}
if n < 0 {
return 0, fmt.Errorf("priority must be >= 0")
}
return n, nil
}
Сейчас нас интересует не «идеальная доменная модель», а тесты: как сделать их читаемыми и удобными для сопровождения.
3. t.Run: subtests, имена и группировка
Теперь сделаем то же самое, но каждый кейс запустим как под‑тест. Для этого добавим полю name и обернём тело кейса в t.Run.
// priority_test.go
package todo
import "testing"
func TestParsePriority_Subtests(t *testing.T) {
tests := []struct {
name string
in string
want int
wantErr bool
}{
{name: "zero", in: "0", want: 0, wantErr: false},
{name: "ok", in: "5", want: 5, wantErr: false},
{name: "negative", in: "-1", want: 0, wantErr: true},
{name: "not_a_number", in: "x", want: 0, wantErr: true},
}
for _, tt := range tests {
tt := tt // фиксируем кейс для замыкания
t.Run(tt.name, func(t *testing.T) {
got, err := ParsePriority(tt.in)
if tt.wantErr {
if err == nil {
t.Fatalf("ParsePriority(%q): expected error, got nil", tt.in)
}
return
}
if err != nil {
t.Fatalf("ParsePriority(%q): unexpected error: %v", tt.in, err)
}
if got != tt.want {
t.Fatalf("ParsePriority(%q)=%d, want %d", tt.in, got, tt.want)
}
})
}
}
Здесь два ключевых плюса:
- Имя сценария. Вы больше не читаете case 2, вы читаете negative.
- Изоляция отчёта. Теперь это отдельные под‑тесты, и сообщения не «слипаются» в одно полотно.
И да, строка tt := tt выглядит как «магический обряд». На самом деле это простая защита от захвата переменной цикла замыканием: t.Run принимает функцию, и эта функция может выполниться позже (в общем случае), а переменная цикла к тому моменту может уже измениться. Поэтому мы создаём копию tt на каждой итерации — и замыкание видит именно её.
Subtests как дерево: когда удобно группировать сценарии
Когда сценариев становится совсем много, бывает полезно сделать под‑уровень: например, отдельно «валидные входы» и отдельно «ошибочные входы». Это не обязательно, но иногда делает картину ещё понятнее: вы видите, что ломается не «что-то», а именно ветка invalid.
// title_test.go
package todo
import "testing"
func TestValidateTitle_Groups(t *testing.T) {
t.Run("valid", func(t *testing.T) {
if err := ValidateTitle("read book"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("invalid", func(t *testing.T) {
if err := ValidateTitle(""); err == nil {
t.Fatalf("expected error, got nil")
}
})
}
Такой стиль особенно приятен, когда тестируемая функция имеет несколько «категорий» поведения. Вы как будто рисуете мини‑документацию: «valid работает так, invalid работает иначе».
Наглядная схема: как тест превращается в дерево
Когда вы впервые видите subtests, мозг может воспринимать это как «цикл внутри теста внутри теста» (и это правда). Помогает держать в голове простую схему: TestXxx — это корень, а t.Run добавляет дочерние узлы.
flowchart TD
A["TestParsePriority"] --> B["subtest: zero"]
A --> C["subtest: ok"]
A --> D["subtest: negative"]
A --> E["subtest: not_a_number"]
В отчёте тест‑раннера вы видите именно эту структуру: родитель и дети.
4. t.Helper(): helper‑функции и читаемые падения
Когда вы пишете много тестов, у вас появляется повторяющийся код: «проверить, что ошибки нет», «проверить, что ошибка есть», «сравнить got и want». Его хочется вынести в функции, иначе тесты превращаются в простыню из одинаковых проверок.
Но как только вы выносите проверку в helper‑функцию, появляется неприятность: если тест упал внутри helper’а, тест‑раннер покажет вам строку внутри helper‑функции, а не строку в тесте, где helper был вызван. В итоге вы прыгаете в helper, потом обратно, и мозг тратит лишние секунды.
Для этого в Go есть t.Helper(). Он помечает функцию как «вспомогательную», и при выводе файла/строки тест‑раннер будет показывать место вызова helper’а, а не внутренности helper‑функции.
Пишем маленькие helper‑функции
Очень частая ошибка новичков: сделать один гигантский helper вида assertEverything(...), который знает слишком много. В Go стиль обычно другой: лучше несколько маленьких helper’ов, каждый из которых делает одну простую проверку.
Начнём с двух классических: «ошибки быть не должно» и «ошибка должна быть».
// helpers_test.go
package todo
import "testing"
func requireNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func requireError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatalf("expected error, got nil")
}
}
Обратите внимание: t.Helper() стоит первой строкой. Это важно: мы заранее говорим тест‑раннеру «не считай эту функцию местом ошибки».
Теперь тесты становятся короче и читаемее.
// title_test.go
package todo
import "testing"
func TestValidateTitle_UsesHelpers(t *testing.T) {
requireNoError(t, ValidateTitle("read book"))
requireError(t, ValidateTitle(""))
}
Комбо: table‑driven + subtests + helpers
Теперь соберём «комбо»: table‑driven кейсы + subtests + helper‑функции. Это один из самых популярных и практичных паттернов тестов в Go, потому что он одновременно даёт компактность, понятный отчёт и минимум шума.
// title_test.go
package todo
import "testing"
func TestValidateTitle_Table_Subtests(t *testing.T) {
tests := []struct {
name string
in string
wantErr bool
}{
{name: "ok", in: "read book", wantErr: false},
{name: "empty", in: "", wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
err := ValidateTitle(tt.in)
if tt.wantErr {
requireError(t, err)
return
}
requireNoError(t, err)
})
}
}
Здесь тест легко расширять: добавили новый кейс — и он автоматически стал отдельным под‑тестом. При этом проверки читаются одинаково в каждом кейсе, и вам не нужно копировать однотипную проверку десять раз.
Поддерживаемые тесты: практический эффект
Когда говорят «поддерживаемые тесты», обычно имеют в виду, что тесты не только ловят баги, но и помогают их чинить. На практике поддерживаемость чаще всего ломается в двух местах:
- вы не можете быстро понять, какой сценарий упал;
- вы не можете быстро понять, где именно тест упал (потому что всё падает «внутри helper’а» или «внутри общей функции»).
Subtests решают первую проблему: сценарий становится именем. Helper‑функции с t.Helper() решают вторую: место падения указывает на строку вызова, а не на внутренности технической функции.
Если сформулировать совсем по‑человечески: t.Run и t.Helper() — это два инструмента, которые делают так, чтобы тесты «общались» с вами понятным языком, а не загадками.
5. Типичные ошибки при работе с t.Run и t.Helper()
Ошибка №1: subtest’ы без осмысленных имён.
Иногда пишут name: "case1", name: "case2". Формально это работает, но смысл subtests теряется: вы снова получаете «индексы», только в профиль. Хорошее имя — это короткое описание сценария: empty, negative, ok, not_a_number. Оно должно помогать понять, что именно сломалось, не открывая код.
Ошибка №2: забыли «зафиксировать» переменную кейса в цикле.
Самый неприятный баг с t.Run в цикле — когда все под‑тесты внезапно работают с одним и тем же tt (обычно с последним). Снаружи это выглядит как мистика: «почему у меня все subtests проверяют одно и то же?». Решение простое: делать tt := tt внутри цикла перед t.Run. Это не магия, это просто защита от захвата переменной замыканием.
Ошибка №3: helper‑функция не вызывает t.Helper().
Вы вынесли проверку в requireNoError, тест упал, и теперь вам показывают строку внутри requireNoError, а не строку теста. Вроде мелочь, но на больших наборах тестов это реально замедляет отладку. t.Helper() как раз существует, чтобы тест‑раннер показывал место вызова helper‑функции.
Ошибка №4: helper‑функции превращаются в «супер‑комбайн».
Когда helper начинает принимать десять параметров и решать за вас, что считать ошибкой, тесты становятся короче, но понимание падает. В Go обычно выигрывает стиль маленьких helper’ов: requireNoError, requireError, assertEqualInt. Тогда тесты остаются «читаемыми как рассказ», а helper’ы не скрывают смысл за слишком умной логикой.
Ошибка №5: t.Error* там, где нужен t.Fatal* (и наоборот) внутри subtests.
Внутри одного subtest’а, если условие критическое (например, ожидали err == nil, а получили ошибку), продолжать проверки часто бессмысленно: дальнейшие got/want уже невалидны. Тогда лучше t.Fatalf. Если же проверки независимы (редко, но бывает), можно использовать t.Errorf и собрать несколько ошибок за один прогон. Главное — чтобы это было осознанно, а не случайно.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ