← Все статьи

Go-автотесты

Хуки в Go-автотестах: как управлять жизненным циклом тестов

Коротко разбираем, зачем нужны хуки в Go-автотестах, почему BeforeAll и AfterAll сложно реализовать без явной границы выполнения и как Axiom решает эту задачу через Runner, Suite, Case и lifecycle hooks.

gotestingaxiomхукиинтеграционные тестыe2eавтотестытестовый фреймворкqaqa automation

Хуки в 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 задаёт явную границу выполнения группы тестов.

В результате тесты остаются тестами: они описывают проверяемое поведение, а не управляют инфраструктурой запуска.