golang

Плагины в Go-автотестах: как вынести инфраструктуру за пределы тестов

  • пятница, 3 июля 2026 г. в 00:00:09
https://habr.com/ru/articles/981190/

Вступление

Кто хоть раз писал автотесты на Go, которые выходят за рамки простых unit-тестов, неизбежно сталкивался с одними и теми же вопросами.

  • Как красиво завернуть тест в отчёт, не превращая его в главный объект теста?

  • Как добавить сбор статистики?

  • Как посчитать количество упавших и прошедших тестов?

  • Как перезапускать только нестабильные тесты?

  • Как измерить время выполнения каждого теста?

  • Как запускать тесты по тегам или маркировкам?

Вопросов много — стандартных ответов нет.

И дело здесь даже не в Go как языке. Стандартный testing изначально спроектирован максимально простым и минималистичным. Он честно говорит: вот базовый интерфейс для тестов, а всё остальное — на ваше усмотрение.

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

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

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

Как обычно выглядят автотесты без плагинов

Когда в Go-проекте появляется потребность в отчётности, статистике или фильтрации тестов, всё почти всегда начинается одинаково: нужную инфраструктуру начинают добавлять прямо в тело тестов.

Например, если нужно подключить отчётность (условно — Allure), код теста начинает выглядеть примерно так.

func TestUserCreation(t *testing.T) {
	t.Parallel()

	allure.Test(
		t,
		allure.ID("123"),
		allure.Name("user can be created"),
		allure.Feature("users"),
		allure.Story("create user"),
		allure.Action(func() {

			client := NewClient()
			userID := client.CreateUser()

			resp := client.GetUser(userID)
			if resp.ID != userID {
				t.Fatalf("unexpected user id")
			}
		}),
	)
}

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

Тест перестаёт быть сценарием проверки поведения и превращается в точку сборки отчётной инфраструктуры.

Со временем таких тестов становится десятки и сотни. Метаданные копируются, обёртки повторяются, код разрастается. Любое изменение — например, смена инструмента отчётности или структуры метаданных — требует массового рефакторинга всего проекта.

Глобальное состояние как «решение»

Когда появляется задача посчитать количество прошедших и упавших тестов, часто идут ещё дальше — вводят глобальные счётчики.

var passed int32
var failed int32

func TestSomething(t *testing.T) {
	if err := runScenario(); err != nil {
		atomic.AddInt32(&failed, 1)
		t.Fail()
		return
	}

	atomic.AddInt32(&passed, 1)
}

Теперь тест знает:

  • что такое статистика,

  • как она считается,

  • и когда именно её нужно обновлять.

Это уже не тест. Это часть фреймворка, размазанная по тестам.

Скрипты поверх результатов

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

PASSED=0
FAILED=0

