golang

Axiom — тестовый фреймворк для Go, которого нам всегда не хватало

  • вторник, 23 декабря 2025 г. в 00:00:14
https://habr.com/ru/articles/975478/

Вступление

В этой статье я хочу рассказать про Axiom — тестовый фреймворк (а точнее, тестовый runtime-движок) для Go. Но прежде чем говорить о решении, важно четко обозначить саму проблему, которую он закрывает.

Go по своей философии — язык минимализма. Он осознанно избегает сложных абстракций, магии, навороченных DSL и бесконечных расширений. Пакет testing — идеальное отражение этой философии: маленький, прямолинейный, прозрачный. Это прекрасно для простых юнит-тестов: никаких фреймворков, никаких «чёрных ящиков», всё понятно и управляемо.

Но у этой простоты есть обратная сторона.

Go из коробки не предоставляет ничего из того, без чего современные интеграционные и E2E тесты быстро начинают захлебываться в сложности:

  • нет фикстур и детерминированного жизненного цикла ресурсов

  • нет хуков

  • нет шагов (steps)

  • нет retries

  • нет метаданных (tags, severity, labels…)

  • нет централизованного skip

  • нет плагинов

  • нет отчётности

  • нет механизма композиции конфигурации

  • нет единой точки управления тестовой инфраструктурой

И это не “недостаток” — это осознанный выбор Go. Но последствия этого выбора становятся болезненными, как только тесты выходят за рамки простых unit-case’ов и превращаются в интеграционные, изоляционные или end-to-end сценарии.

В больших проектах тесты постепенно обрастают логированием, проверками, подготовкой данных, сложными зависимостями, проверками окружения, запуском по тегам, отчётностью в Allure, повторными попытками, параллелизацией и внутренними инструментами. И каждое из этих требований приходится реализовывать вручную — снова и снова, от файла к файлу, от сервиса к сервису. Получается огромный бойлерплейт, дублирование решений, рассыпанная по проекту логика и отсутствие каких-либо централизованных практик.

Именно здесь появляется фундаментальная проблема Go-тестирования:

Go остаётся простым, но тестовые сценарии — нет. Отсутствие инфраструктуры приводит к тому, что команды вынуждены изобретать фреймворк внутри каждого проекта.

Где-то это пара функций-хелперов. Где-то мини-DSL. Где-то громоздкая обёртка вокруг t.Run. Где-то кустарные retries, глобальные счётчики статистики и хаотичный набор тегов.

Каждая команда придумывает свой велосипед — несовместимый, хрупкий и полностью завязанный на локальные соглашения.

Именно эту проблему и решает Axiom.

Axiom — это не «красивый синтаксис» и не «фреймворк ради фреймворка». Это попытка создать недостающий слой инфраструктуры для сложных тестов в Go: структурированный, расширяемый, предсказуемый и полностью совместимый с нативным testing.

Он убирает бойлерплейт. Убирает дублирование. Убирает хаос. Убирает необходимость каждый раз «вручную» собирать тестовый фреймворк.

Axiom даёт возможность писать автотесты так, как они должны выглядеть в 2025 году — чисто, структурировано и без бесконечных костылей.

Проблема

Чтобы понять глубину проблемы, достаточно открыть любой интеграционный или E2E тест в крупном Go-проекте. Почти всегда это выглядит примерно так:

func TestPayment_CreateDirectDebit_InvalidAccount(t *testing.T) {
	t.Parallel()

	// Ручная установка тегов — дублируется в каждом тесте
	SetTestTags(t, "payments", "billing", "gateway", "critical")

	// Ручная обёртка Allure — всегда копипаст
	allure.Test(
		t,
		allure.ID("TASK-111"),
		allure.Name("Direct debit should fail for invalid account"),
		allure.Feature("direct-debit"),
		allure.Story("validation"),
		allure.Layer("integration"),
		allure.Action(func() {

			allure.Step("Initialize environment", func() {
				// Каждый тест свой уникальный init
				_ = MustInitEnvironment(t)
			})

			env := MustInitEnvironment(t)

			allure.Step("Connect to database", func() {
				db := ConnectDB(env.Config.DBUrl)
				defer db.Close()

				// Мы создадим данные в отдельном шаге,
				// но тест всё равно держит в голове db, env, req, user, account
			})

			db := ConnectDB(env.Config.DBUrl)
			defer db.Close()

			var req DirectDebitRequest

			allure.Step("Prepare test data", func() {
				user := CreateTestUser(db)
				account := CreateInvalidAccount(db, user.ID)

				req = DirectDebitRequest{
					UserID:    user.ID,
					AccountID: account.ID,
					Amount:    1000,
					Meta: map[string]string{
						"trace": uuid.NewString(),
					},
				}
			})

			allure.Step("Call billing service", func() {
				client := NewBillingClient(env.GRPC)

				resp, err := client.CreateDirectDebit(env.Ctx, req)

				// Сохраняем для следующего шага
				if err == nil {
					t.Fatalf("expected error, got nil")
				}

				if resp.Code != ErrInvalidAccount {
					t.Fatalf("unexpected code: %v", resp.Code)
				}
			})

			allure.Step("Verify no direct debit has been created", func() {
				if ExistsDirectDebit(db, req.Meta["trace"]) {
					t.Fatalf("direct debit was unexpectedly created")
				}
			})
		}),
	)
}

