golang

Retry в Go автотестах: как перестать бояться flaky-тестов

  • четверг, 25 июня 2026 г. в 00:00:14
https://habr.com/ru/articles/978802/

Вступление

Flaky-тесты — неизбежная реальность, как бы нам этого ни хотелось. Особенно в интеграционных и E2E-сценариях, где автотесты зависят от сети, окружения и нестабильных тестовых стендов. Любой временный сбой — и тест падает, а его перезапуск превращается в проблему.

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

Сразу важно обозначить: flaky-тесты не стоит лечить ретраями в первую очередь. В идеале проблема должна решаться на уровне инфраструктуры или тестового сценария. Но если это невозможно и retry всё же необходим — его важно реализовать правильно.

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

Как обычно реализуют retry в Go

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

Наивный retry

func TestSomething(t *testing.T) {
	for i := 0; i < 3; i++ {
		err := runTestScenario()
		if err == nil {
			return
		}
	}

	t.Fatal("test failed after retries")
}

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

В результате:

  • состояние между попытками не сбрасывается;

  • ресурсы переиспользуются повторно;

  • побочные эффекты накапливаются;

  • поведение теста становится недетерминированным.

Фактически это не retry теста, а несколько вызовов одной и той же логики в одном и том же контексте.

Или ещё хуже

Часто следующий шаг — попытка «сделать красиво» через t.Run:

func TestSomething(t *testing.T) {
	for i := 0; i < 3; i++ {
		t.Run(fmt.Sprintf("attempt-%d", i), func(t *testing.T) {
			runTestScenario(t)
		})
	}
}

Здесь кажется, что мы запускаем тест заново. Но на практике это всё тот же один тест с общим состоянием:

  • фикстуры и ресурсы не переинициализируются;

  • контекст и глобальное состояние протекают между попытками;

  • невозможно гарантировать чистый lifecycle;

  • CI начинает вести себя непредсказуемо.

Такой retry часто делает flaky-тесты ещё более flaky, чем они были изначально.

В обоих случаях проблема одна и та же: retry реализуется как повторный вызов кода, а не как повтор модели выполнения теста.

Как сделать retry правильно

После антипримеров важно зафиксировать одну ключевую мысль:

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

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

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

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

Минимальный пример retry в Axiom

package example_test

import (
	"fmt"
	"testing"
	"time"

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

func TestRetryExample(t *testing.T) {

	// -------------------------------------------------------------------------
	// Runner-level retry policy
	// -------------------------------------------------------------------------
	// Глобальная политика retry для всех тестов,
	// если Case не переопределяет её явно.
	runner := axiom.NewRunner(
		axiom.WithRunnerRetry(
			axiom.WithRetryTimes(2),
			axiom.WithRetryDelay(1*time.Second),
		),
	)

	// -------------------------------------------------------------------------
	// Case-level override
	// -------------------------------------------------------------------------
	// Конкретный тест может переопределить retry-политику.
	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)

		// Имитируем flaky-поведение
		if attempt < 3 {
			t.Fail() // триггерит retry
		}

		cfg.Step("finalize", func() {
			fmt.Println("success on attempt", attempt)
		})
	})
}

Здесь retry задаётся декларативно и не вмешивается в код теста. Политика может быть определена глобально и при необходимости переопределена локально — без копипаста и без условной логики внутри тестов.

Что именно происходит при retry в Axiom

Когда тест запускается через Runner, Axiom сначала собирает полную конфигурацию выполнения — объединяя настройки Runner’а и конкретного Case. Эта конфигурация описывает всё, что относится к выполнению теста: метаданные, retry-политику, контекст, хуки, фикстуры, плагины и правила исполнения.

При retry происходит следующее:

  1. Конфигурация теста собирается заново. Для каждой попытки Axiom создаёт новый runtime-снимок (Config), объединяя Runner и Case. Это означает, что retry — это не повтор в рамках уже существующего состояния, а новый запуск с теми же правилами.

  2. Контекст и runtime не переиспользуются. Контекст выполнения, runtime-обёртки и execution-политики инициализируются заново. Ничего не «протекает» между попытками, если это явно не задано конфигурацией.

  3. Фикстуры получают новый жизненный цикл. Все фикстуры считаются ленивыми и привязанными к конкретной попытке. При retry они будут пересозданы при первом обращении и корректно очищены после завершения попытки.

  4. Хуки и плагины применяются повторно. Before/after-хуки, middleware и плагины выполняются так же, как при обычном запуске теста — но для каждой попытки отдельно.

И только после этого выполняется тело теста.

Таким образом, retry в Axiom — это повтор execution model, а не повтор функции.

Под капотом retry может выглядеть как обычный цикл — и это нормально. Ключевая разница не в реализации, а в точке повтора.

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

Заключение

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

Axiom позволяет задать retry как часть тестового рантайма: политика повторных попыток конфигурируется централизованно на уровне Runner’а и при необходимости переопределяется локально на уровне конкретного теста. Это даёт контроль и предсказуемость — retry перестаёт быть хаотичным костылём и становится осознанной политикой выполнения.

Такой подход не лечит flaky-тесты, но позволяет работать с ними аккуратно: без протекания состояния, без скрытых побочных эффектов и без необходимости встраивать retry-логику прямо в тестовый код.