for file in results/*.json; do
  status=$(jq -r '.status' "$file")
  if [ "$status" = "passed" ]; then
    PASSED=$((PASSED+1))
  else
    FAILED=$((FAILED+1))
  fi
done

На этом этапе тесты, отчёты и инфраструктура окончательно перестают быть единым целым. Контракты неявные, зависимости хрупкие, а любое изменение процесса превращается в цепочку правок: тесты → хелперы → скрипты → CI.

Почему такой подход не масштабируется

Во всех этих примерах проблема одна и та же: инфраструктура живёт внутри тестов.

Тесты знают слишком много:

  • как формируется отчёт,

  • как считается статистика,

  • какие теги и фильтры используются,

  • какие инструменты подключены.

Любое изменение — новый формат отчёта, другая система статистики, новые правила запуска — требует изменений по всему проекту.

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

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

Как должно быть: тесты остаются тестами

И тут на сцену выходит Axiom.

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

Более того, тест вообще не должен знать, какой именно инструмент используется под капотом. Тест оперирует только терминами тестирования: названием, приоритетом, тегами, feature, story, шагами выполнения. Всё остальное — ответственность рантайма.

Для этого в Axiom уже есть набор встроенных плагинов — они полностью опциональны: можно использовать готовые решения из коробки или писать свои, не меняя тесты.

Плагины в Axiom — это не хелперы

Плагин в Axiom — это не вспомогательная функция и не обёртка вокруг теста. Это функция, которая подключается к execution model и регистрирует поведение в runtime:

Важно: плагин не исполняет тест и не меняет его код. Он лишь наблюдает и декорирует выполнение.

Пример собственного плагина

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

Важно: цель примера не в измерении времени, а в том, на каком уровне это делается.

package timed

import (
	"fmt"
	"time"

	"github.com/Nikita-Filonov/axiom"
)

// Plugin возвращает axiom.Plugin — функцию,
// которая получает runtime Config теста.
func Plugin() axiom.Plugin {
	return func(cfg *axiom.Config) {

		// Регистрируем TestWrap — middleware вокруг выполнения теста.
		// Wrap применяется на уровне execution model,
		// а не внутри тестового кода.
		cfg.Runtime.EmitTestWrap(func(next axiom.TestAction) axiom.TestAction {

			return func(c *axiom.Config) {
				start := time.Now()

				// Выполняем сам тест
				next(c)

				// Этот код выполнится после завершения теста
				// (включая шаги, хуки и retry-попытки).
				fmt.Printf(
					"test %q finished in %s\n",
					c.Name,
					time.Since(start),
				)
			}
		})
	}
}

Здесь важно несколько вещей:

  • Плагин работает с Config — runtime-снимком теста, который создаётся Runner’ом для конкретного выполнения. Он не знает ничего о теле теста, не имеет доступа к его логике и не вмешивается в сценарий.

  • Через Runtime.EmitTestWrap плагин регистрирует поведение, которое будет применено ко всем тестам, выполняемым через этот Runner. Это не обёртка функции и не helper — это часть execution model.

Подключение плагина

Теперь подключим этот плагин на уровне Runner’а.

var runner = axiom.NewRunner(
	axiom.WithRunnerPlugins(
		timed.Plugin(),
	),
)

На этом инфраструктурная часть закончена.

Сам тест при этом не меняется

func TestUserCreation(t *testing.T) {

	c := axiom.NewCase(
		axiom.WithCaseName("user can be created"),
		axiom.WithCaseMeta(
			axiom.WithMetaFeature("users"),
			axiom.WithMetaStory("create user"),
			axiom.WithMetaTag("smoke"),
		),
	)

	runner.RunCase(t, c, func(cfg *axiom.Config) {
		cfg.Step("create user", func() {
			// бизнес-логика
		})

		cfg.Step("validate response", func() {
			// проверки поведения
		})
	})
}

Тест по-прежнему описывает только сценарий и проверки. В нём нет:

  • логики измерения времени;

  • форматирования вывода;

  • привязки к конкретному инструменту;

  • инфраструктурных зависимостей.

Если плагин убрать или заменить — тест останется неизменным.

Что здесь принципиально важно

Именно так решается главная проблема Go-автотестов: любое инфраструктурное изменение перестаёт требовать переписывания тестов.

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

Смена отчётности, добавление статистики, фильтрации по тегам, логирования или метрик больше не требует переписывания тестов. Меняется только конфигурация execution runtime.

Именно это отличает плагинную модель от хелперов и обёрток — и именно поэтому Axiom масштабируется там, где самодельные решения начинают ломаться.

Заключение

Автотест — это описание проверяемого поведения. Он не должен знать, как формируется отчёт, куда отправляются логи, по каким правилам считается статистика и как именно запускаются тесты в CI.

Все эти решения — инфраструктурные. Они живут в другом слое и меняются по другим причинам.

Плагинная модель Axiom разделяет эти миры. Тесты остаются декларативными и стабильными, а отчётность, фильтрация, логирование, retry и сбор метрик подключаются и изменяются отдельно — на уровне execution runtime.

Именно так тесты перестают быть хрупким набором костылей и превращаются в систему, которую можно развивать без страха сломать всё остальное.