golang

Почему в Go больно писать автотесты (и дело не в синтаксисе)

  • вторник, 9 июня 2026 г. в 00:00:22
https://habr.com/ru/articles/977406/

Вступление

Автотесты на Go обычно начинают с testing.T — и на этом, по сути, всё. Дальше каждый проект вынужден сам решать, как именно исполняются тесты: в каком порядке, с каким окружением, с какими зависимостями, ретраями и логированием.

testing даёт нам минимальный набор примитивов — t.Run, t.Cleanup, TestMain. Этого достаточно для unit-тестов, но за пределами простых сценариев быстро становится ясно: в Go нет единого execution engine, который бы занимался оркестрацией тестов как системы.

И это не недосмотр. Это осознанный дизайн. testing намеренно не является фреймворком — он не управляет жизненным циклом, не знает про фикстуры, шаги, метаданные, ретраи и расширения.

В результате каждая команда:

  • по-своему собирает инфраструктуру вокруг тестов,

  • пишет обёртки, хелперы и внутренние DSL,

  • постепенно превращает тесты в мини-фреймворк.

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

Коротко: в Go не хватает тестовой инфраструктуры, а не assert’ов или DSL.

Дальше — по пунктам, особенно актуальным для интеграционных и E2E-тестов.

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

В Go фикстур, как концепта, по сути нет. Есть только код внутри теста и набор договорённостей: “давай вынесем setup в helper”, “давай будем закрывать через defer”, “давай переиспользуем глобальный клиент”.

Пока тестов мало — это работает. Но как только появляются интеграционные сценарии, БД, брокеры, внешние сервисы и разные окружения, всё начинает расползаться.

Типичная картина в “чистом” Go такая: инфраструктура живёт прямо в тестах. Где-то создаём БД на каждый тест, где-то держим глобально. Где-то не забыли defer Close(), где-то забыли. Где-то один тест внезапно влияет на другой, потому что общий клиент или общий контейнер.

И это не потому что инженеры плохие. Просто testing не даёт модели, где ресурс:

  • создаётся по требованию, а не “всегда в начале”,

  • кешируется в рамках одного прогона,

  • гарантированно чистится,

  • умеет зависеть от других ресурсов,

  • и при ретрае получает чистый жизненный цикл, а не “продолжаем на старом состоянии”.

Как это решает Axiom

В 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.

2. Отсутствие централизованного lifecycle

testing даёт нам t.Run, t.Cleanup и TestMain. Формально — этого достаточно, чтобы что-то собрать. Фактически — это очень низкоуровневые примитивы, которые никак не описывают жизненный цикл теста как системы.

В результате lifecycle в Go-тестах почти всегда существует, но неявно. Он размазан по helper’ам, глобальным переменным и соглашениям внутри команды. Где-то что-то инициализируется “до тестов”, где-то “перед каждым тестом”, где-то “перед шагом” — но нигде это не выражено явно и единообразно.

Особенно это чувствуется, когда хочется сделать что-то кросс-секционное: логирование, трассировку, сбор метрик, отчётность, отладочный вывод. Без централизованного lifecycle единственный способ — вмешиваться в тестовый код.

  • Добавить логирование? → обернуть каждый тест.

  • Добавить тайминги шагов? → руками вокруг каждого вызова.

  • Добавить отчётность? → ещё один слой копипасты.

Почему это системная проблема

Дело не в том, что в Go «нет before/after». Проблема в том, что нет точки, где эти хуки можно определить один раз и быть уверенным, что они сработают всегда и в правильном порядке. TestMain слишком глобален и груб. t.Cleanup слишком локален. Между ними — пустота. И эту пустоту каждая команда заполняет по-своему.

Как это выглядит в модели с execution engine

В 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, а не в сценарии.

3. Retry как часть модели исполнения

В testing ретраев просто нет. Если тест флапает — он либо флапает, либо команда начинает писать ретрай вручную, либо CI становится красным и все делают вид, что «потом разберёмся».

На практике почти в каждом проекте появляется свой велосипед: где-то цикл for, где-то time.Sleep, где-то повторный t.Run. У каждого решения — своё поведение, свои баги и, что хуже всего, никакой изоляции между попытками.

Ретрай в Go обычно выглядит как повторное выполнение того же кода в том же состоянии. А это означает, что:

  • часть ресурсов уже создана,

  • часть данных уже мутирована,

  • фикстуры и окружение находятся в неопределённом состоянии.

