Приводим Go-автотесты в порядок: от helper-ов к нормальной модели исполнения
Вступление
Речь пойдёт не про unit-тесты.
Если нужно проверить маленькую функцию, чистую бизнес-логику, пару входных значений и ожидаемый результат, то стандартного testing, testify/require и table-driven tests обычно хватает с запасом. Туда не надо тащить отдельный runtime, suite, fixture registry, plugin pipeline и прочую тяжёлую артиллерию. Unit-тесты хороши именно тогда, когда они остаются маленькими.
Но есть другой класс тестов.
API-тесты. Интеграционные тесты. e2e. Сценарии вокруг микросервисов, gRPC, HTTP-клиентов, Kafka, баз данных, внешних систем, тестовых стендов, отчётов и CI.
Вот там начинается боль.
Сначала тест выглядит невинно. Создали клиента, подготовили пользователя, вызвали метод, проверили ответ. Потом добавили Allure. Потом теги. Потом t.Parallel(). Потом общий Before. Потом генератор данных. Потом ожидания через Eventually. Потом cleanup. Потом retry, потому что стенд иногда “дышит”. Потом ещё один клиент. Потом Kafka. Потом условный skip по переменной окружения.
И через полгода тест уже не тест. Это кусок фреймворка, написанный прямо внутри func Test.... Просто команда продолжает делать вид, что у неё “обычные Go-тесты”.
Как это обычно выглядит
Я видел такой код много раз. Названия меняются, домены меняются, компании меняются, но форма почти всегда узнаваемая.
Пример ниже выдуманный, но боль вполне реальная.
func TestUserCanSeeActivePaymentMethods(t *testing.T) {
t.Parallel()
SetTestTags(t,
REGRESSION,
PAYMENTS,
MOBILE_GATEWAY,
ACCOUNT_SERVICE,
EXTERNAL_PROVIDER,
)
report.Test(t,
report.Layer("integration"),
report.Feature("payment methods"),
report.Story("list active methods"),
report.ID("11111"),
report.Name("User sees active payment methods from provider and autopayments"),
report.Action(func() {
c, g := Before(t)
user := CreateMobileUser(c, g)
firstMethod := GenerateExternalPaymentMethod(c, g)
secondMethod := GenerateExternalPaymentMethod(c, g)
c.ExternalProvider.SetPaymentMethod(g, ProviderPaymentMethod{
UserDocument: user.DocumentID,
MethodID: firstMethod.ID,
BankName: firstMethod.BankName,
})
createAutopaymentRequest := gateway.CreateAutopaymentRequest{
IdempotencyKey: uuid.NewString(),
AccountID: user.AccountID,
MethodID: secondMethod.ID,
UserID: user.ID,
}
c.Accounts.CreateDebitConsentSuccess(g, user.ID, secondMethod.ID)
c.MobileGateway.CreateAutopaymentSuccess(g, createAutopaymentRequest)
response := c.MobileGateway.ListPaymentMethodsSuccess(g, user.ID)
methods := response.GetMethods()
g.Expect(methods).ShouldNot(gomega.BeNil())
g.Expect(len(methods)).Should(gomega.Equal(2))
if compareAlphabetically(firstMethod.BankName, secondMethod.BankName) {
g.Expect(methods[0].ID).Should(gomega.Equal(firstMethod.ID))
g.Expect(methods[0].Bank.Name).Should(gomega.Equal(firstMethod.BankName))
g.Expect(methods[1].ID).Should(gomega.Equal(secondMethod.ID))
g.Expect(methods[1].Bank.Name).Should(gomega.Equal(secondMethod.BankName))
} else {
g.Expect(methods[1].ID).Should(gomega.Equal(firstMethod.ID))
g.Expect(methods[1].Bank.Name).Should(gomega.Equal(firstMethod.BankName))
g.Expect(methods[0].ID).Should(gomega.Equal(secondMethod.ID))
g.Expect(methods[0].Bank.Name).Should(gomega.Equal(secondMethod.BankName))
}
}),
)
}
Знакомо?
Половина теста — не сценарий. Теги. Репортинг. Before. Клиенты. Генераторы. Подготовка данных. Интеграции между сервисами. Проверка порядка. Непонятно, кто чем владеет. Непонятно, кто что чистит. Непонятно, что будет при retry. Непонятно, какие данные относятся к тесту, а какие к окружению.
Да, это можно читать. Но это не значит, что с этим нормально жить.
Внутри одной функции сидят сразу несколько разных ролей. Тест одновременно запускает сценарий, собирает окружение, управляет отчётностью, настраивает assertion framework, создаёт данные, ходит в сервисы, знает про внешнего провайдера и ещё пытается выглядеть как обычный Go-тест.
Тут обычно говорят: “Ну вынесем в helper-ы”.
Выносят.
- Появляется
Before(t). - Потом
Init(t). - Потом
InitWithModules(t, ...). - Потом
CreateUser. - Потом
CreateUserWithAccount. - Потом
CreateUserWithAccountAndActivePaymentMethod. - Потом
CreateUserWithAccountAndArchivedPaymentMethodAndProviderData.
Через какое-то время helper-ы начинают размножаться быстрее тестов. И вот здесь надо сказать неприятную вещь. Helper-ы — это не архитектура. Helper-ы помогают убрать локальный мусор. Они не создают модель исполнения.
У вас уже есть фреймворк. Просто плохой
Многие команды думают, что у них нет тестового фреймворка.
- Есть стандартный
testing. - Есть
testifyилиgomega. - Есть Allure.
- Есть несколько helper-ов.
- Есть пакет
testutils. - Есть общий
Before. - Есть клиенты.
- Есть генераторы.
- Есть
SetTestTags. - Есть
SkipTest. - Есть
StepWithDescription. - Есть
InitWithModules.
Фреймворка вроде бы нет. На самом деле он уже есть. Просто он распределён по проекту, не имеет имени и живёт на договорённостях.
Один helper создаёт клиента и кладёт его в структуру App. Второй helper создаёт пользователя. Третий регистрирует cleanup. Четвёртый выставляет теги. Пятый оборачивает тест в отчёт. Шестой настраивает gomega fail handler. Седьмой знает, какие модули надо поднять для конкретного сценария. Восьмой отправляет событие в Kafka, потому что “так быстрее, чем ждать настоящий процесс”.
Всё это уже execution layer. Просто он не описан как execution layer.
Самое неприятное начинается тогда, когда появляются lifetime-ы. Клиент хочется создать один раз. Пользователя надо создать на тест. Данные после неудачной попытки retry должны быть другими. Отчёт должен видеть step. Cleanup должен сработать после fixture, но общий ресурс нельзя закрыть после первого case. Retry должен повторить сценарий, но не должен тащить за собой испорченное состояние первой попытки.
Вот тут helper-ы обычно начинают скрипеть. Потому что helper отвечает на вопрос “как сократить код”. А сложным автотестам нужен ответ на другой вопрос: кто владеет исполнением?
Автотест — это маленький процесс
API/e2e-тест — это уже не просто функция. Это маленький процесс со своим окружением, состоянием, входными параметрами, ресурсами, данными, шагами и cleanup-ом.
Go честно даёт вам testing.T. Дальше делайте что хотите.
И это нормально. Стандартный testing не обязан знать, где у вас внешний провайдер, где gRPC-клиент, где Kafka, где Allure, где тестовые данные, где retry, где suite-level lifecycle.
Но кто-то должен это знать. Если такого слоя нет, его роль забирает на себя тестовая функция. А тестовая функция для этого плохо подходит. Она быстро превращается в свалку из setup-а, сценария, инфраструктуры и отчётности.
Поэтому сложным Go-автотестам нужен не DSL. Им нужен execution engine. Не новый язык поверх Go. Не Describe/It. Не магический BDD. Не попытка спрятать testing. Нужен скучный слой, который отвечает за исполнение.
В Axiom этот слой строится вокруг простой модели:
Runner + Case -> Config -> Runtime execution
Это почти вся идея.
Runner задаёт среду исполнения. Case описывает конкретный тест. Config становится runtime-снимком одной попытки. Runtime управляет тем, как исполняются test body и steps.
Дальше появляются более конкретные границы. Fixture живёт на уровне попытки. Resource живёт на уровне runner-а. Plugin расширяет execution pipeline. Suite задаёт границу для группы связанных cases.
Звучит скучно. И это прекрасно. В тестовой инфраструктуре скука обычно означает, что кто-то наконец-то разложил ответственность по местам.
Runner — место, где живёт среда исполнения


