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

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

Axiom — это недостающий тестовый runtime для Go, который добавляет фикстуры, шаги, хуки, retry, плагины, метаданные и структурированное выполнение поверх стандартного testing, оставаясь полностью совместимым с ним. Минимум магии, максимум инфраструктуры.

В этой статье я хочу рассказать про 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), ), )

Мета-данные сливаются (RunnerCase), а плагины вроде 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")

Контекст — типобезопасный и расширяемый (RunnerCase → 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.

Начать дискуссию