← Все статьи

Go-автотесты

Axiom в Go-автотестах: что будет с вашим кодом через 5 лет?

Магия в тестовом фреймворке выглядит удобно ровно до того момента, пока через пять лет вам не придется дебажить зелёный CI без запущенных тестов.

gogolangтестированиеавтотестыавтоматизация тестированияqaqa automationaqaaxiompytest

Axiom в Go-автотестах: что будет с вашим кодом через 5 лет?

Вступление

Когда я начинал писать Axiom, у меня не было цели соорудить очередной «революционный фреймворк» или насильно натянуть PyTest на Go ради красивого README на GitHub. Мне не хотелось изобретать велосипедные DSL или воевать со стандартным пакетом testing.

Вопрос стоял гораздо циничнее и больнее: что будет с этим кодом через 5 лет?

Не через неделю после релиза. Не на красивом демо перед тимлидом и не в примерах для этой статьи.

А через пять лет. Когда в проекте будут крутиться тысячи тяжелых интеграционных тестов, завязанных на Кафку, базы данных, внешние API и вечно хромающую инфраструктуру. Когда CI начнет захлебываться от flaky-тестов, а поддерживать всё это добро придут инженеры, которые в глаза не видели авторов первой версии платформы.

Именно этот экзистенциальный кошмар и определил каждое архитектурное решение в Axiom.

Go testing прекрасен, но его мало

Стандартный testing в Go прекрасен своей аскетичной честностью. Тебе дают *testing.T, t.Run, t.Cleanup, t.Parallel и команду go test. Всё.

Для юнитов этого хватает за глаза. Но на этапе серьезного интеграционного или E2E-тестирования перед нами встают системные задачи, которые testing решать и не должен:

  • Как разбить сценарий на вменяемые шаги и не получить портянку кода на 1000 строк?
  • Как лениво поднимать и гарантированно чистить тяжелые ресурсы (фикстуры)?
  • Как делать честные ретраи, не размазывая грязь от упавшей попытки по базе данных?
  • Куда зашивать метаданные и как без костылей прикрепить артефакты к Allure-репортеру?
  • Как управлять жизненным циклом теста централизованно, а не копипастить хелперы?

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

  1. Пойти путем явного движка исполнения (execution engine).
  2. Начать строить магический фреймворк.

На первый взгляд, магия выглядит чертовски соблазнительно. Она позволяет сделать эффектный фасад для слайдов на митапе, полностью спрятать boilerplate, автоматически находить тесты через рефлексию, неявно собирать параметры по именам методов и под капотом крутить хуки.

Но у рантайм-магии есть цена. И прелесть этой цены в том, что её никогда не видно в первый месяц. Она выставляется к оплате спустя годы.

Тест на вшивость: «Открой этот код через 5 лет»

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

Перед его глазами вот такой код:

func TestUserCanLogin(t *testing.T) {
    c := axiom.NewCase(
        axiom.WithCaseName("user can login"),
        axiom.WithCaseMeta(
            axiom.WithMetaTag("smoke"),
            axiom.WithMetaFeature("auth"),
        ),
        axiom.WithCaseFixture("user", UserFixture),
    )

    runner.RunCase(t, c, func(cfg *axiom.Config) {
        user := axiom.GetFixture[User](cfg, "user")

        cfg.Step("login", func() {
            token := api.Login(user.Email, user.Password)
            axiom.SetLocal(cfg, "token", token)
        })

        cfg.Step("check profile", func() {
            token := axiom.GetLocal[string](cfg, "token")
            profile := api.Profile(token)

            assert.Equal(t, user.ID, profile.UserID)
        })
    })
}

Что этот человек поймет из увиденного? Вообще всё. Ему не нужно курить внутреннюю документацию фреймворка, чтобы понять, как работает магия. Он сразу видит:

  • Где и с какими метаданными объявляется тест-кейс.
  • Какой конкретно раннер его оркеструет.
  • Какая фикстура запрашивается и какого она типа (спасибо дженерикам и строгой типизации, а не безликим interface{}).
  • Где границы шагов, откуда берутся данные и куда складывается стейт текущей попытки.

Ему не нужно знать, какие поля структуры фреймворк бесцеремонно просканировал через рефлексию. Ему не нужно гадать, почему метод ParamsOS() заставил тест параметризоваться, а опечатка в регистре (ParamsOs()) молча скрыла критическую проверку от CI. Ему плевать, в каком случайном порядке рантайм обошел внутреннюю map с плагинами и почему экспортируемые поля внезапно превратились в жесткую runtime-схему, требующую исключительно указатели — иначе лови панику на старте.