Runner описывает среду, в которой сценарии будут исполняться.
var runner = axiom.NewRunner(
axiom.WithRunnerMeta(
axiom.WithMetaEpic("payments"),
axiom.WithMetaFeature("payment methods"),
axiom.WithMetaLayer("integration"),
axiom.WithMetaTag("regression"),
),
axiom.WithRunnerContext(
axiom.WithContextData("env", "staging"),
axiom.WithContextData("service", "mobile-gateway"),
),
axiom.WithRunnerRetry(
axiom.WithRetryTimes(3),
axiom.WithRetryDelay(500*time.Millisecond),
),
axiom.WithRunnerResource("api-client", APIClientResource),
axiom.WithRunnerFixture("user", UserFixture),
axiom.WithRunnerRuntime(
axiom.WithRuntimeTestWrap(timingWrap),
axiom.WithRuntimeStepWrap(reportingStepWrap),
),
axiom.WithRunnerPlugins(
AllurePlugin(),
StatsPlugin(),
),
)
В таком коде нет бизнес-сценария. И это правильно. Здесь живут правила исполнения. Какие метаданные по умолчанию получит тест. Какой context будет доступен. Какой retry policy применяется. Какие ресурсы можно запросить. Какие fixture-ы зарегистрированы. Какие wrappers оборачивают test body и steps. Какие plugins подключены к runtime.
В обычном проекте это всё часто размазано по Before, Init, testutils, кастомным клиентам и отчётным обёрткам. Axiom кладёт это в одно место и называет честно: Runner.
Case — тест перестаёт быть просто функцией
В Go тест начинается как функция:
func TestUserCanSeePaymentMethods(t *testing.T) {
// ...
}
Для маленьких тестов это идеально.
Для интеграционных сценариев одной функции становится мало. Тесту нужно имя для отчёта, story, feature, tags, параметры, context, иногда override retry или skip. Можно всё это размазать по вызовам внутри функции. Так обычно и делают.
Axiom предлагает другую форму.
c := axiom.NewCase(
axiom.WithCaseName("user can see active payment methods"),
axiom.WithCaseMeta(
axiom.WithMetaStory("list methods"),
axiom.WithMetaTag("smoke"),
),
axiom.WithCaseContext(
axiom.WithContextData("role", "customer"),
),
axiom.WithCaseParams(ListMethodsParams{
ExpectedCount: 2,
}),
)
Case описывает тест как объект.
Это маленькое изменение сильно влияет на архитектуру. Пока тест — только функция, всё дополнительное знание приходится протаскивать через тело теста, helper-ы или глобальные структуры. Когда появляется Case, тест становится нормальной единицей исполнения. Его можно смержить с Runner, передать в plugin, обогатить metadata, применить policy, построить runtime config.
Тестовая функция остаётся. go test остаётся. testing.T остаётся. Просто сценарий получает явное описание.
Config — место, где сходится всё
Самая важная сущность в Axiom — Config. Да, звучит скучно. Название вообще без попытки понравиться. Но именно Config делает всю модель рабочей.
Когда Runner запускает Case, он собирает runtime-состояние конкретной попытки. Не абстрактного теста. Не всего suite. Именно этой попытки выполнения.
Упрощённо это выглядит так:
func (r *Runner) BuildConfig(t *testing.T, c *Case) *Config {
meta := r.Meta.Join(c.Meta)
context := r.Context.Join(c.Context)
retry := r.Retry.Join(c.Retry)
runtime := r.Runtime.Join(c.Runtime)
fixtures := r.Fixtures.Join(c.Fixtures)
return &Config{
Case: c,
Runner: r,
RootT: t,
Meta: meta,
Context: context,
Retry: retry,
Runtime: runtime,
Fixtures: fixtures,
}
}
В реальном коде деталей больше, но идея такая.
Config — это то, что тест получает в body:
runner.RunCase(t, c, func(cfg *axiom.Config) {
user := axiom.GetFixture[User](cfg, "user")
client := axiom.MustResource[*APIClient](cfg.Runner, "api-client")
cfg.Step("list payment methods", func() {
response := client.ListPaymentMethods(user.ID)
require.Len(cfg.SubT, response.Methods, 2)
})
})
Тест больше не собирает мир вокруг себя. Он работает с уже собранным runtime-состоянием.
В старом стиле это состояние обычно живёт где попало. Часть в t, часть в gomega, часть в App, часть в глобальных переменных, часть в Allure, часть в helper-ах, часть в замыканиях. Axiom кладёт это в Config.
Retry без нового runtime — это самообман

