Команда Go for Devs подготовила перевод статьи о том, как правильно группировать сабтесты в Go. Автор показывает, что в большинстве случаев достаточно держать тесты плоскими, а когда нужна разная инициализация и очистка — добавить лишь один уровень вложенности. В статье разбираются плюсы и минусы разных подходов: от ручных t.Run
до reflection-хаков и сторонних библиотек.
Go поддерживает сабтесты начиная с версии 1.7. С помощью t.Run
можно вкладывать тесты друг в друга, задавать имена кейсам и при необходимости позволять раннеру выполнять работу параллельно, вызывая t.Parallel
внутри сабтестов.
Для небольших наборов тестов обычно хватает простого списка вызовов t.Run
. Именно с этого я обычно и начинаю. Но по мере роста набора требования к подготовке и очистке окружения (setup/teardown) могут потребовать группировки сабтестов. Сделать это можно разными способами.
Один вариант — сгруппировать сабтесты с помощью вложенных t.Run
. Однако поскольку t.Run
поддерживает произвольное количество уровней вложенности, легко получить тесты, которые трудно читать и понимать, особенно если у каждой группы своя логика setup/teardown. Если же добавить вызовы t.Parallel
, становится ещё менее очевидно, какие группы выполняются последовательно, а какие параллельно.
Без примеров это звучит немного абстрактно. Поэтому начнём с самого простого случая группировки сабтестов и постепенно усложним. Подбирать примеры так, чтобы они влезли в статью и при этом были показательными, непросто, так что придётся немного потерпеть мои «игрушечные» примеры и подключить воображение.
Объект тестирования (SUT)
Представим, что мы пишем тесты для калькулятора, который, ради примера, умеет только складывать и умножать. Вместо табличных тестов мы разобьём проверки сложения и умножения на две группы с помощью сабтестов. Допустим, у этих операций почему-то разные сценарии подготовки и очистки окружения.
Да, притянуто за уши, но так проще показать идею без привлечения моков, баз данных или testcontainers и ненужных деталей. В реальных проектах подобное встречается везде, где у вас есть общение с базой, а пути чтения и записи имеют разные жизненные циклы.
Держите тесты плоскими, пока это возможно
Если бы у нас не было разных setup/teardown для двух групп, самый простой способ — табличные тесты:
func TestCalc(t *testing.T) {
// Common setup and teardown
tests := []struct {
name string
got int
want int
}{
{"1+1=2", 1 + 1, 2},
{"2+3=5", 2 + 3, 5},
{"2*2=4", 2 * 2, 4},
{"3*3=9", 3 * 3, 9},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.got != tt.want {
t.Fatalf("got %d, want %d", tt.got, tt.want)
}
})
}
}
Результат выполнения:
--- PASS: TestCalc (0.00s)
--- PASS: TestCalc/1+1=2 (0.00s)
--- PASS: TestCalc/2+3=5 (0.00s)
--- PASS: TestCalc/2*2=4 (0.00s)
--- PASS: TestCalc/3*3=9 (0.00s)
PASS
Если «развернуть» эти тесты, получится эквивалентная запись с явным перечислением:
func TestCalc(t *testing.T) {
// Common setup and teardown
// Addition
t.Run("1+1=2", func(t *testing.T) {
if 1+1 != 2 {
t.Fatal("want 2")
}
})
t.Run("2+3=5", func(t *testing.T) {
if 2+3 != 5 {
t.Fatal("want 5")
}
})
// Multiplication
t.Run("2*2=4", func(t *testing.T) {
if 2*2 != 4 {
t.Fatal("want 4")
}
})
t.Run("3*3=9", func(t *testing.T) {
if 3*3 != 9 {
t.Fatal("want 9")
}
})
}
Все сабтесты находятся на одном уровне. По имени видно, какую функцию калькулятора они проверяют. Но это не даёт нам раздельных жизненных циклов для групп сложения и умножения.
Группировка сабтестов с помощью вложенных t.Run
Чтобы обеспечить разные setup/teardown для сложения и умножения, можно вложить сабтесты в t.Run
:
func TestCalc(t *testing.T) {
// Common setup and teardown
t.Run("addition", func(t *testing.T) {
// addition-specific setup
defer func() {
// addition-specific teardown
}()
t.Run("1+1=2", func(t *testing.T) {
if 1+1 != 2 {
t.Fatal("want 2")
}
})
t.Run("2+3=5", func(t *testing.T) {
if 2+3 != 5 {
t.Fatal("want 5")
}
})
})
t.Run("multiplication", func(t *testing.T) {
// multiplication-specific setup
defer func() {
// multiplication-specific teardown
}()
t.Run("2*2=4", func(t *testing.T) {
if 2*2 != 4 {
t.Fatal("want 4")
}
})
t.Run("3*3=9", func(t *testing.T) {
if 3*3 != 9 {
t.Fatal("want 9")
}
})
})
}
Теперь можно запускать общий setup/teardown на верхнем уровне, а группы иметь свои собственные. При выполнении мы увидим имена групп:
--- PASS: TestCalc (0.00s)
--- PASS: TestCalc/addition (0.00s)
--- PASS: TestCalc/addition/1+1=2 (0.00s)
--- PASS: TestCalc/addition/2+3=5 (0.00s)
--- PASS: TestCalc/multiplication (0.00s)
--- PASS: TestCalc/multiplication/2*2=4 (0.00s)
--- PASS: TestCalc/multiplication/3*3=9 (0.00s)
PASS
Также можно запустить группы параллельно, вызвав t.Parallel()
внутри каждой:
func TestCalc(t *testing.T) {
// Common setup and teardown
t.Run("addition", func(t *testing.T) {
t.Parallel()
})
t.Run("multiplication", func(t *testing.T) {
t.Parallel()
})
}
На практике достаточно держать сабтесты плоскими, а при необходимости добавить всего один уровень вложенности. Когда появляется больше уровней, читаемость резко падает, а при t.Parallel
сложно предсказать порядок выполнения.
Но даже если вы ограничиваетесь двумя уровнями вложенности, длинная логика отдельных тестов может ухудшить читаемость. В большинстве случаев помогает вынесение кода сабтестов в именованные функции.
Вынос групп в отдельные функции
Чтобы улучшить читаемость, группы можно вынести в функции:
func TestCalc(t *testing.T) {
// Common setup and teardown
t.Run("addition", addgroup)
t.Run("multiplication", multgroup)
}
func addgroup(t *testing.T) {
// addition-specific setup
defer func() {
// addition-specific teardown
}()
t.Run("1+1=2", func(t *testing.T) {
if 1+1 != 2 {
t.Fatal("want 2")
}
})
t.Run("2+3=5", func(t *testing.T) {
if 2+3 != 5 {
t.Fatal("want 5")
}
})
}
func multgroup(t *testing.T) {
// multiplication-specific setup
defer func() {
// multiplication-specific teardown
}()
t.Run("2*2=4", func(t *testing.T) {
if 2*2 != 4 {
t.Fatal("want 4")
}
})
t.Run("3*3=9", func(t *testing.T) {
if 3*3 != 9 {
t.Fatal("want 9")
}
})
}
Всё, что мы сделали здесь, — вынесли группы в отдельные функции. В остальном этот тест идентичен предыдущему варианту с двумя уровнями вложенности сабтестов. Вы можете вызывать t.Parallel
прямо из функций подгрупп:
func TestCalc(t *testing.T) {
// Common setup and teardown
// ...
}
func addgroup(t *testing.T) {
// Run the group in parallel
t.Parallel()
}
func multgroup(t *testing.T) {
// Run the group in parallel
t.Parallel()
}
Или можно вызвать t.Parallel
в самой верхнеуровневой функции теста:
func TestCalc(t *testing.T) {
// Common setup and teardown
t.Run("addition", func(t *testing.T) {
t.Parallel()
addgroup(t) // addgroup doesn't have t.Parallel
})
t.Run("multiplication", func(t *testing.T) {
t.Parallel()
multgroup(t) // multgroup doesn't have t.Parallel
})
}
Вот и всё. Но некоторым не нравится та ручная «проводка», которую нам пришлось сделать в верхнеуровневой функции TestCalc
. Кроме того, в большой кодовой базе нужна дисциплина, чтобы остальные, расширяя код, следовали тому же подходу.
Поэтому часто хотят, чтобы группы сабтестов обнаруживались автоматически, без ручного подключения в основной тестовой функции. Я не большой поклонник такой магии, но мне всё же стало интересно. В gRPC-go
есть функция обнаружения групп, которая делает именно это.
Автоматическое обнаружение групп через reflection
Если бы мы писали тесты внутри репозитория grpc-go, мы могли бы воспользоваться его небольшим вспомогательным пакетом internal/grpctest
. Он использует reflection для анализа переданного значения, находит методы, имена которых начинаются с Test
, и запускает каждый из них как под-тест.
Ключевой момент в том, что этот вспомогательный пакет выполняет setup
перед каждым найденным методом теста и teardown
после него. Это обеспечивает чёткое место для действий, связанных с жизненным циклом каждой группы тестов.
Публичный интерфейс у этого пакета минимальный: RunSubTests(t, x)
и стандартный носитель хуков Tester
, который можно встроить, чтобы получить методы Setup
и Teardown
.
Вот как будет выглядеть наш набор тестов калькулятора в этом стиле — как если бы мы добавляли тесты прямо внутри grpc-go:
// NOTE: This import path only works inside the grpc-go repo family.
// External modules cannot import google.golang.org/grpc/internal/*.
package calc
import (
"testing"
"google.golang.org/grpc/internal/grpctest"
)
// CalcSuite: embed grpctest.Tester so we get Setup and Teardown hooks.
// The runner will discover TestAddition and TestMultiplication below.
type CalcSuite struct{ grpctest.Tester }
// TestAddition is discovered because the name starts with "Test".
func (CalcSuite) TestAddition(t *testing.T) {
// addition-specific setup and teardown for this group
defer func() {
// tear down addition fixtures
}()
t.Run("1+1=2", func(t *testing.T) {
if 1+1 != 2 {
t.Fatal("want 2")
}
})
t.Run("2+3=5", func(t *testing.T) {
if 2+3 != 5 {
t.Fatal("want 5")
}
})
}
// A second discovered group.
func (CalcSuite) TestMultiplication(t *testing.T) {
// multiplication-specific setup and teardown for this group
defer func() {
// tear down multiplication fixtures
}()
t.Run("2*2=4", func(t *testing.T) {
if 2*2 != 4 {
t.Fatal("want 4")
}
})
t.Run("3*3=9", func(t *testing.T) {
// call t.Parallel() here if overlapping with other subtests is safe
if 3*3 != 9 {
t.Fatal("want 9")
}
})
}
// Top-level entry that "go test" sees.
// RunSubTests reflects over CalcSuite,
// then runs Setup, the test method, then Teardown.
func TestCalc(t *testing.T) {
grpctest.RunSubTests(t, CalcSuite{})
}
Внешние проекты использовать этот пакет не могут (он в internal/
), но код можно скопировать — он небольшой. Всего несколько десятков строк, без каких-либо зависимостей, кроме проверщика утечек. Вы можете добавить файл в свои тесты, удалить проверку утечек, если она не нужна, подправить пути импортов и начать использовать RunSubTests
. Чтобы избежать повторений, оставлю это как упражнение для читателя.
Ещё важно отметить, что grpctest.RunSubTests
не меняет стандартный планировщик; вы по-прежнему явно включаете параллелизм с помощью t.Parallel()
, когда это безопасно.
Сторонние библиотеки: testify и go-testgroup
Если вам нравится идея автоматического обнаружения подгрупп, но вы хотите использовать что-то вне grpc-go
, есть два распространённых варианта — suite
из библиотеки testify и go-testgroup
от Bloomberg. Обе позволяют организовывать тесты в именованные группы и размещать setup
/teardown
рядом с самими тестами для каждой группы.
Testify: пакет suite
В testify набор тестов моделируется как структура со встроенными методами Test*
. Он предоставляет метод s.Run
для запуска под-тестов и набор вспомогательных функций для проверок (assertions).
package calc
import (
"testing"
"github.com/stretchr/testify/suite"
)
type CalcSuite struct{ suite.Suite }
func (s *CalcSuite) TestAddition() {
s.Run("1+1=2", func() { s.Equal(2, 1+1) })
s.Run("2+3=5", func() { s.Equal(5, 2+3) })
}
func (s *CalcSuite) TestMultiplication() {
s.Run("2*2=4", func() { s.Equal(4, 2*2) })
s.Run("3*3=9", func() { s.Equal(9, 3*3) })
}
func TestCalc(t *testing.T) {
suite.Run(t, new(CalcSuite))
}
Одно из ограничений заключается в том, что suite
из testify не поддерживает использование t.Parallel
для параллельного запуска методов набора тестов (например, TestAddition
, TestMultiplication
). Библиотека go-testgroup от Bloomberg позволяет это делать.
go-testgroup от Bloomberg
Библиотека Bloomberg также группирует тесты по методам, но передаёт в них указатель на *testgroup.T
и предоставляет два варианта запускателя (runner), что позволяет выбрать — выполнять тесты групп последовательно или параллельно.
Заключение
Хотя существует множество способов организовать подгруппы тестов, я стараюсь как можно дольше сохранять структуру «плоской». Когда группировка всё же становится необходимой, я добавляю лишь один дополнительный уровень вложенности с помощью t.Run
.
В более крупных наборах тестов вынесение групп в отдельные именованные функции значительно улучшает читаемость и сопровождаемость кода. Почти никогда не использую подходы, основанные на reflection, — это просто лишний кусок обвязки, который приходится тянуть за собой.
Я также избегаю подключения сторонних тестовых библиотек, если только проект изначально не построен вокруг них. Такие инструменты, как testify или go-testgroup, требуют определять структуру и «прикреплять» к ней тесты. Я предпочитаю держать тесты как самостоятельные функции. Кроме того, фреймворки для тестирования со временем превращаются в мини-языки со своей логикой, что усложняет вход в проект. Обратите внимание, насколько отличаются API testify suite
и go-testgroup
, хотя по сути они делают одно и то же.
По моему опыту, даже в крупных кодовых базах немного дисциплины обычно достаточно, чтобы обходиться ручным группированием сабтестов.
Русскоязычное Go сообщество

Друзья! Эту статью подготовила команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!