Один тест — и в нём:

  • ручные теги

  • ручной Allure-вызов

  • ручной setup

  • ручные фикстуры

  • ручной teardown

  • ручные проверки

  • дублирование инфраструктурного кода

  • бизнес-логика + инфраструктурная логика вперемешку

И что самое главное — следующий тест выглядит точно так же.

Например, соседний тест:

func TestPayment_CreateDirectDebit_DuplicateRequest(t *testing.T) {
	t.Parallel()

	// Ручная установка тегов — в каждом тесте копипаст
	SetTestTags(t, "payments", "billing", "gateway", "critical")

	allure.Test(
		t,
		allure.ID("TASK-333"),
		allure.Name("Duplicate direct debit request should not be processed twice"),
		allure.Feature("direct-debit"),
		allure.Story("idempotency"),
		allure.Layer("integration"),
		allure.Action(func() {

			var (
				req  DirectDebitRequest
				resp *DirectDebitResponse
				err  error
			)

			allure.Step("Initialize environment", func() {
				_ = MustInitEnvironment(t)
			})

			env := MustInitEnvironment(t)

			allure.Step("Connect to database", func() {
				db := ConnectDB(env.Config.DBUrl)
				defer db.Close()
			})

			db := ConnectDB(env.Config.DBUrl)
			defer db.Close()

			allure.Step("Prepare user and account", func() {
				user := CreateTestUser(db)
				account := CreateValidAccount(db, user.ID)

				req = DirectDebitRequest{
					UserID:         user.ID,
					AccountID:      account.ID,
					Amount:         2500,
					IdempotencyKey: uuid.NewString(),
				}
			})

			allure.Step("Send duplicate requests", func() {
				client := NewBillingClient(env.GRPC)

				// Первая попытка — успешно
				_, _ = client.CreateDirectDebit(env.Ctx, req)

				// Вторая — должна дать ошибку
				resp, err = client.CreateDirectDebit(env.Ctx, req)
			})

			allure.Step("Validate response", func() {
				if err == nil {
					t.Fatalf("expected error for duplicate request, got nil")
				}
				if resp.Code != ErrDuplicateRequest {
					t.Fatalf("unexpected code: %v", resp.Code)
				}
			})
		}),
	)
}

Что здесь не так?

На первый взгляд — обычные интеграционные тесты. Но если внимательно присмотреться, становится видно:

1. Нет централизованного управления инфраструктурой

В Go-тестах вся инфраструктура живёт прямо внутри тестов. Каждый файл вручную решает, как инициализировать окружение, открыть базу, создать клиентов, загрузить конфиг и подготовить данные. В результате у каждого теста появляется собственный «локальный фреймворк».

Пока тестов мало, это терпимо. Но с ростом проекта инфраструктура расползается в виде копипаста: разные тесты по-разному создают ресурсы и управляют окружением. Это не просто дублирование — это потеря управляемости. Любое изменение требует правки десятков или сотен тестов, потому что единого источника правды не существует.

2. Теги, метаданные, Allure — всё вручную

Метаданные живут прямо в теле тестов: теги, severity, feature и вызовы Allure прописываются вручную в каждом файле. Пока структура отчётности стабильна, это работает. Но любое изменение — переименование feature, новая группировка, другая фильтрация — превращается в массовый рефакторинг.

У проекта нет конфигурационного слоя, который описывает политику метаданных и отчётности. Всё зашито в тестах и не поддаётся централизованному управлению.

3. Никакого retry

Go не предоставляет механизма повторных запусков. Интеграционные тесты флапают, а retries реализуются вручную: через обёртки, счётчики попыток и условные teardown’ы. Каждый пишет это по-своему, без общей политики и без изоляции между попытками. Такие решения быстро становятся хрупкими и непредсказуемыми.

4. Параллелизация размазана

Параллельность управляется вызовом t.Parallel(), разбросанным по тестам. Это не политика выполнения, а копипаст-флаг. Глобально изменить стратегию запуска или временно отключить параллелизм можно только вручную, проходясь по всему проекту.

5. Фикстур нет как концепта

В Go нет идеи фикстуры как ресурса с жизненным циклом. Базы, клиенты и тестовые данные создаются вручную прямо в тестах: где-то есть cleanup, где-то его забыли; где-то ресурсы кешируются, где-то создаются заново. Нет композиции, декларативности и lazy-инициализации. Инфраструктурный код разрастается и начинает доминировать над логикой теста.

6. Инфраструктурная логика смешана с бизнес-логикой

В одном и том же тесте соседствуют инициализация окружения, подключение сервисов, подготовка данных, вызовы бизнес-методов, проверки и teardown. Тест перестаёт быть проверкой поведения и превращается в линейный сценарий из «подключись», «создай», «вызови», «проверь», «почисти». Со временем такие тесты сложно читать и ещё сложнее поддерживать.

7. Дублирование повсюду

Всё, что должно быть инфраструктурой, повторяется в каждом файле: setup, метаданные, интеграция с Allure, проверки, параллелизация. Каждый тест копирует один и тот же каркас. Вместо одного управляемого слоя инфраструктуры проект получает сотни мелких реализаций, разбросанных по дереву тестов.