Retry в интеграционных тестах часто добавляют поздно. Обычно после того, как CI уже начал краснеть от нестабильного стенда, eventual consistency, очередей, внешних провайдеров и микросервисов, которые иногда отвечают не в том порядке, как хотелось бы.
Самый простой retry выглядит так:
for attempt := 1; attempt <= 3; attempt++ {
err := runScenario()
if err == nil {
break
}
time.Sleep(time.Second)
}
Выглядит нормально. Проблема в том, что такой retry часто повторяет сценарий в уже испорченном состоянии.
Первая попытка могла создать пользователя. Или отправить событие. Или частично создать автоплатёж. Или записать что-то в базу. Или положить данные в fixture cache. Или изменить локальную структуру. Вторая попытка стартует вроде бы заново, но на самом деле идёт по следам первой.
Это не retry. Это повторный вызов функции с надеждой, что повезёт.
В Axiom retry завязан на создание нового Config.
func (r *Runner) runCase(t *testing.T, c Case, action TestAction) {
baseCase := c.Copy()
baseCfg := r.BuildConfig(t, &baseCase)
baseCfg.ApplyPlugins()
baseCfg.ApplyExecutionPolicy()
for attempt := 1; attempt <= baseCfg.Retry.Times; attempt++ {
if attempt > 1 && baseCfg.Retry.Delay > 0 {
time.Sleep(baseCfg.Retry.Delay)
}
attemptCase := c.Copy()
cfg := r.BuildConfig(t, &attemptCase)
cfg.ApplyPlugins()
ok := t.Run(cfg.Case.Name, func(st *testing.T) {
cfg.SubT = st
cfg.ApplyExecutionPolicy()
cfg.Test(action)
})
if ok {
break
}
}
}
Здесь несколько важных решений.
- Case копируется. Исходное описание теста не должно мутировать от попытки к попытке.
- Config создаётся заново. Runtime state одной попытки не должен протекать в следующую.
- Fixture-ы живут в Config. Новая попытка получает новые fixture-ы.
- Resource остаётся в Runner. Дорогой клиент, connection pool или test server не надо пересоздавать на каждую попытку.
Вот это и есть нормальный retry для интеграционных тестов. Не цикл вокруг функции. А новая попытка исполнения с новым runtime state.
Fixture и Resource надо разделять