Инженер просто читает обычный Go-код. Компилятор его проверяет, IDE нативно подсвечивает, дебаггер без проблем проходит по шагам.

В этом и заключалась главная цель. Axiom спроектирован так, чтобы через пять лет быть смертельно скучным. Потому что именно скучный и предсказуемый код выживает в крупном продакшене, пока «эффектная магия» из README-файлов медленно хоронит под собой поддержку платформы.

Почему Axiom простой

Простота Axiom — это не дефицит фантазии у автора. Это жесткое, осознанное ограничение области ответственности. Наш движок не пытается казаться «умнее» разработчика и не пытается стать всем и сразу.

Axiom не подменяет собой go test. Он не лезет в зону ответственности библиотек ассертов или моков. Он не заставляет вас раскладывать сценарии по методам тяжелых структур-сьютов и не требует кастомных оберток над testing.T. Он принципиально не занимается оккультизмом в рантайме: не сканирует ваш код рефлексией, не пытается угадать намерения по префиксам имен, не превращает систему плагинов в развесистый DI-контейнер и не заставляет бизнес-логику теста намертво зависеть от API конкретного Allure-репортера.

Axiom делает ровно одну вещь — он добавляет в экосистему недостающий слой исполнения (execution layer). И делает это через абсолютно прозрачные, изолированные примитивы: Runner, Case, Config, Runtime, Fixture, Plugin, Hook, Artefact, Retry и Context.

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

Да, на Axiom тоже можно навертеть ерунды — написать нечитаемый спагетти-код при желании можно на чём угодно. Но если «магические» фреймворки навязывают вам свой скрытый рантайм-оккультизм как фундамент ядра, то Axiom даёт лишь голые, предсказуемые примитивы. Использование их через одно место будет вашим личным осознанным выбором, а не системным требованием инструмента. В итоге даже самый плохой код на Axiom останется обычным Go-кодом, который компилятор проверит, IDE подсветит, а дебаггер без проблем пройдёт по шагам.

Магия карается слезами инженеров

В экосистеме Go почти каждая история про рантайм-магию начинается одинаково умилительно:

«Да мы тут добавим всего пару строк рефлексии, чисто чтобы тесты покрасивее объявлять»

А дальше открывается классический ящик Пандоры. Сначала пишется автоматический поиск тест-сьютов. Потом включается подход convention over configuration. Потом префиксы и регистры в именах методов внезапно обретают сакральный смысл. На слайдах презентации это выглядит как магия чистой воды:

type Suite struct {
    framework.Suite[Testing]
}

// Фреймворк сам догадается, что это параметры для Region
func (Suite) ParamsRegion() []string {
    return []string{"eu", "us"}
}

func (Suite) ParamsBrowser() []string {
    return []string{"chrome", "firefox"}
}

func (Suite) TestCheckout(t T, p struct {
    Region  string
    Browser string
}) {
    t.Logf("run checkout for %s/%s", p.Region, p.Browser)
}

Но стоит данным из бэка или конфигурации не доехать, как этот карточный домик молча складывается в продакшене. Метод параметров возвращает пустой слайс, фреймворк считает, что тестировать нечего, плюет варнингом в логи и закрывает билд со статусом PASS:

func (Suite) ParamsRegion() []string {
    return nil // Что-то пошло не так
}

func (Suite) TestCheckout(t T, p struct{ Region string }) {
    t.Fatal("this test must never silently disappear")
}

// В логи улетает копеечный варнинг, который никто никогда не прочитает:
// warning: Suite.ParamsRegion provides zero values, Suite.TestCheckout won't run

// Но для CI всё супер, exit code 0

Затем экспортируемое поле вашей структуры перестает быть просто полем — оно превращается в жесткую рантайм-схему для скрытого DI-контейнера. Фреймворк начинает сам аллоцировать указатели, сам собирать зависимости, и в итоге порядок выполнения тестов начинает зависеть не от кода, который вы видите глазами, а от того, как reflection-движок прошелся по структурам.

А потом наступает неизбежный момент, когда в чате команды звучит вопрос:

«Ребята, а почему этот критичный тест вообще не запустился на CI?»

И вот это — по-настоящему паршивая точка. Потому что ответа в коде самого теста больше нет. Вы не можете кликнуть по нему дебаггером. Ответ похоронен где-то глубоко в кишках чудо-фреймворка, внутри хитрого planner-а, в логике приоритетов плагинов или в правилах парсинга названий методов.

Рефлексия сама по себе — не абсолютное зло. Она легитимна и полезна в маршалинге JSON, Protobuf, ORM-библиотеках или генераторах совместимости. Но использовать рефлексию как фундамент модели исполнения тестов — это худший компромисс из возможных.

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