8. Никакой возможности централизованно управлять тестовым поведением

В традиционном Go-подходе тестовое поведение рассыпано по файлам. Нельзя централизованно задать правила фильтрации, retry, логирования, отчётности или параллелизма. Любое изменение политики превращается в ручной обход проекта. Пока инфраструктура остаётся распределённой по тестам, управляемый и предсказуемый тестовый контур построить невозможно.

Итог

Такой стиль тестирования неизбежно появляется в Go-проектах без фреймворка. Потому что Go даёт минималистичный testing, но не даёт инфраструктуры.

И именно здесь становится очевидно:

Нужен слой, который объединяет инфраструктурный код, структуру тестов, метаданные, фикстуры, параллелизацию, retry и плагины. Слой, который даёт порядок и композицию. Слой, который избавляет от бойлерплейта.

И этот слой — Axiom.

Пример использования Axiom

После просмотра анти-примеров становится ясно: основной объём кода в интеграционных тестах — это вовсе не тест. Это инфраструктура. И именно она должна быть вынесена из тестов полностью.

В Axiom эта роль принадлежит Runner — центральной точке конфигурации, где определяется всё: метаданные, плагины, retry-политики, параллелизм, fixtures, контекст.

Тесту остаётся только сценарий.

Runner: единый слой инфраструктуры

var runner = axiom.NewRunner(

	// Базовые метаданные для всех тестов: автоматически попадут в Allure.
	axiom.WithRunnerMeta(
		axiom.WithMetaFeature("direct-debit"),
		axiom.WithMetaLayer("integration"),
	),

	// Подключаем плагины — отчётность, статистика, правила выбора тестов.
	// Это заменяет десятки строк ручного кода в каждом тесте.
	axiom.WithRunnerPlugins(
		testallure.Plugin(),                    // Allure-обёртки: тесты и шаги
		teststats.Plugin(teststats.NewStats()), // Метрики выполнения
		testtags.Plugin(),                      // Фильтрация по тегам (include/exclude)
	),

	// Глобальная retry-политика.
	axiom.WithRunnerRetry(
		axiom.WithRetryTimes(2),
		axiom.WithRetryDelay(50),
	),

	// Параллельный запуск — единым флагом.
	// Больше не нужно расставлять t.Parallel() по всем тестам.
	axiom.WithRunnerParallel(),

	// Глобальные фикстуры — инфраструктура, доступная каждому тесту:
	// окружение, БД, gRPC-клиенты. Ленивая инициализация и автоматический cleanup.
	axiom.WithRunnerFixture("env", EnvFixture),
	axiom.WithRunnerFixture("db", DBFixture),
	axiom.WithRunnerFixture("billing", BillingClientFixture),
)

Здесь сосредоточено всё, что раньше приходилось вручную повторять от теста к тесту: инициализация окружения, подключение к БД, создание gRPC-клиентов, настройка Allure, подсчёт статистики, политика retries, контроль параллелизации. Тесты больше не знают, как всё это работает — им это и не нужно.

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

Fixtures: инфраструктура в минимуме кода

Фикстуры в Axiom — это ленивые ресурсы с детерминированным жизненным циклом. Они создаются только при первом обращении, кешируются на время выполнения теста (или retry-попытки) и автоматически очищаются. Это позволяет выразить инфраструктуру в нескольких чётких функциях и полностью исключить её из тестового кода.

// EnvFixture — точка входа в окружение.
// Вызывается только один раз при первом запросе теста.
// Cleanup не нужен: конфиг — неизменяемая структура.
func EnvFixture(cfg *axiom.Config) (any, func(), error) {
	env := LoadEnvironment()
	return env, nil, nil
}

// DBFixture — подключение к БД.
// Зависит от фикстуры окружения: удобная, декларативная композиция.
// Axiom гарантирует, что соединение будет закрыто после теста.
func DBFixture(cfg *axiom.Config) (any, func(), error) {
	db := ConnectDB(axiom.GetFixture[Environment](cfg, "env").Config.DBUrl)
	return db, func() { db.Close() }, nil
}

// BillingClientFixture — создание gRPC-клиента.
// Всего одна строка, без ручного подключения, без повторов в тестах.
func BillingClientFixture(cfg *axiom.Config) (any, func(), error) {
	env := axiom.GetFixture[Environment](cfg, "env")
	return NewBillingClient(env.GRPC), nil, nil
}

Фикстуры позволяют держать инфраструктурный код компактным, декларативным и полностью изолированным от тестов. Тест получает уже готовые ресурсы — в момент, когда они действительно нужны — и никогда не заботится о том, как они создаются, кешируются или очищаются.

Итог: инфраструктура перестаёт быть «мини-фреймворком внутри каждого теста» и превращается в чистый, предсказуемый слой, оформленный в нескольких небольших функциях.

Тест: Invalid Account (Axiom)

То, что раньше занимало половину файла — подключение клиентов, setup окружения, ручные теги, повторяющиеся конструкции Allure, дублирование логики проверок — теперь сводится к последовательности шагов. Инфраструктура живёт в Runner’e, тест концентрируется только на сценарии.