В тестовых проектах всё любят называть fixture. Пользователь — fixture. Клиент — fixture. База — fixture. Kafka producer — fixture. Токен — fixture. Docker container — fixture. Сгенерированная операция — fixture.
Потом начинается веселье.
Что из этого надо пересоздавать на retry? Что надо шарить между cases? Что чистить после теста? Что сбрасывать состояние? Что можно кешировать? Что опасно кешировать? Почему один тест случайно использует данные другого?
Axiom режет это на две разные сущности.
Fixture — данные или зависимость конкретной попытки.
func UserFixture(cfg *axiom.Config) (any, func(), error) {
client := axiom.MustResource[*APIClient](cfg.Runner, "api-client")
user, err := client.CreateUser(CreateUserRequest{
Email: fmt.Sprintf("%s@example.test", cfg.Case.ID),
})
if err != nil {
return nil, nil, err
}
cleanup := func() {
client.DeleteUser(user.ID)
}
return user, cleanup, nil
}
Resource — общий ресурс runner-а.
func APIClientResource(r *axiom.Runner) (any, func(), error) {
client := NewAPIClient("https://gateway.example.test")
cleanup := func() {
client.Close()
}
return client, cleanup, nil
}
Пользователь создаётся под конкретный case attempt. API-клиент живёт дольше. Он создаётся лениво, кешируется в runner-е и закрывается в конце runner lifecycle.
Это кажется очевидным, когда уже разложено. Но в реальных тестах именно здесь часто начинается каша. Общий клиент создаётся в Before(t) и закрывается через t.Cleanup. Данные создаются helper-ом, который иногда чистит за собой, иногда нет. Retry повторяет тест с тем же состоянием. Suite-level setup притворяется test-level setup-ом. Потом всё это чинят ещё одним helper-ом.
Axiom просто заставляет выбрать правильную полку.
Runtime wrappers убирают отчётность из сценария
Отчётность часто убивает читаемость тестов. Сценарий вроде бы простой: создать пользователя, создать способ оплаты, получить список, проверить результат.
А в коде сначала allure.Test, потом allure.ID, потом allure.Name, потом allure.Story, потом allure.Feature, потом allure.Action, потом StepWithDescription, потом ещё один StepWithDescription, потом внутри каждого step проверки.
Тест начинает выглядеть так, будто бизнес-сценарий обслуживает отчёт. Хотя должно быть наоборот. Инфраструктура должна обслуживать сценарий.
В Axiom для этого есть Runtime.
Можно обернуть тело теста:
axiom.WithRunnerRuntime(
axiom.WithRuntimeTestWrap(func(next axiom.TestAction) axiom.TestAction {
return func(cfg *axiom.Config) {
start := time.Now()
fmt.Println("start case:", cfg.Case.Name)
next(cfg)
fmt.Println("finish case:", cfg.Case.Name, time.Since(start))
}
}),
)
Можно обернуть каждый step:
axiom.WithRunnerRuntime(
axiom.WithRuntimeStepWrap(func(name string, next axiom.StepAction) axiom.StepAction {
return func() {
fmt.Println("step:", name)
next()
}
}),
)
В реальном проекте вместо fmt.Println будет Allure, логгер, метрики, trace, что угодно. Суть не в конкретной интеграции. Суть в месте подключения.
Если отчётность живёт внутри каждого теста, она будет расползаться. Если она подключается в runtime pipeline, тест остаётся сценарием.
cfg.Step("list payment methods", func() {
response := client.ListPaymentMethods(user.ID)
require.Len(cfg.SubT, response.Methods, 2)
})
Вот так должен выглядеть шаг. Всё лишнее уехало на свой уровень.
Plugin — нормальное место для расширений