Если тест не запустился, причина должна быть очевидна из кода. Если ресурс создался, должно быть четко видно — где. Если сработал cleanup, должно быть понятно — когда. А если плагин как-то повлиял на выполнение, его влияние должно быть строго ограниченным и предсказуемым.

Именно поэтому базовый путь в Axiom — это не «просканировать структуру и догадаться о намерениях автора». Наш путь — это явный Runner, явный Case и явный RunCase:

runner.RunCase(t, c, func(cfg *axiom.Config) {
    cfg.Step("do something", func() {
        // обычный, предсказуемый Go-код
    })
})

Да, в Axiom есть suite-style адаптер для тех, кто привык к классическому xUnit-подходу с методами на структурах. Но это именно внешний, изолированный адаптер для обратной совместимости и мягкого переезда, а не фундамент ядра.

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

Плагин должен быть функцией, а не операционной системой

Одно из ключевых архитектурных решений в Axiom: плагин — это простая, до неприличия тупая функция.

type Plugin func(cfg *axiom.Config)

Вот пример плагина, который просто замеряет время выполнения шага:

func StepTimingPlugin() axiom.Plugin {
    return func(cfg *axiom.Config) {
        cfg.Runtime.EmitStepWrap(func(next axiom.StepAction) axiom.StepAction {
            return func(cfg *axiom.Config, step axiom.Step) {
                started := time.Now()

                next(cfg, step)

                cfg.Logf("step %q finished in %s", step.Name, time.Since(started))
            }
        })
    }
}

Этот код тупой. И это прекрасно. Плагин обязан быть тупым, потому что это точка расширения (extension point), а не вторая операционная система, живущая внутри вашего тест-раннера.

Плагин имеет право делать понятные сквозные задачи: завернуть выполнение теста или шага в middleware (знакомый всем Go-разработчикам паттерн), подписаться на логи, сложить артефакты, накинуть метаданные или изолированно отфильтровать запуск.

Но плагин не должен становиться хозяином execution-модели.

// Плагин, который залез во внутренности рантайма и развернул тест-план задом наперед:
type ReverseOrderPlugin struct{}

func (ReverseOrderPlugin) Plugin() PluginTest {
    return PluginTest{
        Plan: TestPlan{
            Prepare: func(tests *[]PlannedTest) {
                slices.Reverse(*tests)
            },
        },
    }
}
// Плагин, который бесцеремонно перехватывает системный вывод:
type LogOverridePlugin struct {
    *FrameworkT
}

func (p LogOverridePlugin) Plugin() PluginTest {
    return PluginTest{
        Overrides: Overrides{
            Log: func(next func(args ...any)) func(args ...any) {
                return func(args ...any) {
                    fmt.Println("log intercepted by plugin")
                    next(args...)
                }
            },
        },
    }
}

Он не имеет права коллекционировать тесты, перекраивать глобальный тест-план на ходу, превращаться в Service Locator или мутировать поля ваших структур, работая как скрытый DI-контейнер. Как только вы даете плагину право владеть исполнением, простая система расширений превращается в неуправляемого монстра.

И через пять лет дебаг любого упавшего сценария превращается в полноценное расследование:

  • Какой именно плагин перетер стейт в рантайме?
  • Почему этот хук сработал раньше, хотя в коде объявлен позже?
  • Почему на локальной машине порядок плагинов один, а в CI из-за случайного обхода Go-мап рантаймом — другой?
  • Почему фильтрация применилась до того, как доехали метаданные?

С такой магической архитектурой можно жить, но её точно нельзя назвать простой и поддерживаемой.

Axiom сознательно выбирает другой путь — банальный и предсказуемый слайс функций:

axiom.WithRunnerPlugins(
    testtags.Plugin(testtags.WithConfigInclude(os.Getenv("REGION"))),
    testtags.Plugin(testtags.WithConfigInclude(os.Getenv("TEST_TAGS"))),
    allure.Plugin(),
)

Порядок здесь — это порядок. Первый плагин в слайсе выполнился первым, второй — вторым, третий — третьим.

Никаких архитектурных костылей вроде singleton-by-type (когда плагины складывают в map[reflect.Type], намертво убивая композицию). Никакой рулетки и потери очередности из-за рантайм-рандомизации мап. Никаких криков о помощи в духе «расставьте циферки приоритетов вручную, чтобы вернуть тот порядок выполнения, в котором вы изначально их написали».

