Почему в Go больно писать автотесты (и дело не в синтаксисе)
- вторник, 9 июня 2026 г. в 00:00:22
Автотесты на Go обычно начинают с testing.T — и на этом, по сути, всё. Дальше каждый проект вынужден сам решать, как именно исполняются тесты: в каком порядке, с каким окружением, с какими зависимостями, ретраями и логированием.
testing даёт нам минимальный набор примитивов — t.Run, t.Cleanup, TestMain. Этого достаточно для unit-тестов, но за пределами простых сценариев быстро становится ясно: в Go нет единого execution engine, который бы занимался оркестрацией тестов как системы.
И это не недосмотр. Это осознанный дизайн. testing намеренно не является фреймворком — он не управляет жизненным циклом, не знает про фикстуры, шаги, метаданные, ретраи и расширения.
В результате каждая команда:
по-своему собирает инфраструктуру вокруг тестов,
пишет обёртки, хелперы и внутренние DSL,
постепенно превращает тесты в мини-фреймворк.
В этой статье я разберу ключевые системные боли автотестов на Go — не на уровне синтаксиса, а на уровне исполнения — и на практических примерах покажу, какого слоя не хватает и как он может выглядеть.
Коротко: в Go не хватает тестовой инфраструктуры, а не assert’ов или DSL.
Дальше — по пунктам, особенно актуальным для интеграционных и E2E-тестов.
В Go фикстур, как концепта, по сути нет. Есть только код внутри теста и набор договорённостей: “давай вынесем setup в helper”, “давай будем закрывать через defer”, “давай переиспользуем глобальный клиент”.
Пока тестов мало — это работает. Но как только появляются интеграционные сценарии, БД, брокеры, внешние сервисы и разные окружения, всё начинает расползаться.
Типичная картина в “чистом” Go такая: инфраструктура живёт прямо в тестах. Где-то создаём БД на каждый тест, где-то держим глобально. Где-то не забыли defer Close(), где-то забыли. Где-то один тест внезапно влияет на другой, потому что общий клиент или общий контейнер.
И это не потому что инженеры плохие. Просто testing не даёт модели, где ресурс:
создаётся по требованию, а не “всегда в начале”,
кешируется в рамках одного прогона,
гарантированно чистится,
умеет зависеть от других ресурсов,
и при ретрае получает чистый жизненный цикл, а не “продолжаем на старом состоянии”.
В Axiom фикстура — это обычная функция. Без рефлексии, без магии, без контейнера зависимостей “как в Java”. Но при этом у неё появляется то, чего не хватает testing: понятный жизненный цикл.
Модель простая:
фикстура лениво создаётся при первом доступе,
кешируется до конца попытки,
cleanup запускается автоматически,
фикстуры могут зависеть друг от друга,
при ретрае все фикстуры пересоздаются заново (изоляция попыток).
Ниже пример: одна фикстура создаёт “БД”, вторая зависит от неё и создаёт пользователя. В тесте мы просто запрашиваем нужные ресурсы через GetFixture.
package example_test import ( "fmt" "testing" "github.com/Nikita-Filonov/axiom" ) // DBFixture — created once per test attempt, cleaned up automatically. func DBFixture(cfg *axiom.Config) (any, func(), error) { db := fmt.Sprintf("db-%s", cfg.ID) cleanup := func() { fmt.Println("closing:", db) } return db, cleanup, nil } // UserFixture — depends on the DB fixture via GetFixture. func UserFixture(cfg *axiom.Config) (any, func(), error) { db := axiom.GetFixture[string](cfg, "db") user := fmt.Sprintf("user-from-%s", db) return user, nil, nil } func TestFixtureExample(t *testing.T) { runner := axiom.NewRunner( axiom.WithRunnerFixture("db", DBFixture), ) c := axiom.NewCase( axiom.WithCaseName("fixture example"), axiom.WithCaseFixture("user", UserFixture), ) runner.RunCase(t, c, func(cfg *axiom.Config) { db := axiom.GetFixture[string](cfg, "db") fmt.Println("using:", db) again := axiom.GetFixture[string](cfg, "db") fmt.Println("cached:", again) user := axiom.GetFixture[string](cfg, "user") fmt.Println("user:", user) cfg.Step("validate", func() { fmt.Println("validating...") }) }) }
Главное, что меняется: тест перестаёт быть местом, где ты “вручную собираешь окружение”. Он становится сценарием, который запрашивает ресурсы, а lifecycle и порядок жизни этих ресурсов — задача execution engine.
testing даёт нам t.Run, t.Cleanup и TestMain. Формально — этого достаточно, чтобы что-то собрать. Фактически — это очень низкоуровневые примитивы, которые никак не описывают жизненный цикл теста как системы.
В результате lifecycle в Go-тестах почти всегда существует, но неявно. Он размазан по helper’ам, глобальным переменным и соглашениям внутри команды. Где-то что-то инициализируется “до тестов”, где-то “перед каждым тестом”, где-то “перед шагом” — но нигде это не выражено явно и единообразно.
Особенно это чувствуется, когда хочется сделать что-то кросс-секционное: логирование, трассировку, сбор метрик, отчётность, отладочный вывод. Без централизованного lifecycle единственный способ — вмешиваться в тестовый код.
Добавить логирование? → обернуть каждый тест.
Добавить тайминги шагов? → руками вокруг каждого вызова.
Добавить отчётность? → ещё один слой копипасты.
Дело не в том, что в Go «нет before/after». Проблема в том, что нет точки, где эти хуки можно определить один раз и быть уверенным, что они сработают всегда и в правильном порядке. TestMain слишком глобален и груб. t.Cleanup слишком локален. Между ними — пустота. И эту пустоту каждая команда заполняет по-своему.
В Axiom lifecycle становится частью модели исполнения тестов, а не побочным эффектом структуры кода.
Появляется чёткое разделение:
lifecycle всего набора тестов,
lifecycle отдельного теста,
lifecycle шагов внутри теста.
При этом хуки не управляют выполнением — они его наблюдают. Они не меняют control flow, не «оборачивают бизнес-логику», а просто гарантированно срабатывают до и после нужных фаз, даже если внутри был panic.
Важно, что хуки можно определить:
глобально — на уровне Runner,
локально — на уровне конкретного теста.
Из них собирается единый, предсказуемый pipeline исполнения.
Ниже пример, где один Runner задаёт глобальные хуки, а тест описывает только сценарий. Вся инфраструктурная логика живёт снаружи.
package example_test import ( "fmt" "testing" "github.com/Nikita-Filonov/axiom" ) func beforeAll(r *axiom.Runner) { fmt.Println("→ before all") } func afterAll(r *axiom.Runner) { fmt.Println("→ after all") } func beforeTest(c *axiom.Config) { fmt.Println("→ before test") } func afterTest(c *axiom.Config) { fmt.Println("→ after test") } func beforeStep(c *axiom.Config, n string) { fmt.Println("→ before step:", n) } func afterStep(c *axiom.Config, n string) { fmt.Println("→ after step:", n) } var runner = axiom.NewRunner( axiom.WithRunnerHooks( axiom.WithBeforeAll(beforeAll), axiom.WithAfterAll(afterAll), axiom.WithBeforeTest(beforeTest), axiom.WithAfterTest(afterTest), axiom.WithBeforeStep(beforeStep), axiom.WithAfterStep(afterStep), ), ) func TestHooksExample(t *testing.T) { c := axiom.NewCase( axiom.WithCaseName("hooks example"), ) runner.RunCase(t, c, func(cfg *axiom.Config) { cfg.Step("prepare", func() { fmt.Println("doing prepare...") }) cfg.Test(func(_ *axiom.Config) { fmt.Println("inside test body") }) cfg.Step("finish", func() { fmt.Println("finishing...") }) }) }
Важный момент здесь не в API, а в модели: порядок выполнения заранее определён, централизован и одинаков для всех тестов. Логирование, метрики, отчёты и прочие поперечные задачи можно добавлять и убирать, не трогая сами тесты.
Без централизованного lifecycle тесты в Go неизбежно начинают содержать в себе инфраструктуру. С lifecycle — инфраструктура возвращается туда, где ей и место: в execution engine, а не в сценарии.
В testing ретраев просто нет. Если тест флапает — он либо флапает, либо команда начинает писать ретрай вручную, либо CI становится красным и все делают вид, что «потом разберёмся».
На практике почти в каждом проекте появляется свой велосипед: где-то цикл for, где-то time.Sleep, где-то повторный t.Run. У каждого решения — своё поведение, свои баги и, что хуже всего, никакой изоляции между попытками.
Ретрай в Go обычно выглядит как повторное выполнение того же кода в том же состоянии. А это означает, что:
часть ресурсов уже создана,
часть данных уже мутирована,
фикстуры и окружение находятся в неопределённом состоянии.
Формально тест «перезапустился», но по факту это уже другой сценарий, который сложно отлаживать и невозможно анализировать.
В Axiom ретрай становится частью модели исполнения теста.
Каждая попытка:
создаёт новый runtime Config,
заново инициализирует фикстуры,
повторно запускает lifecycle-хуки,
полностью изолирована от предыдущей.
Политика ретраев задаётся централизованно — на уровне Runner — и при необходимости может быть переопределена для конкретного теста. Это позволяет отделить поведение системы от сценария теста.
Ниже пример: для всего набора тестов задана одна политика ретраев, а конкретный тест переопределяет её под себя.
package example_test import ( "fmt" "testing" "time" "github.com/Nikita-Filonov/axiom" ) func TestRetryExample(t *testing.T) { runner := axiom.NewRunner( axiom.WithRunnerRetry( axiom.WithRetryTimes(2), axiom.WithRetryDelay(1*time.Second), ), ) c := axiom.NewCase( axiom.WithCaseName("retry example"), axiom.WithCaseRetry( axiom.WithRetryTimes(3), axiom.WithRetryDelay(500*time.Millisecond), ), ) attempt := 0 runner.RunCase(t, c, func(cfg *axiom.Config) { attempt++ fmt.Println("attempt:", attempt) if attempt < 3 { t.Fail() // triggers retry } cfg.Step("finalize", func() { fmt.Println("success on attempt", attempt) }) }) }
Здесь важно не то, что тест “перезапускается”. Важно, что каждая попытка — это новый прогон с нуля, а не продолжение сломанного состояния.
В testing у теста нет никаких метаданных. Тест — это просто функция с именем, и всё, что мы можем о нём сказать, — это строка в TestSomething.
Пока тестов мало, это не кажется проблемой. Но как только тестовая база начинает расти, быстро выясняется, что без метаданных невозможно управлять выполнением тестов как системой.
Нельзя явно сказать:
какие тесты относятся к интеграционным,
какие можно запускать в smoke,
какие критичны для релиза,
какие принадлежат конкретной фиче или сервису.
В результате вся эта информация либо живёт в комментариях, либо кодируется в именах тестов, либо размазывается по CI-скриптам и сторонним интеграциям. Тесты при этом остаются «немыми» — execution engine ничего о них не знает.
В Axiom метаданные — это отдельный, декларативный слой. Они не влияют на выполнение теста напрямую и не меняют control flow. Их задача — описать тест, чтобы execution engine и плагины могли с ним работать.
Метаданные можно задать:
глобально, на уровне Runner,
локально, на уровне конкретного теста.
При этом они предсказуемо объединяются, а локальные значения могут переопределять глобальные. Тест остаётся тестом, а вся логика фильтрации, отчётности и аналитики живёт снаружи.
Ниже пример, где общая бизнес-область и уровень критичности задаются один раз, а конкретный сценарий дополняет их деталями.
package example_test import ( "fmt" "testing" "github.com/Nikita-Filonov/axiom" ) func TestMetaExample(t *testing.T) { runner := axiom.NewRunner( axiom.WithRunnerMeta( axiom.WithMetaEpic("authentication"), axiom.WithMetaFeature("login"), axiom.WithMetaSeverity(axiom.SeverityCritical), axiom.WithMetaTag("regression"), axiom.WithMetaLabel("team", "backend"), ), ) c := axiom.NewCase( axiom.WithCaseName("user can authenticate"), axiom.WithCaseMeta( axiom.WithMetaStory("valid login flow"), axiom.WithMetaTag("smoke"), axiom.WithMetaLayer("api"), axiom.WithMetaLabel("component", "auth-service"), axiom.WithMetaSeverity(axiom.SeverityBlocker), ), ) runner.RunCase(t, c, func(cfg *axiom.Config) { meta := cfg.Meta fmt.Println("Epic:", meta.Epic) fmt.Println("Feature:", meta.Feature) fmt.Println("Story:", meta.Story) fmt.Println("Layer:", meta.Layer) fmt.Println("Severity:", meta.Severity) fmt.Println("Tags:", meta.Tags) fmt.Println("Labels:", meta.Labels) cfg.Step("validate", func() { fmt.Println("validating...") }) }) }
Важно, что execution engine теперь понимает, что именно он запускает, а не просто вызывает набор функций. Это позволяет подключать фильтры, репортинг и аналитику, не встраивая их в тестовый код.
Без метаданных тесты невозможно классифицировать и масштабировать. С метаданными тесты становятся частью системы, а не просто набором функций.
В Go тест — это просто функция. Фреймворк никак не различает, где в ней подготовка, где действие, а где проверка. Для testing всё это — один непрерывный блок кода.
В результате даже аккуратно написанный тест со временем превращается в портянку: сначала setup, потом бизнес-вызовы, потом проверки, потом cleanup — всё подряд, без структуры и без семантики. Через полгода такой тест сложно читать, а ещё сложнее понять, на каком шаге он упал.
Часто шаги существуют только в голове у автора:
// setup // act // assert
или в комментариях. Для execution engine это не шаги, а просто строки кода.
Важно подчеркнуть: шаги нужны не для “Given / When / Then” и не для красивых отчётов. Шаг — это минимальная единица сценария, которую можно:
логировать,
оборачивать,
измерять,
повторно исполнять,
отображать в отчётах.
Пока шаги не являются частью модели исполнения, все эти задачи решаются вручную и всегда по-разному.
В Axiom шаг — это осознанная сущность. Он имеет имя, границы и lifecycle. Execution engine знает, когда шаг начинается и когда заканчивается, и может вокруг этого делать всё, что угодно: логирование, трассировку, отчётность, хуки, плагины.
При этом сам тест остаётся обычным Go-кодом и не превращается в DSL.
func TestRunnerExample(t *testing.T) { c := axiom.NewCase( axiom.WithCaseName("user can log in"), axiom.WithCaseMeta(axiom.WithMetaTag("smoke")), ) runner.RunCase(t, c, func(cfg *axiom.Config) { db := axiom.GetFixture[string](cfg, "db") fmt.Println("using fixture:", db) cfg.Step("login", func() { fmt.Println("perform login") }) cfg.Step("validate", func() { fmt.Println("validate result") }) }) }
Здесь шаги — это не просто визуальное деление кода. Это точки, в которых execution engine может:
зафиксировать начало и конец операции,
понять, на каком этапе произошёл сбой,
привязать логи и артефакты к конкретному действию.
Когда шаги становятся first-class сущностью, тест перестаёт быть «простынёй кода» и превращается в сценарий, который можно читать и анализировать.
Инфраструктура (логирование, хуки, отчёты) работает вокруг шагов, а не вшивается внутрь тестов. Это резко снижает шум в коде и делает тесты устойчивыми к росту сложности.
Пока шаги не являются частью execution engine, они существуют только в комментариях и договорённостях. Когда шаги становятся частью модели исполнения, тесты начинают масштабироваться — не по количеству строк, а по сложности сценариев.
В Go инфраструктура и сценарий почти всегда живут в одном месте — внутри теста. Один и тот же код одновременно:
поднимает окружение,
создаёт данные,
вызывает бизнес-логику,
проверяет результат,
логирует,
и в конце пытается всё аккуратно прибрать.
Со временем такой тест перестаёт быть тестом. Он превращается в маленький фреймворк, который сложно читать, ещё сложнее менять и почти невозможно переиспользовать.
Проблема здесь не в дисциплине и не в стиле кода. Просто testing не даёт слоя, где инфраструктура могла бы жить отдельно от сценария.
В стандартной модели Go тест и есть точка исполнения. Если тесту нужен логгер, контекст, фикстуры, хуки, ретраи или параллельность — всё это приходится описывать прямо внутри него или рядом, через глобальные переменные и helper’ы.
В итоге:
сценарий знает слишком много,
инфраструктура протекает в бизнес-логику,
любое изменение execution-поведения требует правок в тестах.
Чем больше тестов, тем дороже становится их поддержка.
В модели с execution engine эти роли разделяются.
Сценарий отвечает только на вопрос: что мы тестируем и в каком порядке выполняются шаги.
Инфраструктура отвечает на вопрос: как именно этот сценарий исполняется — с какими ретраями, хуками, логированием, контекстом, параллельностью и окружением.
В Axiom этот инфраструктурный слой описывается через Runner. Runner не описывает тест. Он описывает среду выполнения, в которой тесты запускаются.
Runner задаёт общие правила для всех тестов: метаданные, фикстуры, ретраи, хуки, плагины, параллельность. Тест же описывает только сценарий и при необходимости слегка переопределяет глобальные настройки.
package example_test import ( "fmt" "testing" "time" "github.com/Nikita-Filonov/axiom" ) func DBFixture(cfg *axiom.Config) (any, func(), error) { db := fmt.Sprintf("db-%s", cfg.ID) cleanup := func() { fmt.Println("closing:", db) } return db, cleanup, nil } func LoggingPlugin() axiom.Plugin { return func(cfg *axiom.Config) { cfg.Runtime.EmitTestWrap(func(next axiom.TestAction) axiom.TestAction { return func(c *axiom.Config) { start := time.Now() next(c) fmt.Println("duration:", time.Since(start)) } }) } } func beforeTest(c *axiom.Config) { fmt.Println("→ before test") } func afterTest(c *axiom.Config) { fmt.Println("→ after test") } func beforeStep(c *axiom.Config, _ string) { fmt.Println("→ before step") } func afterStep(c *axiom.Config, _ string) { fmt.Println("→ after step") } var runner = axiom.NewRunner( axiom.WithRunnerMeta( axiom.WithMetaEpic("authentication"), axiom.WithMetaFeature("login"), axiom.WithMetaTag("regression"), ), axiom.WithRunnerRetry( axiom.WithRetryTimes(3), axiom.WithRetryDelay(50), ), axiom.WithRunnerHooks( axiom.WithBeforeTest(beforeTest), axiom.WithAfterTest(afterTest), axiom.WithBeforeStep(beforeStep), axiom.WithAfterStep(afterStep), ), axiom.WithRunnerPlugins( LoggingPlugin(), ), axiom.WithRunnerParallel(), axiom.WithRunnerFixture("db", DBFixture), ) func TestRunnerExample(t *testing.T) { c := axiom.NewCase( axiom.WithCaseName("user can log in"), axiom.WithCaseMeta(axiom.WithMetaTag("smoke")), ) runner.RunCase(t, c, func(cfg *axiom.Config) { db := axiom.GetFixture[string](cfg, "db") fmt.Println("using fixture:", db) cfg.Step("login", func() { fmt.Println("perform login") }) cfg.Step("validate", func() { fmt.Println("validate result") }) }) }
Здесь важно не количество опций, а сам факт разделения. Инфраструктура живёт в одном месте и меняется централизованно. Сценарии остаются короткими, читаемыми и стабильными.
Пока инфраструктура и сценарий смешаны, тесты неизбежно разрастаются и деградируют. Когда инфраструктура выносится в отдельный слой, тесты снова становятся тестами, а не набором технических деталей.
В testing нет понятия расширяемости во время выполнения. Если нужно добавить логирование, отчётность, фильтрацию тестов или сбор статистики — единственный вариант это лезть в тесты или оборачивать их снаружи скриптами.
Хочешь:
централизованно логировать шаги,
собрать статистику по тестам,
подключить Allure,
отфильтровать тесты по тегам,
добавить трассировку или тайминги,
→ готовься править код тестов или писать очередную обёртку вокруг go test. Каждое улучшение превращается в миграцию всей тестовой базы.
Как только тестов становится много, почти сразу появляются поперечные задачи: логирование, метрики, отчёты, фильтры, интеграции с CI. Это не задачи тестов. Это задачи runtime’а, который эти тесты исполняет.
Если execution engine нельзя расширять снаружи, он перестаёт быть платформой и остаётся просто утилитой.
В Axiom расширяемость решается через плагины. Плагин — это обычная Go-функция, которая:
не запускает тесты,
не содержит бизнес-логики,
а декорирует выполнение, подключаясь к lifecycle и runtime.
Плагины применяются:
глобально, на уровне Runner,
или локально, на уровне конкретного теста.
Важно, что плагины:
применяются в предсказуемом порядке,
не требуют изменения ядра,
не заставляют переписывать тесты.
Execution engine остаётся стабильным, а поведение вокруг него можно наращивать.
Вот плагин, который просто измеряет время выполнения теста. Никаких магических API — только оборачивание runtime’а.
package myplugin import ( "fmt" "time" "github.com/Nikita-Filonov/axiom" ) func Plugin() axiom.Plugin { return func(cfg *axiom.Config) { cfg.Runtime.EmitTestWrap(func(next axiom.TestAction) axiom.TestAction { return func(c *axiom.Config) { start := time.Now() next(c) fmt.Println("test took:", time.Since(start)) } }) } }
Подключение такого плагина не требует менять ни один тест — он просто начинает работать вокруг них.
За счёт плагинов execution engine превращается в платформу:
отчётность подключается как модуль,
фильтрация тестов — как модуль,
логирование и метрики — как модуль.
Тесты при этом остаются чистыми и сфокусированными на сценарии, а не на обслуживании инфраструктуры.
В Go параллельность тестов включается через t.Parallel(). Это просто флаг внутри тестовой функции, который говорит рантайму: “этот тест можно запускать параллельно с другими”.
Проблема в том, что на этом возможности управления заканчиваются.
Хочешь изменить стратегию параллельности — например, отключить её глобально, включить только для части тестов или временно убрать из-за нестабильного окружения — готовься ходить по коду и править десятки файлов.
Параллельность в testing живёт внутри тестов, хотя по своей природе она относится к исполнению, а не к сценарию.
Параллельность — это политика, а не деталь сценария. Тест не должен решать:
когда и с кем он выполняется,
сколько ресурсов ему доступно,
можно ли его запускать одновременно с другими.
Когда эта логика зашита в t.Parallel(), любые изменения превращаются в массовый рефакторинг и создают риск поломать изоляцию тестов
В модели с execution engine параллельность становится настройкой среды выполнения, а не частью тестового кода.
В Axiom режим параллельности задаётся:
глобально, на уровне Runner,
и при необходимости переопределяется для конкретного теста.
Тест при этом остаётся тем же самым — меняется только то, как он планируется к выполнению.
package example_test import ( "fmt" "testing" "github.com/Nikita-Filonov/axiom" ) func TestParallelExample(t *testing.T) { runner := axiom.NewRunner( axiom.WithRunnerParallel(), ) c := axiom.NewCase( axiom.WithCaseName("runs sequentially"), axiom.WithCaseSequential(), ) runner.RunCase(t, c, func(cfg *axiom.Config) { fmt.Println("Parallel enabled:", cfg.Parallel.Enabled) cfg.Step("work", func() { fmt.Println("performing work...") }) }) }
Важно, что параллельность здесь:
не влияет на порядок шагов внутри теста,
не ломает lifecycle,
корректно работает с фикстурами, хуками и ретраями.
Execution engine просто использует эту информацию при планировании тестов.
На просторах Go-экосистемы уже существуют библиотеки, которые пытаются закрыть проблему отсутствия test runtime. Чаще всего они делают это, плотно встраивая модель исполнения прямо в тестовый API или в систему отчётности.
Такой подход работает, но имеет цену: execution engine, шаги, хуки, метаданные и репортинг сливаются в один слой. Тесты начинают писаться под конкретный инструмент, а не под testing, и со временем оказываются жёстко с ним связаны.
В этом подходе нет ничего «неправильного» — он просто решает другую задачу. Axiom же сознательно остаётся execution engine’ом, который работает поверх стандартного testing, не подменяя его и не навязывая собственный DSL. Репортинг, логирование и аналитика в нём вынесены в плагины и могут подключаться или отключаться независимо.
Это делает Axiom не альтернативой testing, а недостающим слоем между тестами и их исполнением.
Если свести всё к одной фразе, то картина получается простой:
Go даёт testing, но не даёт test runtime.
testing отлично справляется со своей задачей — быть минимальным, предсказуемым и простым. Этого достаточно для unit-тестов и небольших проектов. Но как только тесты перестают быть “просто функциями” и превращаются в систему, начинают проявляться одни и те же проблемы.
Интеграционные и E2E-тесты быстро обрастают инфраструктурой. Команды вынуждены решать вопросы lifecycle, фикстур, ретраев, шагов, метаданных, параллельности и отчётности — каждый раз с нуля. Со временем вокруг testing вырастает собственный фреймворк, поддержка которого становится отдельной задачей.
Именно поэтому в Go-проектах так часто появляются: внутренние DSL, обёртки над testing, кастомные раннеры и бесконечные велосипеды. Это не мода и не ошибка — это попытка закрыть отсутствующий слой исполнения.
В этой статье я показал, какого слоя не хватает и как он может выглядеть: с централизованным lifecycle, фикстурами, ретраями, шагами, метаданными, плагинами и управляемой параллельностью. Axiom — это одна из возможных реализаций такой модели, без магии и без отказа от стандартного go test.
Если идеи из статьи тебе близки — можешь посмотреть на Axiom и, если проект показался полезным, поставить ⭐️ на GitHub. Это хороший способ поддержать развитие test runtime’а для Go.