Axiom — тестовый фреймворк для Go, которого нам всегда не хватало
- вторник, 23 декабря 2025 г. в 00:00:14
В этой статье я хочу рассказать про 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)
}
})
}),
)
}На первый взгляд — обычные интеграционные тесты. Но если внимательно присмотреться, становится видно:
В Go-тестах вся инфраструктура живёт прямо внутри тестов. Каждый файл вручную решает, как инициализировать окружение, открыть базу, создать клиентов, загрузить конфиг и подготовить данные. В результате у каждого теста появляется собственный «локальный фреймворк».
Пока тестов мало, это терпимо. Но с ростом проекта инфраструктура расползается в виде копипаста: разные тесты по-разному создают ресурсы и управляют окружением. Это не просто дублирование — это потеря управляемости. Любое изменение требует правки десятков или сотен тестов, потому что единого источника правды не существует.
Метаданные живут прямо в теле тестов: теги, severity, feature и вызовы Allure прописываются вручную в каждом файле. Пока структура отчётности стабильна, это работает. Но любое изменение — переименование feature, новая группировка, другая фильтрация — превращается в массовый рефакторинг.
У проекта нет конфигурационного слоя, который описывает политику метаданных и отчётности. Всё зашито в тестах и не поддаётся централизованному управлению.
Go не предоставляет механизма повторных запусков. Интеграционные тесты флапают, а retries реализуются вручную: через обёртки, счётчики попыток и условные teardown’ы. Каждый пишет это по-своему, без общей политики и без изоляции между попытками. Такие решения быстро становятся хрупкими и непредсказуемыми.
Параллельность управляется вызовом t.Parallel(), разбросанным по тестам. Это не политика выполнения, а копипаст-флаг. Глобально изменить стратегию запуска или временно отключить параллелизм можно только вручную, проходясь по всему проекту.
В Go нет идеи фикстуры как ресурса с жизненным циклом. Базы, клиенты и тестовые данные создаются вручную прямо в тестах: где-то есть cleanup, где-то его забыли; где-то ресурсы кешируются, где-то создаются заново. Нет композиции, декларативности и lazy-инициализации. Инфраструктурный код разрастается и начинает доминировать над логикой теста.
В одном и том же тесте соседствуют инициализация окружения, подключение сервисов, подготовка данных, вызовы бизнес-методов, проверки и teardown. Тест перестаёт быть проверкой поведения и превращается в линейный сценарий из «подключись», «создай», «вызови», «проверь», «почисти». Со временем такие тесты сложно читать и ещё сложнее поддерживать.
Всё, что должно быть инфраструктурой, повторяется в каждом файле: setup, метаданные, интеграция с Allure, проверки, параллелизация. Каждый тест копирует один и тот же каркас. Вместо одного управляемого слоя инфраструктуры проект получает сотни мелких реализаций, разбросанных по дереву тестов.
В традиционном Go-подходе тестовое поведение рассыпано по файлам. Нельзя централизованно задать правила фильтрации, retry, логирования, отчётности или параллелизма. Любое изменение политики превращается в ручной обход проекта. Пока инфраструктура остаётся распределённой по тестам, управляемый и предсказуемый тестовый контур построить невозможно.
Такой стиль тестирования неизбежно появляется в Go-проектах без фреймворка. Потому что Go даёт минималистичный testing, но не даёт инфраструктуры.
И именно здесь становится очевидно:
Нужен слой, который объединяет инфраструктурный код, структуру тестов, метаданные, фикстуры, параллелизацию, retry и плагины. Слой, который даёт порядок и композицию. Слой, который избавляет от бойлерплейта.
И этот слой — Axiom.
После просмотра анти-примеров становится ясно: основной объём кода в интеграционных тестах — это вовсе не тест. Это инфраструктура. И именно она должна быть вынесена из тестов полностью.
В Axiom эта роль принадлежит Runner — центральной точке конфигурации, где определяется всё: метаданные, плагины, retry-политики, параллелизм, fixtures, контекст.
Тесту остаётся только сценарий.
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 становится точкой сборки тестовой инфраструктуры: единым, предсказуемым и расширяемым слоем, на который опирается весь тестовый набор.
Фикстуры в 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
}Фикстуры позволяют держать инфраструктурный код компактным, декларативным и полностью изолированным от тестов. Тест получает уже готовые ресурсы — в момент, когда они действительно нужны — и никогда не заботится о том, как они создаются, кешируются или очищаются.
Итог: инфраструктура перестаёт быть «мини-фреймворком внутри каждого теста» и превращается в чистый, предсказуемый слой, оформленный в нескольких небольших функциях.
То, что раньше занимало половину файла — подключение клиентов, 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,
фикстуры дают декларативный доступ к ресурсам,
шаги автоматически структурируют сценарий,
контекст даёт типизированное хранилище данных,
плагины берут на себя отчётность, теги, хуки и статистику.
Тест остаётся минимальным, выразительным и не содержит ничего, что не относится непосредственно к проверяемому поведению.
Второй тест повторяет ту же структуру: сценарий описан шагами, инфраструктура скрыта в 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 — это не просто удобная оболочка над testing.T. Это полноценный тестовый runtime, который формирует предсказуемую инфраструктуру вокруг каждого теста: от метаданных до lifecycle-хуков, от фикстур до плагинов.
Ниже — краткий обзор основных механизмов, которые делает доступными Axiom.
Вместо того чтобы вручную расставлять теги и объекты 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(...) — всё декларативно.
Фикстуры в 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-образный.
Axiom поддерживает полный набор хуков, позволяющих расширять поведение тестов без изменения их тела. Все хуки можно навешивать как на Runner (глобально), так и на отдельные Case (локально).
Эти хуки выполняются один раз за весь запуск Runner, вне зависимости от количества тестов.
Хук | Когда вызывается |
|---|---|
| перед запуском первого тест-кейса |
| после последнего тест-кейса (через |
Идеально для:
запуска docker-контейнеров / embedded-сервисов;
прогрева кэша, загрузки конфигурации;
глобальных метрик;
общего teardown.
Хук | Когда вызывается |
|---|---|
| перед выполнением тестового сценария (каждого кейса) |
| после выполнения теста, даже при panic |
Используется для:
логирования начала/конца теста;
создания контекста (trace span, request-id);
pre/post валидаций.
Хук | Когда вызывается |
|---|---|
| перед выполнением шага |
| после шага (включая 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);
расширение поведения без изменения тестов.
Хуки создают события (before/after…), а Wraps — формируют middleware-цепочку, изменяющую само поведение исполнения:
WrapTestAction
WrapStepAction
Именно сочетание Hooks + Wraps превращает Axiom в полноценный execution engine, аналогичный middleware в Gin / Fiber или pytest hooks.
Go не даёт retry механизма. Axiom — да.
runner := axiom.NewRunner(
axiom.WithRunnerRetry(
axiom.WithRetryTimes(3),
axiom.WithRetryDelay(100*time.Millisecond),
),
)Каждый retry создаёт полностью новый Config, что значит:
фикстуры переинициализируются,
контекст чистый,
состояние шага не переиспользуется,
нет скрытых побочных эффектов.
Axiom обеспечивает чистые, изолированные попытки — как это должно работать в реальных E2E тестах.
В 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(),
)Тесты часто требуют передать данные:
между шагами,
из фикстуры в шаг,
из setup-логики в действие.
Вместо переменных на верхнем уровне теста:
cfg.Context.SetData("userID", id)
id := axiom.MustContextValue[string](&cfg.Context, "userID")Контекст — типобезопасный и расширяемый (Runner → Case → Test).
Можно передать параметры тесту:
type LoginParams struct {
User string
Pass string
}
c := axiom.NewCase(
axiom.WithCaseParams(LoginParams{"alice", "123"}),
)
params := axiom.GetParams[LoginParams](cfg)Это идеально для таблиц тестов, датасетов и декларативных сценариев.
Вместо t.Parallel() в случайных местах:
axiom.WithRunnerParallel()
axiom.WithCaseSequential()Case-уровень перекрывает Runner-уровень.
Параллельность перестаёт быть хаотичным флагом в каждом тесте — она становится политикой.
axiom.WithCaseSkip(axiom.SkipBecause("feature disabled"))Axiom не просто пропускает тест — он пропускает всю окружающую инфраструктуру:
фикстуры,
хуки,
шаги,
плагины.
Это критично для CI (например, если сервис временно отключён или environment degraded).
Axiom — это не «удобный синтаксис для тестов». Это полноценная архитектура тестового окружения, в которой:
Runner задаёт стратегию тестирования;
Case формирует декларативный контракт теста;
Config — runtime-снимок окружения;
Fixtures — источник инфраструктуры;
Plugins — точка интеграции с внешним миром;
И всё это работает поверх нативного testing, не заменяя его, а дополняя.
Одно из самых мощных преимуществ Axiom — это архитектура плагинов. Если Runner — это «центр управления тестовой инфраструктурой», то плагины — его «надстройки», которые позволяют менять поведение всей тестовой системы, не изменяя ни строчки тестового кода.
Плагин в Axiom — это всего лишь функция:
type Plugin func(cfg *axiom.Config)Но при этом она может делать практически всё:
модифицировать метаданные теста,
менять правила skip и retry,
навешивать middleware на тест или шаги,
собирать статистику,
внедрять контекст,
подключать внешние системы (Sentry, Datadog, Prometheus…),
фильтровать тесты по тегам,
интегрировать отчётность (например, Allure),
полностью переписывать жизненный цикл теста.
Плагин выполняется до начала теста, получает доступ к runtime-конфигурации (Config) и может свободно её менять.
Это делает Axiom не просто фреймворком, а платформой.
Axiom поставляется с несколькими готовыми плагинами, которые демонстрируют возможности системы.
Плагин 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"}Ни единой проверки в тестах — всё централизовано.
Плагин 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-тесты — ни одного изменения в тестах.
Плагин 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, подключить несколько репортёров одновременно или отключить репортинг полностью — не изменив ни одной строчки тестов.
Это и есть настоящая интеграция через архитектуру, а не через хелперы.
В 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()),
)И теперь каждый шаг в любом тесте будет автоматически логировать своё время.
И не нужно править тесты.
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()
}
})
}
}// 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.