func TestDirectDebit_InvalidAccount(t *testing.T) {

	c := axiom.NewCase(

		// Название и метаданные теста — декларативно, без ручного вызова Allure.
		axiom.WithCaseName("direct debit fails for invalid account"),
		axiom.WithCaseMeta(
			axiom.WithMetaTag("payments"),
			axiom.WithMetaTag("critical"),
			axiom.WithMetaStory("validation"),
		),
	)

	// Runner подключает инфраструктуру: фикстуры, плагины, retry, parallel, hooks.
	runner.RunCase(t, c, func(cfg *axiom.Config) {

		// Получение инфраструктурных зависимостей — одна строка.
		db := axiom.GetFixture[DB](cfg, "db")
		billing := axiom.GetFixture[BillingClient](cfg, "billing")

		// Шаги структурируют тест и автоматически попадают в отчёты (например, Allure).
		cfg.Step("prepare data", func() {
			user := CreateUser(db)
			account := CreateInvalidAccount(db, user.ID)

			// Используем контекст Axiom для хранения промежуточных данных.
			cfg.Context.SetData("userID", user.ID)
			cfg.Context.SetData("accountID", account.ID)
		})

		cfg.Step("call billing service", func() {

			// Контекст предоставляет безопасный, типизированный доступ к данным.
			req := DirectDebitRequest{
				UserID:    axiom.MustContextValue[string](&cfg.Context, "userID"),
				AccountID: axiom.MustContextValue[string](&cfg.Context, "accountID"),
				Amount:    1000,
				Meta:      map[string]string{"trace": uuid.NewString()},
			}

			// Вызов бизнес-логики — без ручного setup и обвязок.
			resp, err := billing.CreateDirectDebit(cfg.Context.Raw, req)

			// Результаты запроса фиксируем в контексте для последующего шага.
			cfg.Context.SetData("resp", resp)
			cfg.Context.SetData("err", err)
		})

		cfg.Step("assert failure", func() {
			err := axiom.MustContextValue[error](&cfg.Context, "err")
			resp := axiom.MustContextValue[*DirectDebitResponse](&cfg.Context, "resp")

			// Проверки — единственное место, где остаётся логика теста.
			if err == nil || resp.Code != ErrInvalidAccount {
				t.Fatalf("expected invalid account error")
			}
		})
	})
}

Этот тест — наконец-то тест, а не смесь из окружения, инфраструктуры, логирования и хаотичных вспомогательных вызовов.

Аналогичный тест до Axiom состоял бы из:

  • ручного подключения БД и клиентов,

  • копипасты Allure и тегов,

  • ручного retry (или отсутствия retry вовсе),

  • повторяющихся setup/teardown-конструкций,

  • дублирования кода создания пользователей и данных,

  • беспорядочного хранения промежуточного состояния.

С Axiom всё это исчезает, потому что:

  • инфраструктура вынесена в Runner,

  • фикстуры дают декларативный доступ к ресурсам,

  • шаги автоматически структурируют сценарий,

  • контекст даёт типизированное хранилище данных,

  • плагины берут на себя отчётность, теги, хуки и статистику.

Тест остаётся минимальным, выразительным и не содержит ничего, что не относится непосредственно к проверяемому поведению.

Duplicate Request — такой же декларативный и короткий

Второй тест повторяет ту же структуру: сценарий описан шагами, инфраструктура скрыта в Runner’e, данные передаются через контекст, фикстуры обеспечивают ресурсы. Тест остаётся фокусированным на поведении, а не на подготовке окружения.

func TestDirectDebit_DuplicateRequest(t *testing.T) {

	c := axiom.NewCase(
		axiom.WithCaseName("duplicate request is rejected"),
		axiom.WithCaseMeta(
			axiom.WithMetaTag("payments"),
			axiom.WithMetaTag("critical"),
			axiom.WithMetaStory("idempotency"),
		),
	)

	runner.RunCase(t, c, func(cfg *axiom.Config) {

		db := axiom.GetFixture[DB](cfg, "db")
		billing := axiom.GetFixture[BillingClient](cfg, "billing")

		cfg.Step("prepare user/account", func() {
			user := CreateUser(db)
			account := CreateValidAccount(db, user.ID)

			cfg.Context.SetData("userID", user.ID)
			cfg.Context.SetData("accountID", account.ID)
			cfg.Context.SetData("idemp", uuid.NewString())
		})

		cfg.Step("send duplicate request", func() {
			req := DirectDebitRequest{
				UserID:         axiom.MustContextValue[string](&cfg.Context, "userID"),
				AccountID:      axiom.MustContextValue[string](&cfg.Context, "accountID"),
				Amount:         2500,
				IdempotencyKey: axiom.MustContextValue[string](&cfg.Context, "idemp"),
			}

			// первая попытка — создаёт операцию
			_, _ = billing.CreateDirectDebit(cfg.Context.Raw, req)

			// вторая — должна вернуть ошибку идемпотентности
			resp, err := billing.CreateDirectDebit(cfg.Context.Raw, req)

			cfg.Context.SetData("resp", resp)
			cfg.Context.SetData("err", err)
		})

		cfg.Step("assert duplicate", func() {
			err := axiom.MustContextValue[error](&cfg.Context, "err")
			resp := axiom.MustContextValue[*DirectDebitResponse](&cfg.Context, "resp")

			if err == nil || resp.Code != ErrDuplicateRequest {
				t.Fatalf("expected duplicate request error")
			}
		})
	})
}
  • Логика теста — это три шага: подготовка, действие, проверка.

  • Ни одного вспомогательного вызова: ни клиентов, ни конфигов, ни setup, ни retries.

  • Axiom гарантирует, что фикстуры создадутся ровно один раз, шаги будут обработаны плагинами, а метаданные попадут в отчёты.

  • Тест читается как сценарий, а не как смесь бизнес- и инфраструктурных обязанностей.

