Хуки в Go-автотестах: как управлять жизненным циклом тестов
Вступление
Когда автотесты на Go выходят за рамки простых unit-тестов, почти сразу появляется потребность в lifecycle-логике.
- Нужно что-то сделать до всех тестов.
- Нужно что-то сделать после всех тестов.
- Нужно выполнить подготовку перед каждым тестом.
- Нужно собрать статистику после каждого теста.
- Нужно логировать шаги, замерять время, подключать отчётность или делать cleanup.
В привычных тестовых фреймворках для этого есть хуки: before all, after all, before test, after test, before step, after step.
Но стандартный пакет testing в Go остаётся минималистичным. Он даёт TestXxx, t.Run, t.Cleanup, defer — и на этом всё. Всё остальное команда должна собрать сама.
На маленьких тестах это не выглядит проблемой. Но как только появляются интеграционные сценарии, E2E-тесты, общие ресурсы, клиенты сервисов, отчёты и шаги, lifecycle начинает расползаться прямо по тестам.
В этой статье разберёмся, зачем нужны хуки в Go-автотестах и как они работают в Axiom. Примеры сразу покажем через Suite, потому что именно suite задаёт явную границу выполнения для BeforeAll и AfterAll.
Как обычно пишут без хуков
Представим обычный интеграционный тест.
Перед тестом нужно подготовить окружение, после теста — очистить состояние, а внутри сценария хочется явно видеть шаги выполнения.
Без хуков это часто выглядит так:
package users_test
import (
"fmt"
"testing"
)
func TestUserCanBeCreated(t *testing.T) {
fmt.Println("before test: user can be created")
defer func() {
fmt.Println("after test: user can be created")
}()
fmt.Println("before step: prepare user")
// подготовка данных
fmt.Println("after step: prepare user")
fmt.Println("before step: create user")
// создание пользователя
fmt.Println("after step: create user")
fmt.Println("before step: validate response")
// проверки
fmt.Println("after step: validate response")
}
Формально такой тест работает.
Но проблема уже видна: инфраструктура смешалась с логикой сценария. Тест описывает не только проверяемое поведение, но и то, как логировать шаги, где начинать и заканчивать тест, когда делать cleanup и как оформлять lifecycle.
Если таких тестов два или три — это терпимо. Если их сотни, код начинает превращаться в копипасту.
Появляются вспомогательные функции:
func beforeTest(name string) {
fmt.Println("before test:", name)
}
func afterTest(name string) {
fmt.Println("after test:", name)
}
func beforeStep(name string) {
fmt.Println("before step:", name)
}
func afterStep(name string) {
fmt.Println("after step:", name)
}
И тест становится таким:
func TestUserCanBeCreated(t *testing.T) {
beforeTest("user can be created")
defer afterTest("user can be created")
beforeStep("prepare user")
// подготовка данных
afterStep("prepare user")
beforeStep("create user")
// создание пользователя
afterStep("create user")
beforeStep("validate response")
// проверки
afterStep("validate response")
}
Выглядит чуть аккуратнее, но суть не изменилась. Тест по-прежнему вручную управляет своим lifecycle.
Он сам знает:
- когда вызвать
beforeTest; - когда вызвать
afterTest; - как обернуть шаг;
- где поставить
defer; - как не забыть cleanup;
- как не сломать порядок выполнения.
Это уже не просто тест. Это тест плюс кусок самодельного фреймворка внутри него.
Хуки в Axiom
В Axiom хуки — это lifecycle callbacks, которые подключаются к Runner и выполняются в нужных точках execution model.
Есть несколько уровней хуков:
type AllHook func(r *axiom.Runner)
type TestHook func(cfg *axiom.Config)
type StepHook func(cfg *axiom.Config, name string)
И несколько точек выполнения:
BeforeAll
AfterAll
BeforeTest
AfterTest
BeforeStep
AfterStep
Если упростить, порядок выглядит так:
BeforeAll
BeforeTest
BeforeStep
AfterStep
BeforeStep
AfterStep
AfterTest
BeforeTest
BeforeStep
AfterStep
AfterTest
AfterAll
BeforeAllиAfterAllотносятся ко всей группе тестов.BeforeTestиAfterTestотносятся к конкретномуCase.BeforeStepиAfterStepотносятся к конкретному шагу внутриCase.
Важно, что
AfterTestиAfterStepвызываются через внутренний lifecycle Axiom. Их не нужно руками расставлять в каждом тесте черезdefer.
Пример со сьютом
Теперь посмотрим на пример. Допустим, у нас есть suite для тестов пользователей. Мы хотим:
- один раз выполнить setup перед всем сьютом;
- один раз выполнить teardown после всего сьюта;
- логировать начало и конец каждого test case;
- логировать начало и конец каждого шага.
Сначала описываем хуки:
package users_test
import (
"fmt"
"testing"
"github.com/Nikita-Filonov/axiom"
)
func beforeAll(r *axiom.Runner) {
fmt.Println("== before all: start users suite ==")
}
func afterAll(r *axiom.Runner) {
fmt.Println("== after all: finish users suite ==")
}
func beforeTest(cfg *axiom.Config) {
fmt.Println("before test:", cfg.Case.Name)
}
func afterTest(cfg *axiom.Config) {
fmt.Println("after test:", cfg.Case.Name)
}
func beforeStep(cfg *axiom.Config, name string) {
fmt.Println(" before step:", name)
}
func afterStep(cfg *axiom.Config, name string) {
fmt.Println(" after step:", name)
}
Теперь создаём Runner и подключаем хуки:
var runner = axiom.NewRunner(
axiom.WithRunnerHooks(
axiom.WithBeforeAll(beforeAll),
axiom.WithAfterAll(afterAll),
axiom.WithBeforeTest(beforeTest),
axiom.WithAfterTest(afterTest),
axiom.WithBeforeStep(beforeStep),
axiom.WithAfterStep(afterStep),
),
)
После этого описываем 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 found", (*UsersSuite).UserCanBeFound)
suite.Run()
}
Здесь важный момент: suite задаёт границу выполнения. Именно вокруг этой границы будут вызваны
BeforeAllиAfterAll.
go test видит обычный top-level тест:
func TestUsersSuite(t *testing.T) {}
А внутри него мы явно собираем группу suite-тестов:
suite.Test("user can be created", (*UsersSuite).UserCanBeCreated)
suite.Test("user can be found", (*UsersSuite).UserCanBeFound)
После этого запускаем всю группу:
suite.Run()
Это простая модель:
NewSuite -> Test -> Test -> Run
Без автоматического поиска методов и скрытых правил по именованию.
Тестовые сценарии
Добавим первый сценарий:
func (s *UsersSuite) UserCanBeCreated() {
c := axiom.NewCase(
axiom.WithCaseName("user can be created"),
)
s.RunCase(c, func(cfg *axiom.Config) {
cfg.Step("prepare user data", func() {
fmt.Println(" prepare user data")
})
cfg.Step("create user", func() {
fmt.Println(" create user")
})
cfg.Step("validate response", func() {
fmt.Println(" validate response")
})
})
}
И второй сценарий:
func (s *UsersSuite) UserCanBeFound() {
c := axiom.NewCase(
axiom.WithCaseName("user can be found"),
)
s.RunCase(c, func(cfg *axiom.Config) {
cfg.Step("create user", func() {
fmt.Println(" create user")
})
cfg.Step("get user by id", func() {
fmt.Println(" get user by id")
})
cfg.Step("validate user", func() {
fmt.Println(" validate user")
})
})
}
Внутри методов нет ручных вызовов beforeTest, afterTest, beforeStep и afterStep.
Сценарий описывает только проверяемое поведение:
- подготовить данные;
- создать пользователя;
- получить пользователя;
- проверить результат.
А lifecycle остаётся на уровне Runner и Suite.
Полный пример
Соберём всё вместе:
package users_test
import (
"fmt"
"testing"
"github.com/Nikita-Filonov/axiom"
)
func beforeAll(r *axiom.Runner) {
fmt.Println("== before all: start users suite ==")
}
func afterAll(r *axiom.Runner) {
fmt.Println("== after all: finish users suite ==")
}
func beforeTest(cfg *axiom.Config) {
fmt.Println("before test:", cfg.Case.Name)
}
func afterTest(cfg *axiom.Config) {
fmt.Println("after test:", cfg.Case.Name)
}
func beforeStep(cfg *axiom.Config, name string) {
fmt.Println(" before step:", name)
}
func afterStep(cfg *axiom.Config, name string) {
fmt.Println(" after step:", name)
}
var runner = axiom.NewRunner(
axiom.WithRunnerHooks(
axiom.WithBeforeAll(beforeAll),
axiom.WithAfterAll(afterAll),
axiom.WithBeforeTest(beforeTest),
axiom.WithAfterTest(afterTest),
axiom.WithBeforeStep(beforeStep),
axiom.WithAfterStep(afterStep),
),
)
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 found", (*UsersSuite).UserCanBeFound)
suite.Run()
}
func (s *UsersSuite) UserCanBeCreated() {
c := axiom.NewCase(
axiom.WithCaseName("user can be created"),
)
s.RunCase(c, func(cfg *axiom.Config) {
cfg.Step("prepare user data", func() {
fmt.Println(" prepare user data")
})
cfg.Step("create user", func() {
fmt.Println(" create user")
})
cfg.Step("validate response", func() {
fmt.Println(" validate response")
})
})
}
func (s *UsersSuite) UserCanBeFound() {
c := axiom.NewCase(
axiom.WithCaseName("user can be found"),
)
s.RunCase(c, func(cfg *axiom.Config) {
cfg.Step("create user", func() {
fmt.Println(" create user")
})
cfg.Step("get user by id", func() {
fmt.Println(" get user by id")
})
cfg.Step("validate user", func() {
fmt.Println(" validate user")
})
})
}
При запуске порядок выполнения будет примерно таким:
== before all: start users suite ==
before test: user can be created
before step: prepare user data
prepare user data
after step: prepare user data
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 found
before step: create user
create user
after step: create user
before step: get user by id
get user by id
after step: get user by id
before step: validate user
validate user
after step: validate user
after test: user can be found
== after all: finish users suite ==
Что здесь принципиально важно
Тесты не вызывают хуки вручную. Внутри UserCanBeCreated и UserCanBeFound нет beforeTest, afterTest, beforeStep, afterStep, defer и ручной обвязки.
Тесты описывают только сценарий. А lifecycle живёт отдельно — на уровне Runner и Suite.
Это важное разделение. Тест не должен знать, как именно логируются шаги, где собирается статистика, как подключается отчётность и когда выполняется teardown. Всё это инфраструктурные задачи.
Suite как граница выполнения
Отдельно стоит проговорить роль Suite. Без suite сложно честно ответить на вопрос: когда именно должен выполниться AfterAll.
Если запускать несколько отдельных TestXxx с общим Runner, каждый из них всё равно остаётся самостоятельной функцией в терминах стандартного Go testing. У такой группы нет общей явной границы.
Suite эту границу создаёт.
В одном top-level тесте мы создаём suite:
suite := axiom.NewSuite(t, new(UsersSuite), axiom.WithSuiteConfigRunner(runner))
Явно регистрируем тесты:
suite.Test("user can be created", (*UsersSuite).UserCanBeCreated)
suite.Test("user can be found", (*UsersSuite).UserCanBeFound)
И запускаем группу:
suite.Run()
В результате BeforeAll выполняется перед всей группой зарегистрированных suite-тестов, а AfterAll — после завершения всей группы.
Именно поэтому для хуков уровня all лучше использовать suite-модель. Она делает lifecycle явным и предсказуемым.
Где это полезно
Хуки хорошо подходят для инфраструктурных задач, которые не должны жить внутри тестового сценария.
Например:
- запуск и остановка общего окружения;
- инициализация моков или тестовых серверов;
- логирование начала и конца теста;
- сбор статистики;
- интеграция с отчётностью;
- замеры времени;
- трассировка шагов;
- единый debug output.
Главное — не превращать хуки в место для бизнес-логики теста. Если действие является частью сценария, его лучше оставить в Step. Если действие относится к инфраструктуре выполнения, его можно вынести в hook.
Заключение
Хуки решают простую, но важную проблему: они отделяют lifecycle автотеста от самого тестового сценария.
Без хуков подготовка, логирование, cleanup, отчётность и статистика постепенно расползаются по тестам. Каждый тест начинает вручную управлять своим выполнением, и со временем в проекте появляется самодельный фреймворк из defer, helper-функций и копипасты.
Axiom выносит эту логику в execution model.
BeforeTestиAfterTestработают на уровне конкретного test case.BeforeStepиAfterStepработают на уровне шагов.BeforeAllиAfterAllкорректно работают вокруг suite, потому что suite задаёт явную границу выполнения группы тестов.
В результате тесты остаются тестами: они описывают проверяемое поведение, а не управляют инфраструктурой запуска.