В какой-то момент одного Runtime мало. Нужна интеграция с Allure. Нужны assertion sinks. Нужна статистика. Нужны tags. Нужны артефакты. Нужен сбор ошибок. Нужна своя внутренняя отчётность.
Можно снова начать прокидывать это через helper-ы. А можно дать расширениям нормальную точку входа.
В Axiom plugin получает Config.
func TimingPlugin() axiom.Plugin {
return func(cfg *axiom.Config) {
cfg.Runtime.TestWraps = append(cfg.Runtime.TestWraps, func(next axiom.TestAction) axiom.TestAction {
return func(c *axiom.Config) {
start := time.Now()
next(c)
fmt.Println("duration:", c.Case.Name, time.Since(start))
}
})
}
}
Plugin видит case, metadata, context, runtime, runner. Он подключается к модели исполнения, а не к конкретному тесту.
Это важная разница.
Когда расширение живёт в тесте, каждый тест начинает знать слишком много. Когда расширение живёт в runtime model, тесту всё равно, как именно строится отчёт, куда пишется статистика и кто собирает артефакты.
Тест проверяет поведение системы. Инфраструктура занимается инфраструктурой. Звучит банально. Но большинство раздутых e2e-тестов нарушают именно это.
Suite — когда тесты уже не одиночки
Обычный runner.RunCase(t, c, action) хорошо подходит для отдельного теста. Есть testing.T, есть Case, есть Runner, есть тело теста. Всё просто.
func TestUserCanLogin(t *testing.T) {
c := axiom.NewCase(axiom.WithCaseName("user can login"))
runner.RunCase(t, c, func(cfg *axiom.Config) {
// ...
})
}
Но интеграционные тесты редко живут совсем по одному.
Обычно есть группа сценариев вокруг одного домена: пользователи, платежи, подписки, документы, операции, тарифы. У них общий runner, общие клиенты, общие ресурсы, одинаковый слой отчётности, похожий context, общая metadata и понятная логическая граница.
Можно разложить такие сценарии по независимым top-level Test... функциям.
func TestUserCanLogin(t *testing.T) {
c := axiom.NewCase(axiom.WithCaseName("user can login"))
runner.RunCase(t, c, func(cfg *axiom.Config) {
// ...
})
}
func TestAdminCanBlockUser(t *testing.T) {
c := axiom.NewCase(axiom.WithCaseName("admin can block user"))
runner.RunCase(t, c, func(cfg *axiom.Config) {
// ...
})
}
Это нормальный Go-код. Но здесь нет группы. Есть два отдельных top-level теста, которые случайно используют один и тот же runner. Для testing это просто две независимые функции. Для runner это два отдельных вызова RunCase. Никакой общей границы между ними в коде не существует.
А если границы нет, то всё, что должно жить на уровне группы, начинает жить “где-то рядом”.
Общий setup прячется в helper. Общий cleanup уезжает в defer или t.Cleanup. Ресурсы становятся глобальными. Lifecycle начинает зависеть от порядка вызова. И дальше уже никто не может быстро ответить, где на самом деле начинается и заканчивается группа сценариев.
Suite решает именно это. Он просто говорит: вот эта группа тестовых методов выполняется под одним runner.
type UsersSuite struct {
axiom.Suite
}
func TestUsersSuite(t *testing.T) {
axiom.RunSuite(t, new(UsersSuite), axiom.WithSuiteRunner(runner))
}
func (s *UsersSuite) TestUserCanLogin() {
c := axiom.NewCase(axiom.WithCaseName("user can login"))
s.RunCase(c, func(cfg *axiom.Config) {
// ...
})
}
func (s *UsersSuite) TestAdminCanBlockUser() {
c := axiom.NewCase(axiom.WithCaseName("admin can block user"))
s.RunCase(c, func(cfg *axiom.Config) {
// ...
})
}
Теперь структура становится явной.
TestUsersSuite
TestUserCanLogin
user can login
TestAdminCanBlockUser
admin can block user
И execution model тоже становится явной.
RunSuite
Runner start
suite test method
Case execution
suite test method
Case execution
Runner finish
Вот здесь уже можно честно держать runner-scoped ресурсы, общий lifecycle, suite-level setup/cleanup, общую отчётность и общую конфигурацию исполнения.
BeforeAll и AfterAll в такой модели становятся естественным следствием. Если есть граница группы, есть место, где группа начинается, и есть место, где она заканчивается.
Без suite у нас есть только отдельные вызовы RunCase. С suite у нас появляется группа связанных сценариев. Это вся разница.
Axiom не лезет в чужую работу
Я не хочу, чтобы Axiom заменял testify. Пишите проверки через require, assert, gomega, стандартный testing — как удобно.
require.NoError(cfg.SubT, err)
require.Equal(cfg.SubT, "active", status)
Я не хочу, чтобы Axiom заменял gomock. Нужны моки — используйте моки.
Я не хочу, чтобы Axiom заменял go test. Запуск остаётся обычным:
go test ./...
Axiom находится между стандартным testing и сложным автотестом. Он отвечает за execution model.
Как только тестовый инструмент начинает делать всё сразу, он быстро превращается в монстра. Assertion framework, mocking framework, DSL, reporter, runner, dependency container, generator, orchestrator — всё в одном.
Axiom скучнее. Он берёт только тот слой, которого обычно не хватает в API/e2e-тестах. Исполнение.
Как выглядит тест с Axiom
Теперь тот же класс сценария, но уже с явными границами.
Сначала доменная часть.
type APIClient struct {
BaseURL string
}
func (c *APIClient) CreateUser(email string) (User, error) {
return User{ID: "user-1", Email: email}, nil
}
func (c *APIClient) CreatePaymentMethod(userID string) (PaymentMethod, error) {
return PaymentMethod{ID: "method-1", BankName: "Test Bank"}, nil
}
func (c *APIClient) ListPaymentMethods(userID string) ([]PaymentMethod, error) {
return []PaymentMethod{
{ID: "method-1", BankName: "Test Bank"},
}, nil
}
type User struct {
ID string
Email string
}
type PaymentMethod struct {
ID string
BankName string
}
Общий клиент — это resource.
func APIClientResource(r *axiom.Runner) (any, func(), error) {
client := &APIClient{
BaseURL: "https://gateway.example.test",
}
cleanup := func() {
fmt.Println("close api client")
}
return client, cleanup, nil
}
Пользователь — fixture. Он относится к конкретной попытке конкретного case.
func UserFixture(cfg *axiom.Config) (any, func(), error) {
client := axiom.MustResource[*APIClient](cfg.Runner, "api-client")
user, err := client.CreateUser(fmt.Sprintf("%s@example.test", cfg.Case.ID))
if err != nil {
return nil, nil, err
}
cleanup := func() {
fmt.Println("delete user:", user.ID)
}
return user, cleanup, nil
}
Runner собирает среду исполнения.
var runner = axiom.NewRunner(
axiom.WithRunnerMeta(
axiom.WithMetaEpic("payments"),
axiom.WithMetaFeature("payment methods"),
axiom.WithMetaLayer("integration"),
axiom.WithMetaTag("regression"),
),
axiom.WithRunnerContext(
axiom.WithContextData("env", "staging"),
),
axiom.WithRunnerRetry(
axiom.WithRetryTimes(3),
axiom.WithRetryDelay(500*time.Millisecond),
),
axiom.WithRunnerResource("api-client", APIClientResource),
axiom.WithRunnerFixture("user", UserFixture),
axiom.WithRunnerRuntime(
axiom.WithRuntimeStepWrap(func(name string, next axiom.StepAction) axiom.StepAction {
return func() {
fmt.Println("step:", name)
next()
}
}),
),
)
Suite задаёт границу группы.
type PaymentMethodsSuite struct {
axiom.Suite
}
func TestPaymentMethodsSuite(t *testing.T) {
axiom.RunSuite(t, new(PaymentMethodsSuite), axiom.WithSuiteRunner(runner))
}
А сам тест наконец-то похож на сценарий.
func (s *PaymentMethodsSuite) TestUserCanSeeActivePaymentMethods() {
c := axiom.NewCase(
axiom.WithCaseID("11111")
axiom.WithCaseName("user can see active payment methods"),
axiom.WithCaseMeta(
axiom.WithMetaStory("list methods"),
axiom.WithMetaTag("smoke"),
),
)
s.RunCase(c, func(cfg *axiom.Config) {
client := axiom.MustResource[*APIClient](cfg.Runner, "api-client")
user := axiom.GetFixture[User](cfg, "user")
cfg.Step("create payment method", func() {
method, err := client.CreatePaymentMethod(user.ID)
require.NoError(cfg.SubT, err)
require.NotEmpty(cfg.SubT, method.ID)
})
cfg.Step("list payment methods", func() {
methods, err := client.ListPaymentMethods(user.ID)
require.NoError(cfg.SubT, err)
require.Len(cfg.SubT, methods, 1)
require.Equal(cfg.SubT, "Test Bank", methods[0].BankName)
})
})
}
Тут всё ещё есть инфраструктура. Она никуда не исчезла. Просто она больше не притворяется тестовым сценарием.
- Клиент живёт в
resource. - Пользователь живёт в
fixture. Retryживёт вrunner.Metadataживёт вrunnerиcase.Step wrapperживёт вruntime.Group lifecycleживёт вsuite.
А в тесте остался только тест :)
Почему это выглядит слишком просто
Axiom может показаться слишком простым.
Где магия? А магии нет. И слава богу.
Магия в тестовой инфраструктуре обычно заканчивается тем, что через полгода никто не понимает, почему общий клиент закрылся после первого теста, почему retry использует старого пользователя, почему отчёт потерял step, почему cleanup не сработал, почему параллельный тест сломал глобальное состояние и почему для нового сценария надо вызвать ровно тот самый InitWithModules, а не соседний.
Axiom делает скучную вещь: он называет runtime runtime-ом. Тестовая функция перестаёт быть dependency container-ом, lifecycle manager-ом, retry engine-ом, registry fixture-ов, адаптером отчётности и step runner-ом одновременно. Она снова становится местом, где описан сценарий. Это и есть основная идея.
Вместо вывода
Сложные Go-автотесты почти всегда приходят к собственному фреймворку. Вопрос только в том, как он будет выглядеть.
- Можно получить фреймворк случайно. Через helper-ы, глобальные переменные,
Before,Init,testutils,defer,t.Cleanup, кастомные step-функции и отчётные обёртки. - Можно сделать его явным.
Axiom — это второй вариант.
Он не делает тесты модными. Не превращает Go в BDD. Не обещает, что интеграционные тесты перестанут быть сложными. Не отменяет плохие стенды, нестабильные внешние сервисы и кривые данные.
Он просто даёт execution model.
- Runner отвечает за среду исполнения.
- Case описывает тест.
- Config фиксирует runtime попытки.
- Fixture создаёт данные попытки.
- Resource держит общий ресурс.
- Runtime оборачивает исполнение.
- Plugin расширяет pipeline.
- Suite задаёт границу группы.
Да, всё настолько скучно. И именно поэтому это работает. Потому что хороший API/e2e-тест должен проверять поведение системы, а не каждый раз заново собирать вокруг себя маленький фреймворк.