Что важно в этих примерах

Axiom превращает тест в декларативный сценарий, а инфраструктуру — в скрытый слой конфигурации, которым управляет Runner. Нет ручных клиентов, ручного setup, ручного teardown, ручных Allure-вызовов, ручных тегов, ручного retry, ручного параллелизма.

Тесты наконец-то перестают быть смесью технологий и начинают быть тем, чем должны быть — описанием поведения системы.

Функциональность Axiom

Axiom — это не просто удобная оболочка над testing.T. Это полноценный тестовый runtime, который формирует предсказуемую инфраструктуру вокруг каждого теста: от метаданных до lifecycle-хуков, от фикстур до плагинов.

Ниже — краткий обзор основных механизмов, которые делает доступными Axiom.

1. Meta: единый слой метаданных (tags, labels, stories, features, severity)

Вместо того чтобы вручную расставлять теги и объекты Allure в каждом тесте, Axiom вводит слой Meta, который можно описывать на уровне Runner’а или конкретного Case.

c := axiom.NewCase(
    axiom.WithCaseMeta(
        axiom.WithMetaStory("validation"),
        axiom.WithMetaTag("payments"),
        axiom.WithMetaSeverity(axiom.SeverityCritical),
    ),
)

Мета-данные сливаются (Runner → Case), а плагины вроде testallure автоматически превращают их в отчёты. Это значит: никакой ручной интеграции с Allure, никаких копипастных allure.Feature(...) — всё декларативно.

2. Fixtures: ленивые ресурсы с автоматическим cleanup

Фикстуры в Axiom — центральный концепт. Они создаются только тогда, когда тест действительно к ним обращается, автоматически кешируются и автоматически же очищаются после выполнения.

func DBFixture(cfg *axiom.Config) (any, func(), error) {
    db := Connect(cfg.Context.Raw)
    return db, func() { db.Close() }, nil
}

// Регистрация:
runner := axiom.NewRunner(
    axiom.WithRunnerFixture("db", DBFixture),
)

// Использование в тесте:
db := axiom.GetFixture[*DB](cfg, "db")

Это избавляет тесты от ручного:

  • создания коннекшенов,

  • закрытия ресурсов,

  • хранения зависимостей в переменных,

  • передачи окружения по цепочке.

Фикстуры формируют полноценный DI-контейнер для тестов, но лёгкий, прозрачный и полностью Go-образный.

3. Hooks: предсказуемый lifecycle тестов, шагов и всего тестового раннера

Axiom поддерживает полный набор хуков, позволяющих расширять поведение тестов без изменения их тела. Все хуки можно навешивать как на Runner (глобально), так и на отдельные Case (локально).

Suite-level (глобальные):

Эти хуки выполняются один раз за весь запуск Runner, вне зависимости от количества тестов.

Хук

Когда вызывается

BeforeAll

перед запуском первого тест-кейса

AfterAll

после последнего тест-кейса (через t.Cleanup)

Идеально для:

  • запуска docker-контейнеров / embedded-сервисов;

  • прогрева кэша, загрузки конфигурации;

  • глобальных метрик;

  • общего teardown.

Test-level:

Хук

Когда вызывается

BeforeTest

перед выполнением тестового сценария (каждого кейса)

AfterTest

после выполнения теста, даже при panic

Используется для:

  • логирования начала/конца теста;

  • создания контекста (trace span, request-id);

  • pre/post валидаций.

Step-level:

Хук

Когда вызывается

BeforeStep

перед выполнением шага

AfterStep

после шага (включая panic)

Используется для:

  • измерения времени шагов;

  • детализированного логирования;

  • Allure / tracing интеграции;

  • валидации инвариантов между шагами.

Пример: как подключить хуки

runner := axiom.NewRunner(
    axiom.WithRunnerHooks(
        // Глобальные хуки: выполняются один раз на весь runner
        axiom.WithBeforeAll(func(r *axiom.Runner) {
            fmt.Println("→ test suite start")
        }),
        axiom.WithAfterAll(func(r *axiom.Runner) {
            fmt.Println("→ test suite end")
        }),

        // Хуки на уровне тестов
        axiom.WithBeforeTest(func(cfg *axiom.Config) {
            fmt.Println("→ test starts:", cfg.Name)
        }),
        axiom.WithAfterTest(func(cfg *axiom.Config) {
            fmt.Println("→ test finished:", cfg.Name)
        }),

        // Хуки шагов
        axiom.WithBeforeStep(func(cfg *axiom.Config, step string) {
            fmt.Println("→ step:", step)
        }),
        axiom.WithAfterStep(func(cfg *axiom.Config, step string) {
            fmt.Println("✓ step completed:", step)
        }),
    ),
)