// Анатомия архитектурного тупика. 
// Сначала плагины схлопываются по типу (ключ — reflect.Type),
// а затем итерируются в абсолютно случайном порядке:
plugins := map[reflect.Type]Plugin{}

for _, field := range exportedFields(customT) {
    typ := field.Type

    if implementsPlugin(typ) {
        plugins[typ] = newPlugin(typ)  // Прощай, возможность подключить два одинаковых плагина
    }
}

// Привет, рандомный порядок выполнения хуков в CI из-за random map iteration в Go!
for _, plugin := range plugins {
    spec := plugin.Spec()
    specs = append(specs, spec)
}

В Axiom один и тот же плагин можно спокойно подключить дважды или трижды с абсолютно разной конфигурацией (например, для последовательной фильтрации из разных источников окружения). Потому что плагин в Axiom — это явное значение, а не тип в reflection-мапе.

Через пять лет это спасет вашей команде недели дебага. Просто потому, что явная композиция через значения живет и масштабируется лучше, чем скрытая runtime-идентичность.

«Это можно сделать через плагин» — универсальный ответ плохих архитекторов

Когда указываешь авторам «магических» фреймворков на явные дыры в дизайне их продукта, в ответ почти всегда прилетает ленивое:

«Ну, это же легко расширяется, просто напиши под эту задачу кастомный плагин»

Конечно, можно. В Go вообще можно сделать абсолютно всё, если достаточно долго и упорно насиловать рефлексию. Можно собрать неявный DI-контейнер, жестко завязанный на указатели. Можно парсить имена методов, угадывая параметры по регистру букв. Можно превратить экспортируемые поля пользовательской структуры в рантайм-схему и выплевывать паники на старте, если кто-то забыл поставить звездочку перед типом.

Но вопрос никогда не стоит в плоскости «можно или нельзя». Вопрос в другом:

почему конечный инженер должен платить за эти архитектурные костыли своей когнитивной нагрузкой каждый день?

Если базовая, фундаментальная вещь (вроде изоляции попыток или очистки окружения) перекладывается на плечи плагинов, стоит задать авторам инструмента несколько очень неудобных вопросов:

  • Это реально осознанная точка расширения или вы просто маскируете плагином зияющую дыру в базовой модели исполнения?
  • Плагин просто скромно наблюдает за рантаймом со стороны или он вынужден компенсировать отсутствие нормального жизненного цикла в вашем ядре?
  • Плагин является элегантным дополнением или он сам бесцеремонно становится вторым рантаймом внутри первого?

Через пять лет красивый лозунг «зато у нас всё на плагинах» неизбежно мутирует в утренние расследования на созвонах:

— А какой именно плагин молча перетер стейт в рантайме?
— Почему этот хук сработал раньше, хотя приоритеты у всех дефолтные?
— Почему на локальной машине тест запускается, а на CI он просто растворяется, выплевывая копеечный варнинг в stderr и делая билд ложно-зеленым?
— Почему два независимых инстанса одного плагина тупо схлопнулись в глобальной мапе по reflect.Type?

Именно поэтому в Axiom мы принципиально не пытаемся затыкать архитектурные просчеты плагинами. Мы жестко, на уровне ядра, разделили зоны ответственности:

  • Plugin — это исключительно внешняя точка наблюдения и декорирования (мидлварь).
  • Fixture — это изолированный жизненный цикл ресурсов конкретной попытки.
  • Case — это декларативная, атомарная тестовая единица.
  • Runner — это оркестратор и жесткая граница исполнения сценариев.
  • Config — это thread-safe snapshot стейта и параметров рантайма.
  • Step — это явная, именованная операция внутри сценария, которую видит компилятор.

Каждая сущность занимается своим делом. Без рантайм-магии, без превращения системы расширений в Service Locator и без перекладывания системных задач на плечи пользователя.

Репортинг: Allure должен быть адаптером, а не вашим тестовым API

Отчетность — это эталонный пример того, как технические детали инфраструктуры незаметно протекают в бизнес-логику сценариев.

Давайте посмотрим на классическую ошибку моделирования, которую можно встретить в каждом втором Go-проекте:

func TestStatement(t *testing.T) {
    allure.Step("create user", func() {
        // ...
    })

    allure.Step("close statement", func() {
        // ...
    })

    allure.AddAttachment("actual response", response)
}

Диагноз: проблема здесь вовсе не в Allure. Это вменяемый, проверенный временем инструмент визуализации. Проблема в том, что API конкретного внешнего репортера стал частью вашего бизнес-сценария. Тест намертво завязался на стороннюю библиотеку.

