Сьюты в Go-автотестах: зачем нужна явная граница выполнения тестов
Вступление
Когда автотесты на Go выходят за рамки простых unit-тестов, почти сразу появляется потребность группировать связанные проверки.
Например, есть набор тестов для пользователей:
- создание пользователя;
- логин;
- получение пользователя по ID;
- блокировка пользователя;
- проверка прав доступа.
Логически это одна группа тестов. У неё может быть общий клиент, общий набор метаданных, общий lifecycle, общие хуки, общий setup и teardown.
В других тестовых фреймворках для этого обычно есть suite. В Go стандартный пакет testing остаётся минималистичным: есть TestXxx, есть t.Run, есть t.Cleanup, есть defer. Но понятия suite как явной границы выполнения группы тестов нет.
На маленьких тестах это не мешает. Но как только появляются интеграционные сценарии, общие ресурсы, отчёты, хуки и lifecycle уровня BeforeAll / AfterAll, отсутствие такой границы начинает создавать проблемы.
В этой статье разберёмся, зачем нужны сьюты в Go-автотестах и как они работают в Axiom.
Как обычно пишут без сьютов
Представим, что у нас есть общий Runner, к которому подключены хуки.
Мы хотим один раз выполнить подготовку перед группой тестов и один раз выполнить финализацию после всех тестов.
Например:
package users_test
import (
"fmt"
"testing"
"github.com/Nikita-Filonov/axiom"
)
var runner = axiom.NewRunner(
axiom.WithRunnerHooks(
axiom.WithBeforeAll(func(r *axiom.Runner) {
fmt.Println("before all: start users tests")
}),
axiom.WithAfterAll(func(r *axiom.Runner) {
fmt.Println("after all: finish users tests")
}),
axiom.WithBeforeTest(func(cfg *axiom.Config) {
fmt.Println("before test:", cfg.Case.Name)
}),
axiom.WithAfterTest(func(cfg *axiom.Config) {
fmt.Println("after test:", cfg.Case.Name)
}),
),
)
func TestUserCanBeCreated(t *testing.T) {
c := axiom.NewCase(
axiom.WithCaseName("user can be created"),
)
runner.RunCase(t, c, func(cfg *axiom.Config) {
cfg.Step("create user", func() {
fmt.Println("create user")
})
cfg.Step("validate response", func() {
fmt.Println("validate response")
})
})
}
func TestUserCanBeBlocked(t *testing.T) {
c := axiom.NewCase(
axiom.WithCaseName("user can be blocked"),
)
runner.RunCase(t, c, func(cfg *axiom.Config) {
cfg.Step("create user", func() {
fmt.Println("create user")
})
cfg.Step("block user", func() {
fmt.Println("block user")
})
})
}
На первый взгляд всё выглядит нормально.
Есть два обычных Go-теста. Оба используют один Runner. Внутри каждого теста создаётся Case, а дальше выполняется обычный сценарий через runner.RunCase.
Но здесь есть важная проблема: в терминах стандартного
testingэто два независимых top-level теста. У них нет общей явной границы.
Где возникает проблема
Если говорить совсем просто, BeforeAll и AfterAll требуют ответа на два вопроса:
- когда началась вся группа тестов;
- когда закончилась вся группа тестов.
С отдельными TestXxx честно ответить на эти вопросы сложно.
Каждый TestXxx живёт сам по себе. runner.RunCase(t, ...) может запустить runner-level lifecycle и зарегистрировать cleanup через t.Cleanup. Но этот cleanup привязан к конкретному *testing.T, то есть к конкретной функции TestXxx.
В результате AfterAll может оказаться не настоящим “после всех тестов группы”, а “после завершения того top-level теста, который первым запустил runner”.
Для простых сценариев это может быть незаметно. Но как только появляются общие ресурсы, отчёты, статистика или teardown окружения, поведение становится не тем, которое ожидается от suite-level lifecycle.
Проблема не в хуках как таковых. Проблема в отсутствии явной границы выполнения. Именно эту границу и добавляет Suite.
Что такое Suite в Axiom
Suite в Axiom — это опциональная execution boundary для группы связанных тестов.
Важно: suite не заменяет
Runner,Caseи стандартный Gotesting.
Модель остаётся такой:
Runner = общая конфигурация выполнения
Suite = граница группы связанных тестов
Case = конкретный тестовый сценарий
Config = runtime-состояние одного выполнения Case
Runner по-прежнему отвечает за execution model:
- хуки;
- фикстуры;
- ресурсы;
- метаданные;
- контекст;
- retry;
- плагины;
- runtime wrappers;
- отчётность.
Suite отвечает только за одно: где группа тестов начинается и где заканчивается.
При этом API Axiom намеренно сделан явным. Сьют не сканирует методы по имени. Он не ищет методы с префиксом Test. Он не пытается угадать, что именно нужно запустить. Вы сами создаёте suite, явно регистрируете тесты и явно запускаете выполнение:
suite := axiom.NewSuite(t, new(UsersSuite), axiom.WithSuiteConfigRunner(runner))
suite.Test("user can be created", (*UsersSuite).UserCanBeCreated)
suite.Test("user can be blocked", (*UsersSuite).UserCanBeBlocked)
suite.Run()
Это и есть вся модель:
NewSuite -> Test -> Test -> Run
Без магии.
Пример со сьютом
Перепишем пример выше через Suite.
Сначала оставим общий Runner:
package users_test
import (
"fmt"
"testing"
"github.com/Nikita-Filonov/axiom"
)
var runner = axiom.NewRunner(
axiom.WithRunnerHooks(
axiom.WithBeforeAll(func(r *axiom.Runner) {
fmt.Println("before all: start users suite")
}),
axiom.WithAfterAll(func(r *axiom.Runner) {
fmt.Println("after all: finish users suite")
}),
axiom.WithBeforeTest(func(cfg *axiom.Config) {
fmt.Println("before test:", cfg.Case.Name)
}),
axiom.WithAfterTest(func(cfg *axiom.Config) {
fmt.Println("after test:", cfg.Case.Name)
}),
axiom.WithBeforeStep(func(cfg *axiom.Config, name string) {
fmt.Println("before step:", name)
}),
axiom.WithAfterStep(func(cfg *axiom.Config, name string) {
fmt.Println("after step:", name)
}),
),
)
Теперь описываем сам suite.
Для этого создаём структуру и встраиваем в неё axiom.Suite:
type UsersSuite struct {
axiom.Suite
}
После этого создаём единую точку входа для Go testing:
func TestUsersSuite(t *testing.T) {
suite := axiom.NewSuite(t, new(UsersSuite), axiom.WithSuiteConfigRunner(runner))
suite.Test("user can be created", (*UsersSuite).UserCanBeCreated)
suite.Test("user can be blocked", (*UsersSuite).UserCanBeBlocked)
suite.Run()
}
Здесь важно несколько вещей.
go test видит только обычный top-level тест:
func TestUsersSuite(t *testing.T) {}
Axiom не ищет методы внутри UsersSuite.
Метод становится suite-тестом только после явной регистрации:
suite.Test("user can be created", (*UsersSuite).UserCanBeCreated)
Поэтому методы внутри suite не обязаны начинаться с Test:
func (s *UsersSuite) UserCanBeCreated() {}
func (s *UsersSuite) UserCanBeBlocked() {}
Теперь описываем сами сценарии:
func (s *UsersSuite) UserCanBeCreated() {
c := axiom.NewCase(
axiom.WithCaseName("user can be created"),
)
s.RunCase(c, func(cfg *axiom.Config) {
cfg.Step("create user", func() {
fmt.Println("create user")
})
cfg.Step("validate response", func() {
fmt.Println("validate response")
})
})
}
func (s *UsersSuite) UserCanBeBlocked() {
c := axiom.NewCase(
axiom.WithCaseName("user can be blocked"),
)
s.RunCase(c, func(cfg *axiom.Config) {
cfg.Step("create user", func() {
fmt.Println("create user")
})
cfg.Step("block user", func() {
fmt.Println("block user")
})
})
}
Внутри методов всё остаётся обычным Axiom-кейсом.
- Создали
Case. - Запустили его через
s.RunCase. - Описали шаги через
cfg.Step.
Разница только в том, что теперь эти кейсы выполняются внутри общей suite boundary.
Как это выполняется
Структура выполнения будет примерно такой:
TestUsersSuite
user can be created
user can be created
user can be blocked
user can be blocked
- Первый уровень — это suite-тест, зарегистрированный через
suite.Test. - Второй уровень — это Axiom
Case, который запускается черезs.RunCase.
Lifecycle выглядит так:
NewSuite
BeforeAll
suite test: user can be created
BeforeTest
BeforeStep
AfterStep
BeforeStep
AfterStep
AfterTest
suite test: user can be blocked
BeforeTest
BeforeStep
AfterStep
BeforeStep
AfterStep
AfterTest
AfterAll
Теперь BeforeAll действительно выполняется перед всей группой зарегистрированных suite-тестов, а AfterAll — после завершения всей группы. Именно это и является главной задачей Suite.
Полный пример
Соберём всё вместе:
package users_test
import (
"fmt"
"testing"
"github.com/Nikita-Filonov/axiom"
)
var runner = axiom.NewRunner(
axiom.WithRunnerHooks(
axiom.WithBeforeAll(func(r *axiom.Runner) {
fmt.Println("before all: start users suite")
}),
axiom.WithAfterAll(func(r *axiom.Runner) {
fmt.Println("after all: finish users suite")
}),
axiom.WithBeforeTest(func(cfg *axiom.Config) {
fmt.Println("before test:", cfg.Case.Name)
}),
axiom.WithAfterTest(func(cfg *axiom.Config) {
fmt.Println("after test:", cfg.Case.Name)
}),
axiom.WithBeforeStep(func(cfg *axiom.Config, name string) {
fmt.Println("before step:", name)
}),
axiom.WithAfterStep(func(cfg *axiom.Config, name string) {
fmt.Println("after step:", name)
}),
),
)
type UsersSuite struct {
axiom.Suite
}
func TestUsersSuite(t *testing.T) {
suite := axiom.NewSuite(t, new(UsersSuite), axiom.WithSuiteConfigRunner(runner))
suite.Test("user can be created", (*UsersSuite).UserCanBeCreated)
suite.Test("user can be blocked", (*UsersSuite).UserCanBeBlocked)
suite.Run()
}
func (s *UsersSuite) UserCanBeCreated() {
c := axiom.NewCase(
axiom.WithCaseName("user can be created"),
)
s.RunCase(c, func(cfg *axiom.Config) {
cfg.Step("create user", func() {
fmt.Println("create user")
})
cfg.Step("validate response", func() {
fmt.Println("validate response")
})
})
}
func (s *UsersSuite) UserCanBeBlocked() {
c := axiom.NewCase(
axiom.WithCaseName("user can be blocked"),
)
s.RunCase(c, func(cfg *axiom.Config) {
cfg.Step("create user", func() {
fmt.Println("create user")
})
cfg.Step("block user", func() {
fmt.Println("block user")
})
})
}
При запуске порядок будет примерно таким:
before all: start users suite
before test: user can be created
before step: create user
create user
after step: create user
before step: validate response
validate response
after step: validate response
after test: user can be created
before test: user can be blocked
before step: create user
create user
after step: create user
before step: block user
block user
after step: block user
after test: user can be blocked
after all: finish users suite
Здесь важно не само логирование. Важно место, где оно выполняется. Suite-методы не вызывают BeforeAll, AfterAll, BeforeTest, AfterTest, BeforeStep и AfterStep вручную. Они описывают только сценарий.
Lifecycle живёт отдельно — на уровне Runner и Suite.
Почему регистрация тестов явная
Во многих suite-подходах фреймворк сам ищет методы по соглашению об именовании. Например, берёт все методы, которые начинаются с Test, проверяет их сигнатуру и запускает как тесты.
Это удобно, но у такого подхода есть цена. Появляются неявные правила:
- как должен называться метод;
- какая у него должна быть сигнатура;
- что будет с helper-методами;
- что будет с методами, которые случайно попали под шаблон;
- в каком порядке они будут выполняться.
Axiom идёт другим путём. Метод сам по себе ничего не значит. Он может называться как угодно:
func (s *UsersSuite) UserCanBeCreated() {}
Исполняемым suite-тестом он становится только после явной регистрации:
suite.Test("user can be created", (*UsersSuite).UserCanBeCreated)
В точке входа сразу видно:
- какой suite запускается;
- какой runner используется;
- какие тесты входят в группу;
- в каком порядке они зарегистрированы;
- где начинается выполнение.
Это обычный Go-код. Его легко читать, легко искать и легко ревьюить.
Когда использовать Suite
Suite имеет смысл использовать, когда несколько тестов логически относятся к одной группе.
Например:
- тесты пользователей;
- тесты платежей;
- тесты документов;
- тесты конкретного API-сервиса;
- набор E2E-сценариев вокруг одной фичи;
- группа тестов с общими runner-scoped ресурсами;
- группа тестов, где нужен честный
BeforeAll / AfterAll.
При этом suite не нужно использовать везде. Если у вас один простой unit-тест, обычного
TestXxxболее чем достаточно. Axiom не заставляет переписывать все тесты под suite-модель.
Можно продолжать писать обычные Go-тесты:
func TestUserCanLogin(t *testing.T) {
c := axiom.NewCase(
axiom.WithCaseName("user can log in"),
)
runner.RunCase(t, c, func(cfg *axiom.Config) {
cfg.Step("login", func() {
// test body
})
})
}
А suite подключать только там, где действительно нужна групповая граница выполнения.
Несколько практических правил
Все suite-тесты нужно зарегистрировать до вызова suite.Run().
suite.Test("user can be created", (*UsersSuite).UserCanBeCreated)
suite.Test("user can be blocked", (*UsersSuite).UserCanBeBlocked)
suite.Run()
После suite.Run() добавлять новые тесты уже нельзя. Это нормальное ограничение: execution boundary должна быть собрана до запуска.
Имена suite-тестов должны быть непустыми и уникальными внутри одного suite:
suite.Test("user can be created", (*UsersSuite).UserCanBeCreated)
Если runner не передать явно, Axiom создаст runner по умолчанию:
suite := axiom.NewSuite(t, new(UsersSuite))
Если нужна общая конфигурация, ресурсы, хуки, retry, плагины и отчётность, runner лучше передать явно:
suite := axiom.NewSuite(t, new(UsersSuite), axiom.WithSuiteConfigRunner(runner))
Заключение
Сьюты решают простую, но важную проблему Go-автотестов: они дают явную границу выполнения для группы связанных тестов.
Без suite каждый TestXxx остаётся самостоятельной функцией. У такой группы нет общего начала и общего конца. Поэтому lifecycle уровня BeforeAll / AfterAll становится неоднозначным, а общие ресурсы приходится привязывать к неявным договорённостям.
Axiom добавляет suite как опциональный слой поверх стандартного testing.
- Тесты по-прежнему остаются Go-тестами.
- Runner по-прежнему управляет execution model.
- Case по-прежнему описывает конкретный сценарий.
А Suite просто отвечает на вопрос: где начинается и где заканчивается группа связанных тестов.