Что дают хуки в реальных интеграционных тестах

  • централизованное логирование (не нужно писать t.Log везде);

  • метрики шагов (время, частота фейлов);

  • автоматическая трассировка (начать trace-span в BeforeTest, завершить в AfterTest);

  • пред-/пост-проверки (валидация окружения, состояния БД);

  • интеграция с репортерами (например, Allure-плагин добавляет step attachments через hooks);

  • расширение поведения без изменения тестов.

Важно: хуки работают вместе с Wraps

Хуки создают события (before/after…), а Wraps — формируют middleware-цепочку, изменяющую само поведение исполнения:

  • WrapTestAction

  • WrapStepAction

Именно сочетание Hooks + Wraps превращает Axiom в полноценный execution engine, аналогичный middleware в Gin / Fiber или pytest hooks.

4. Retry: детерминированные повторные попытки теста

Go не даёт retry механизма. Axiom — да.

runner := axiom.NewRunner(
    axiom.WithRunnerRetry(
        axiom.WithRetryTimes(3),
        axiom.WithRetryDelay(100*time.Millisecond),
    ),
)

Каждый retry создаёт полностью новый Config, что значит:

  • фикстуры переинициализируются,

  • контекст чистый,

  • состояние шага не переиспользуется,

  • нет скрытых побочных эффектов.

Axiom обеспечивает чистые, изолированные попытки — как это должно работать в реальных E2E тестах.

5. Plugins: расширяемость на уровне runtime

В Axiom расширение поведения тестов реализовано через плагины, работающие на уровне runtime.

Плагин — это простая функция, которая получает *axiom.Config и регистрирует поведение в его Runtime:

func MyPlugin() axiom.Plugin {
	return func(cfg *axiom.Config) {
		cfg.Runtime.EmitTestWrap(func(next axiom.TestAction) axiom.TestAction {
			return func(c *axiom.Config) {
				log.Println("before test")
				next(c)
			}
		})
	}
}

Важно: плагин ничего не исполняет сам. Он лишь подписывается на события тестового runtime.

Плагин может регистрировать обработчики в Runtime и тем самым расширять поведение фреймворка:

  • оборачивать выполнение тестов (EmitTestWrap)

  • оборачивать выполнение шагов (EmitStepWrap)

  • потреблять логи (EmitLogSink)

  • потреблять артефакты (EmitArtefactSink)

  • модифицировать метаданные (cfg.Meta)

  • внедрять контекст (cfg.Context)

  • менять правила skip / retry

  • подключать отчётность (Allure, JUnit, JSON)

  • фильтровать тесты (как testtags)

  • собирать статистику (как teststats)

  • отправлять события в Sentry / Datadog / ClickHouse

  • делать любую кросс-срезовую инфраструктурную логику

Плагин не знает, как именно будет выполняться тест. Он лишь регистрирует реакции на события runtime:

  • «тест начался»

  • «шаг выполняется»

  • «появился лог»

  • «появился артефакт»

Таким образом:

  • тесты остаются чистыми

  • Allure, логирование и метрики не протекают в тестовый код

  • ядро фреймворка не зависит от конкретных интеграций

  • любое поведение можно добавить или убрать одной строкой

axiom.WithRunnerPlugins(
	testtags.Plugin(),
	teststats.Plugin(stats),
	testlogger.Plugin(),
	testallure.Plugin(),
)

6. Context: структурированное хранение данных между шагами

Тесты часто требуют передать данные:

  • между шагами,

  • из фикстуры в шаг,

  • из setup-логики в действие.

Вместо переменных на верхнем уровне теста:

cfg.Context.SetData("userID", id)
id := axiom.MustContextValue[string](&cfg.Context, "userID")

Контекст — типобезопасный и расширяемый (Runner → Case → Test).

7. Parameters: типизированные вводные данные

Можно передать параметры тесту:

type LoginParams struct {
    User string
    Pass string
}

c := axiom.NewCase(
    axiom.WithCaseParams(LoginParams{"alice", "123"}),
)

params := axiom.GetParams[LoginParams](cfg)

Это идеально для таблиц тестов, датасетов и декларативных сценариев.

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

Вместо t.Parallel() в случайных местах:

axiom.WithRunnerParallel()
axiom.WithCaseSequential()

Case-уровень перекрывает Runner-уровень.

Параллельность перестаёт быть хаотичным флагом в каждом тесте — она становится политикой.

9. Skip: статический и динамический

axiom.WithCaseSkip(axiom.SkipBecause("feature disabled"))

Axiom не просто пропускает тест — он пропускает всю окружающую инфраструктуру:

  • фикстуры,

  • хуки,

  • шаги,

  • плагины.

Это критично для CI (например, если сервис временно отключён или environment degraded).

Итог

Axiom — это не «удобный синтаксис для тестов». Это полноценная архитектура тестового окружения, в которой:

  • Runner задаёт стратегию тестирования;

  • Case формирует декларативный контракт теста;

  • Config — runtime-снимок окружения;

  • Fixtures — источник инфраструктуры;

  • Hooks и Wraps — механизм расширения;

  • Plugins — точка интеграции с внешним миром;

  • Context и Params — безопасная передача данных;

  • Retry и Parallel — политики исполнения.

И всё это работает поверх нативного testing, не заменяя его, а дополняя.

