JavaRush /Курсы /Go SELF /Subtests t.Run и

Subtests t.Run и t.Helper: поддерживаемые тесты

Go SELF
29 уровень , 2 лекция
Открыта

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 и собрать несколько ошибок за один прогон. Главное — чтобы это было осознанно, а не случайно.

1
Задача
Go SELF, 29 уровень, 2 лекция
Недоступна
Знак числа
Знак числа
1
Задача
Go SELF, 29 уровень, 2 лекция
Недоступна
Скидочный купон
Скидочный купон
1
Задача
Go SELF, 29 уровень, 2 лекция
Недоступна
Порт сервера
Порт сервера
1
Задача
Go SELF, 29 уровень, 2 лекция
Недоступна
Разбор ФИО
Разбор ФИО
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