Формально тест «перезапустился», но по факту это уже другой сценарий, который сложно отлаживать и невозможно анализировать.

Как это выглядит в execution engine

В 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)
		})
	})
}

Здесь важно не то, что тест “перезапускается”. Важно, что каждая попытка — это новый прогон с нуля, а не продолжение сломанного состояния.

4. Метаданные и теги

В 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 теперь понимает, что именно он запускает, а не просто вызывает набор функций. Это позволяет подключать фильтры, репортинг и аналитику, не встраивая их в тестовый код.

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

5. Шаги как first-class сущность

В Go тест — это просто функция. Фреймворк никак не различает, где в ней подготовка, где действие, а где проверка. Для testing всё это — один непрерывный блок кода.

В результате даже аккуратно написанный тест со временем превращается в портянку: сначала setup, потом бизнес-вызовы, потом проверки, потом cleanup — всё подряд, без структуры и без семантики. Через полгода такой тест сложно читать, а ещё сложнее понять, на каком шаге он упал.

Часто шаги существуют только в голове у автора:

// setup
// act
// assert

или в комментариях. Для execution engine это не шаги, а просто строки кода.

Почему шаги — это не про BDD

Важно подчеркнуть: шаги нужны не для “Given / When / Then” и не для красивых отчётов. Шаг — это минимальная единица сценария, которую можно:

  • логировать,

  • оборачивать,

  • измерять,

  • повторно исполнять,

  • отображать в отчётах.

Пока шаги не являются частью модели исполнения, все эти задачи решаются вручную и всегда по-разному.

Как это выглядит, когда шаги — часть runtime

В 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, они существуют только в комментариях и договорённостях. Когда шаги становятся частью модели исполнения, тесты начинают масштабироваться — не по количеству строк, а по сложности сценариев.

6. Разделение инфраструктуры и сценария

В Go инфраструктура и сценарий почти всегда живут в одном месте — внутри теста. Один и тот же код одновременно:

  • поднимает окружение,

  • создаёт данные,

  • вызывает бизнес-логику,

  • проверяет результат,

  • логирует,

  • и в конце пытается всё аккуратно прибрать.

Со временем такой тест перестаёт быть тестом. Он превращается в маленький фреймворк, который сложно читать, ещё сложнее менять и почти невозможно переиспользовать.

Проблема здесь не в дисциплине и не в стиле кода. Просто testing не даёт слоя, где инфраструктура могла бы жить отдельно от сценария.

Почему это неизбежно в 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")
		})
	})
}

Здесь важно не количество опций, а сам факт разделения. Инфраструктура живёт в одном месте и меняется централизованно. Сценарии остаются короткими, читаемыми и стабильными.

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

7. Плагины и расширяемость

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

Хочешь:

  • централизованно логировать шаги,

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

  • подключить Allure,

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

  • добавить трассировку или тайминги,

→ готовься править код тестов или писать очередную обёртку вокруг go test. Каждое улучшение превращается в миграцию всей тестовой базы.

Почему без плагинов execution engine не масштабируется

Как только тестов становится много, почти сразу появляются поперечные задачи: логирование, метрики, отчёты, фильтры, интеграции с CI. Это не задачи тестов. Это задачи runtime’а, который эти тесты исполняет.

Если execution engine нельзя расширять снаружи, он перестаёт быть платформой и остаётся просто утилитой.

Модель плагинов в 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 превращается в платформу:

  • отчётность подключается как модуль,

  • фильтрация тестов — как модуль,

  • логирование и метрики — как модуль.

Тесты при этом остаются чистыми и сфокусированными на сценарии, а не на обслуживании инфраструктуры.

8. Управление параллельностью

В Go параллельность тестов включается через t.Parallel(). Это просто флаг внутри тестовой функции, который говорит рантайму: “этот тест можно запускать параллельно с другими”.

Проблема в том, что на этом возможности управления заканчиваются.

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

Параллельность в testing живёт внутри тестов, хотя по своей природе она относится к исполнению, а не к сценарию.

Почему это плохо масштабируется

Параллельность — это политика, а не деталь сценария. Тест не должен решать:

  • когда и с кем он выполняется,

  • сколько ресурсов ему доступно,

  • можно ли его запускать одновременно с другими.

Когда эта логика зашита в t.Parallel(), любые изменения превращаются в массовый рефакторинг и создают риск поломать изоляцию тестов

Как это выглядит в execution engine

В модели с 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.