Поздравляю, вы получили чистокровный vendor-lock. Если завтра ваша компания решит мигрировать на другую систему отчетности, или вам банально потребуется параллельно стримить тайминги шагов в OpenTelemetry, отправлять метрики прогонов в Grafana, а тяжелые файлы складывать в корпоративное S3-хранилище — вам придется пойти и руками переписать тысячи файлов с тестами, выковыривая оттуда allure.Step.

В Axiom тест вообще понятия не имеет, существует ли Allure в природе. Он общается исключительно со своим runtime-контекстом:

cfg.Step("close statement", func() {
    // чистая бизнес-логика
})

cfg.Artefact(axiom.Artefact{
    Name: "actual response",
    Type: axiom.ArtefactTypeJSON,
    Data: body,
})

А вот дальше за дело берется плагин. Движок Axiom просто генерирует чистые runtime-события («начался шаг», «записан артефакт»), а подключенный в раннере плагин-адаптер перехватывает их и упаковывает в формат Allure, Jaeger-трассировку или обычный структурированный лог.

Это и есть единственно верное разделение зависимостей. Сценарий зависит только от модели тестирования. Репортер зависит от событий рантайма. Но не наоборот.

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

Архитектурная развилка: помогать коду или пытаться быть умнее него

Периодически у меня самого возникает соблазн добавить в Axiom очередную фичу ради «мнимого удобства». Автодискавери сьютов, угадывание параметров по именам методов, кастомный T, скрытый граф зависимостей плагинов или DI на рефлексии. Любую из этих идей можно красиво обосновать на созвоне, и каждая из них сделает трехстрочный пример в README еще более сексуальным.

Но я каждый раз возвращаюсь к одному вопросу:

что будет с этим кодом через 5 лет?

Если ответ звучит как «новому инженеру придется лезть в исходники ядра фреймворка, чтобы понять логику прогона», то идея отправляется в мусорную корзину.

Axiom спроектирован именно таким, потому что шаг в сторону магии автоматически меняет класс инструмента. Он перестает быть прозрачным движком исполнения (execution engine) и превращается в тяжелый, неявный runtime поверх самого языка.

Давайте проведем обратный эксперимент. Если взять любой «магический» фреймворк и безжалостно вырезать из него reflection-дискавери, скрытый DI, неявные лайфсайклы и угадывание логики по префиксам методов, в сухом остатке останется ровно то, что делает Axiom:

  • Явный Runner и явный Case.
  • Явный, изолированный Config и прозрачные Fixtures.
  • Явные, именованные Steps.
  • Плагины как обычные функции, а репортер — как изолированный адаптер.
  • Чистый жизненный цикл без скрытого управления потоком (control flow).

Это не случайность. Это фундаментальная архитектурная развилка. Либо фреймворк пытается быть умнее вашего теста, либо рантайм помогает тесту оставаться обычным, предсказуемым Go-кодом. Axiom сознательно выбирает второе.

Заключение: Единственный важный критерий

Axiom получился простым не потому, что у меня не хватило ресурсов или фантазии завернуть его в тяжелую абстракцию. Он простой потому, что любая скрытая сложность в тестовой инфраструктуре со скоростью звука превращается в неподъемный технический долг.

Рантайм-магия всегда маскируется под удобство. Но фундаментальная задача автотестов — объяснять и контролировать поведение вашей системы. Если для того, чтобы просто прочитать сценарий, инженеру приходится сначала расшифровывать неявный жизненный цикл и логику самого фреймворка, значит, вместо решения проблем автоматизации мы успешно построили себе еще одну проблему.

Axiom — это попытка дать Go-сообществу недостающий слой исполнения, оставшись при этом на 100% верным ключевой идеологии самого языка:

  • Обычный Go-код вместо развесистых внутренних DSL;
  • Явные зависимости вместо скрытых DI-контейнеров;
  • Предсказуемый control flow и кристально понятный жизненный цикл;
  • Плагины как функции, а фикстуры — как строгое владение ресурсами;
  • Честная параллельность и полная изоляция стейта при ретраях;
  • Абсолютная независимость от конкретной системы отчетности.

Критерий ровно один: сможет ли незнакомый человек открыть ваш интеграционный тест через пять лет и за пару минут понять, что происходит, не заглядывая в исходники самого раннера?

Если да — ваша архитектура работает. Если нет — магия уже победила. А там, где побеждает скрытая рантайм-магия, рано или поздно открываются ящики Пандоры, пайплайны в CI молча зеленеют на пустых прогонах, а в продакшене взрываются простреленные ноги.

Я категорически не хочу, чтобы Axiom плодил такие риски. Именно поэтому он явный. Именно поэтому он простой.