Плагины: как Axiom становится бесконечно расширяемым

Одно из самых мощных преимуществ Axiom — это архитектура плагинов. Если Runner — это «центр управления тестовой инфраструктурой», то плагины — его «надстройки», которые позволяют менять поведение всей тестовой системы, не изменяя ни строчки тестового кода.

Плагин в Axiom — это всего лишь функция:

type Plugin func(cfg *axiom.Config)

Но при этом она может делать практически всё:

  • модифицировать метаданные теста,

  • менять правила skip и retry,

  • навешивать middleware на тест или шаги,

  • собирать статистику,

  • внедрять контекст,

  • подключать внешние системы (Sentry, Datadog, Prometheus…),

  • фильтровать тесты по тегам,

  • интегрировать отчётность (например, Allure),

  • полностью переписывать жизненный цикл теста.

Плагин выполняется до начала теста, получает доступ к runtime-конфигурации (Config) и может свободно её менять.

Это делает Axiom не просто фреймворком, а платформой.

Встроенные плагины: три примера реальных возможностей

Axiom поставляется с несколькими готовыми плагинами, которые демонстрируют возможности системы.

Tags Plugin: фильтрация тестов по тегам (в том числе из ENV)

Плагин testtags позволяет запускать тесты избирательно:

runner := axiom.NewRunner(
    axiom.WithRunnerPlugins(
        testtags.Plugin(
            testtags.WithConfigInclude("smoke"),
        ),
    ),
)

Теперь будут запускаться только тесты с тегом smoke.

Можно определять правила через переменные окружения:

AXIOM_TEST_TAGS_INCLUDE=regression,critical
AXIOM_TEST_TAGS_EXCLUDE=slow,unstable

Тест, который «не прошёл фильтр», автоматически получает:

cfg.Skip = Skip{Enabled: true, Reason: "not included by tag filter"}

Ни единой проверки в тестах — всё централизовано.

Stats Plugin: сбор статистики выполнения

Плагин teststats измеряет:

  • количество попыток,

  • длительность,

  • финальный статус (passed / failed / flaky / skipped),

  • ошибки,

  • метаданные.

Минимальный usage:

stats := teststats.NewStats()

runner := axiom.NewRunner(
    axiom.WithRunnerPlugins(teststats.Plugin(stats)),
)

После прогона:

fmt.Println(stats.Passed, stats.Failed, stats.Flaky)
fmt.Println(stats.Cases) // все результаты в деталях

Плагин использует хуки BeforeTest и AfterTest, чтобы автоматически считать попытки и определять flaky-тесты — ни одного изменения в тестах.

Allure Plugin: полноценная интеграция с Allure без ручного кода

Плагин testallure превращает ваши тесты и шаги в Allure-структуру без единого ручного вызова:

runner := axiom.NewRunner(
	axiom.WithRunnerPlugins(
		testallure.Plugin(),
	),
)

Тестовый шаг:

cfg.Step("prepare data", func() {
	// ...
})

Автоматически становится:

allure.Step("prepare data", ...)

Метаданные задаются декларативно:

axiom.WithCaseMeta(
	axiom.WithMetaStory("login"),
	axiom.WithMetaFeature("authentication"),
	axiom.WithMetaSeverity(axiom.SeverityCritical),
)

И автоматически конвертируются в:

allure.Story("login")
allure.Feature("authentication")
allure.Severity("critical")

Тестовый код не содержит ни одного allure.* вызова.

Помимо шагов и метаданных, testallure также обрабатывает артефакты, которые тест или инфраструктурный код может эмитить во время выполнения.

Пример из теста или клиента:

cfg.Artefact(
	axiom.NewJSONArtefact("Request Body", req),
)

cfg.Artefact(
	axiom.NewTextArtefact("Response Status", resp.Status()),
)

Плагин testallure автоматически:

  • определяет тип артефакта (json, text, bytes)

  • корректно добавляет его в Allure как attachment

  • логирует предупреждение, если добавление не удалось

Артефакты — это инфраструктурные данные: HTTP-запросы и ответы, payload’ы, ошибки, идентификаторы и промежуточные состояния, которые нужны для отладки и анализа, а не для логики теста.

В Axiom тест не знает, куда именно они пойдут: он лишь эмитит артефакт, плагин решает, как его обработать, а Allure — всего лишь один из возможных consumer’ов. Сегодня это Allure, завтра — QASE, testit, S3, ClickHouse, Loki или собственная система отчётности.

Самое важное — тесты вообще не знают, что используется Allure. Ни шаги, ни артефакты, ни метаданные не привязаны к конкретному репортингу.

Вы можете заменить Allure, подключить несколько репортёров одновременно или отключить репортинг полностью — не изменив ни одной строчки тестов.

Это и есть настоящая интеграция через архитектуру, а не через хелперы.

Пример: плагин на 5 строк, который логирует длительность каждого шага

В Axiom написать свой плагин проще, чем в большинстве фреймворков:

func StepDuration() axiom.Plugin {
	return func(cfg *axiom.Config) {
		cfg.Runtime.EmitStepWrap(func(name string, next axiom.StepAction) axiom.StepAction {
			return func() {
				start := time.Now()
				next()
				fmt.Println("→ step", name, "took", time.Since(start))
			}
		})
	}
}

