Retry в Go автотестах: как перестать бояться flaky-тестов
- четверг, 25 июня 2026 г. в 00:00:14
Flaky-тесты — неизбежная реальность, как бы нам этого ни хотелось. Особенно в интеграционных и E2E-сценариях, где автотесты зависят от сети, окружения и нестабильных тестовых стендов. Любой временный сбой — и тест падает, а его перезапуск превращается в проблему.
В Go ситуация усугубляется тем, что стандартный пакет testing никак не описывает механизм повторного запуска тестов. В результате команды вынуждены реализовывать retry самостоятельно — и вместе с этим появляются новые ошибки, утечки состояния и непредсказуемое поведение тестов.
Сразу важно обозначить: flaky-тесты не стоит лечить ретраями в первую очередь. В идеале проблема должна решаться на уровне инфраструктуры или тестового сценария. Но если это невозможно и retry всё же необходим — его важно реализовать правильно.
В этой статье разберёмся, какие подходы к retry в Go приводят к проблемам и как выглядит корректная модель повторного запуска автотестов. В качестве примера будем использовать Axiom.
Когда тесты начинают флапать, первое, что приходит в голову — просто перезапустить тест несколько раз. В Go это обычно выглядит примерно так.
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 — это повтор всей модели выполнения теста.
Именно здесь большинство самодельных решений в Go дают сбой. Они повторяют тестовую функцию, но не пересобирают окружение, не сбрасывают состояние и не изолируют попытки на уровне инфраструктуры.
В Axiom retry встроен в сам механизм выполнения теста. Он не оборачивает тестовую логику в цикл и не пытается «поймать» ошибку. Вместо этого каждая попытка запускается как отдельное выполнение теста с новым runtime-состоянием.
Важно сразу подчеркнуть: никакой магии здесь нет. 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 задаётся декларативно и не вмешивается в код теста. Политика может быть определена глобально и при необходимости переопределена локально — без копипаста и без условной логики внутри тестов.
Когда тест запускается через Runner, Axiom сначала собирает полную конфигурацию выполнения — объединяя настройки Runner’а и конкретного Case. Эта конфигурация описывает всё, что относится к выполнению теста: метаданные, retry-политику, контекст, хуки, фикстуры, плагины и правила исполнения.
При retry происходит следующее:
Конфигурация теста собирается заново. Для каждой попытки Axiom создаёт новый runtime-снимок (Config), объединяя Runner и Case. Это означает, что retry — это не повтор в рамках уже существующего состояния, а новый запуск с теми же правилами.
Контекст и runtime не переиспользуются. Контекст выполнения, runtime-обёртки и execution-политики инициализируются заново. Ничего не «протекает» между попытками, если это явно не задано конфигурацией.
Фикстуры получают новый жизненный цикл. Все фикстуры считаются ленивыми и привязанными к конкретной попытке. При retry они будут пересозданы при первом обращении и корректно очищены после завершения попытки.
Хуки и плагины применяются повторно. Before/after-хуки, middleware и плагины выполняются так же, как при обычном запуске теста — но для каждой попытки отдельно.
И только после этого выполняется тело теста.
Таким образом, retry в Axiom — это повтор execution model, а не повтор функции.
Под капотом retry может выглядеть как обычный цикл — и это нормально. Ключевая разница не в реализации, а в точке повтора.
В Axiom повторяется не вызов тестовой функции, а вся модель выполнения теста: конфигурация, контекст, фикстуры и lifecycle. Благодаря этому retry остаётся предсказуемым, изолированным и управляемым даже в больших тестовых наборах.
В этой статье мы разобрали, как можно корректно реализовать retry в Go-автотестах и почему наивные подходы быстро приводят к нестабильному поведению тестов.
Axiom позволяет задать retry как часть тестового рантайма: политика повторных попыток конфигурируется централизованно на уровне Runner’а и при необходимости переопределяется локально на уровне конкретного теста. Это даёт контроль и предсказуемость — retry перестаёт быть хаотичным костылём и становится осознанной политикой выполнения.
Такой подход не лечит flaky-тесты, но позволяет работать с ними аккуратно: без протекания состояния, без скрытых побочных эффектов и без необходимости встраивать retry-логику прямо в тестовый код.