Подключение:

runner := axiom.NewRunner(
    axiom.WithRunnerPlugins(StepDuration()),
)

И теперь каждый шаг в любом тесте будет автоматически логировать своё время.

И не нужно править тесты.

Пример: плагин, который автоматически добавляет кореляционный ID во все шаги

func CorrelationID() axiom.Plugin {
	return func(cfg *axiom.Config) {
		id := uuid.NewString()

		// инжектим значение в контекст
		cfg.Context = cfg.Context.With(
			axiom.WithContextData("correlation_id", id),
		)

		// используем runtime для оборачивания шагов
		cfg.Runtime.EmitStepWrap(func(name string, next axiom.StepAction) axiom.StepAction {
			return func() {
				fmt.Println("cid:", id, "step:", name)
				next()
			}
		})
	}
}

Пример: плагин, который делает retry только для тестов с тегом "flaky"

// FlakyRetry увеличивает количество ретраев для тестов с тегом "flaky".
//
// Плагин читает уже смерженную Meta (Runner + Case)
// и на её основе настраивает runtime-поведение.
func FlakyRetry(times int) axiom.Plugin {
	return func(cfg *axiom.Config) {

		// Если тест помечен как flaky — увеличиваем количество попыток
		if contains(cfg.Meta.Tags, "flaky") {
			cfg.Retry.Times = times
		}
	}
}

Что важно понять про плагины

Плагины в Axiom — это не “дополнение”, а второй уровень архитектуры, который работает поверх Runner’а и позволяет менять поведение тестовой системы так же свободно, как middleware меняют HTTP-сервер. Это отдельный, изолированный слой логики, который не смешивается с тестами и не заставляет вас писать DSL — он просто подключается или отключается одной строкой.

Плагин может вмешиваться практически в любой аспект работы тестового рантайма: модифицировать метаданные, политику skip/retry/parallel, структуру и порядок шагов, менять хук-цепочку, расширять контекст, управлять фикстурами, добавлять или переопределять TestWraps и StepWraps, фактически меняя сам workflow выполнения теста.

Это означает, что плагины могут не только дополнять фреймворк, но и переписывать его поведение, создавая поверх Axiom вашу собственную тестовую платформу.

Именно комбинация плагинов позволяет собрать корпоративный стандарт тестирования: сложные отчёты, централизованное логирование, policy-based retry, динамический skip, интеграцию с observability-системами, метрики, аудит — всё оформляется декларативно и без копипаста.

Плагины превращают Axiom из “фреймворка” в полноценный конфигурируемый тестовый движок. Он не диктует правила, а предоставляет механизм, на основе которого вы строите свою экосистему. Встроенные плагины показывают потенциал. Настоящая сила — в ваших собственных.

Axiom ничего не ограничивает. Он даёт инструменты.

Небольшая ремарка про экосистему

На просторах Go-экосистемы уже существуют библиотеки, которые пытаются закрыть проблему отсутствия test runtime. Чаще всего они делают это, плотно встраивая модель исполнения прямо в тестовый API или в систему отчётности.

Такой подход работает, но имеет цену: execution engine, шаги, хуки, метаданные и репортинг сливаются в один слой. Тесты начинают писаться под конкретный инструмент, а не под testing, и со временем оказываются жёстко с ним связаны.

В этом подходе нет ничего «неправильного» — он просто решает другую задачу. Axiom же сознательно остаётся execution engine’ом, который работает поверх стандартного testing, не подменяя его и не навязывая собственный DSL. Репортинг, логирование и аналитика в нём вынесены в плагины и могут подключаться или отключаться независимо.

Это делает Axiom не альтернативой testing, а недостающим слоем между тестами и их исполнением.

Заключение

Главная ценность Axiom — в том, что он возвращает тестам структуру. Он создаёт недостающий слой инфраструктуры, которого так не хватало в экосистеме Go: единый runtime, предсказуемый жизненный цикл, декларативные шаги, фикстуры, хуки, плагины, централизованную конфигурацию.

При этом Axiom не ломает привычный Go-подход. Он не вводит магии, не подменяет testing.T, не использует рефлексию для скрытых трансформаций и не превращает тесты в DSL. Всё остаётся максимально прозрачным и совместимым с нативным инструментарием Go: вы по-прежнему запускаете тесты командой go test, используете стандартные механики и интеграции CI.

Axiom — это не альтернатива стандартному testing, а его естественное расширение. Он устраняет боль, но сохраняет философию языка: простоту, явность и предсказуемость.

Фреймворк позволяет писать большие, сложные, интеграционные тесты так же чисто и аккуратно, как простые юнит-тесты. Без бойлерплейта. Без копипаста. Без бесконечных «мини-фреймворков» в каждом проекте.

Axiom даёт инфраструктуру. Тестам остаётся только логика.

Если вам близка идея структурного, расширяемого тестового рантайма для Go — попробуйте Axiom в своих проектах. Фреймворк только начинает развиваться, и любая обратная связь невероятно ценна.

⭐ Поставьте звезду репозиторию — это лучший способ поддержать проект и показать, что экосистеме Go действительно нужен подобный инструмент: https://github.com/Nikita-Filonov/axiom

Спасибо за внимание — и пусть ваши тесты будут такими же простыми, как и сам Go.