Flaky-тесты: почему мигают и как чинить

Flaky-тест не «случайный» — у каждого мигания детерминированная причина: гонка, время, порядок выполнения, обход map, sleep вместо синхронизации. Разбираем пять классов на живом Go-стенде с реальной частотой мигания и настоящим детектором гонок, чиним каждый детерминизмом — а не ретраями

Flaky-тест — это тест, который на одном и том же коде то проходит, то падает. Он подрывает главное свойство тестового набора — доверие. Как только команда привыкает, что «красный иногда бывает и сам чинится повторным прогоном», она перестаёт верить всем красным сразу — и настоящий баг проезжает мимо под тем же соусом «наверное, опять flaky». «Зелёный после retry» — не победа, а слепота: повтор до зелёного одинаково хорошо прячет и гниль в тесте, и редкий реальный дефект.

Ключевая мысль статьи: flaky-тест не случаен. У каждого мигания есть конкретная, воспроизводимая причина, и почти всегда это недетерминизм — тест зависит от того, что не должно влиять на результат: от скорости машины, от порядка горутин, от порядка запуска тестов, от обхода map. Починка — не retry, а устранение этой зависимости. Разберём пять классов на живом стенде digital-cookbook/testing/flaky/: у каждого класса — мигающая версия (её частоту мигания реально замерили) и починенная (зелёная при любом числе прогонов и под детектором гонок). Go, числа сверены на go1.26.3.

Схема «Полдень»: flaky-тесты — центральная лампа-тест мигает между «passed» и «failed» на одном и том же коде («тот же код — разный результат»); пять причин по кругу: время (Sleep опаздывает на доли мс мимо TTL), гонка (две горутины и counter++ → WARNING: DATA RACE), sleep ≠ синхронизация (ридер читает, пока данные ещё не готовы), порядок обхода map, изоляция (тесты делят общее состояние); внизу диагностика -race/-shuffle/-count и «лечим детерминированно» (инъекция часов, atomic/mutex, каналы, сортировка ключей, t.Cleanup), зачёркнутый «retry» — ретрай прячет, не лечит

В статье

Почему flaky дорого стоит

Стоимость flaky-теста не в самом мигании, а во втором порядке — в том, что он делает с процессом:

  • Убивает доверие к набору. Один тест, который «иногда красный», обесценивает все красные: люди начинают перезапускать сборку не глядя. Настоящий регресс, проявляющийся в 1 прогоне из 20, теперь неотличим от шума.
  • Маскирует реальные баги. Часть flaky — это не «плохой тест», а честный редкий баг в продукте (гонка, зависящая от тайминга). Ретрай-до-зелёного гасит сигнал именно там, где он важнее всего.
  • Тормозит CI. Перезапуски, «прогнать ещё раз», ручной разбор «а это точно flaky?» — всё это время и внимание, умноженные на размер команды.

Поэтому flaky нельзя «терпеть»: каждый мигающий тест — либо чинится в корне, либо честно выносится в карантин с заведённой задачей. Про то, где flaky особенно больно (распределённые системы, eventual consistency), — в статье про тестирование распределённых систем; там же — почему ждать событие надо polling’ом, а не sleep.

Пять источников мигания

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

1. Зависимость от времени

Тест читает настоящие часы (time.Now()) или «синхронизируется» через time.Sleep с расчётом на тайминг. Классика — проверка TTL: выпустили токен на 20 мс, поспали 19 мс, ждём, что он ещё годен. Запас в 1 мс изредка перепрыгивается планировщиком или паузой GC — тест мигает.

func TestValidAt_FlakyRealClock(t *testing.T) {
	tok := NewToken(time.Now(), 20*time.Millisecond)
	time.Sleep(19 * time.Millisecond) // «подождать почти до истечения»
	if !ValidAt(tok, time.Now()) {
		t.Fatal("токен неожиданно истёк — Sleep перепрыгнул TTL")
	}
}

Этот класс самый коварный: на быстром деве запас в 1 мс почти не перепрыгивается — в наших замерах от 0 до 11 миганий на 200 прогонов (на linux/amd64 бывало 200/200 зелёных, ни одного мигания), поэтому локально тест выглядит совершенно стабильным. А на нагруженном CI частота взлетает — и «у меня всё зелёное» встречается с «в пайплайне мигает». Именно потому, что локально он может вообще не воспроизводиться, этот flaky живёт в кодовой базе месяцами.

2. Гонка данных

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

type UnsafeCounter struct{ n int }

func (c *UnsafeCounter) Inc() { c.n++ } // гонка: три операции без атомарности

У нас — ~23 мигания на 200 прогонов по значению. Но у гонки есть точный детектор — про него ниже.

3. «Синхронизация» через Sleep

Результат готовится асинхронно, а тест ждёт его фиксированным Sleep вместо настоящей синхронизации. Горутина пишет результат после ~2 мс работы, тест ждёт 1 мс и читает — почти всегда видит ещё не записанный ноль (~198 из 200 у нас). Sleep — это не синхронизация, а ставка на то, что машина успеет; поменяется нагрузка — поедет и частота.

4. Порядок обхода map

Обход map в Go намеренно рандомизирован. Вернуть ключи «как обходятся» и проверять их порядок в тесте — гарантированное мигание: для двух ключей порядок [a b]/[b a] выпадает по-разному (~20 из 200 у нас).

5. Порядок выполнения и изоляция

Тесты делят изменяемое состояние на уровне пакета. Один «наследил» (добавил в общий реестр и не убрал), другой ждёт пустой реестр — проходит, только если запущен первым. Пока тесты бегут в порядке объявления, всё зелено; стоит перемешать порядок — мигает.

var shared = NewRegistry() // ОДИН на весь пакет — корень зла

func TestShared_LeavesState(t *testing.T) {
	shared.Add("leaked") // наследил и не убрал
	// ...
}

func TestShared_ExpectsClean(t *testing.T) {
	if shared.Count() != 0 { // проходит, только если запущен ДО «наследившего»
		t.Fatalf("реестр не пуст (Count=%d) — порядко-зависимо", shared.Count())
	}
}

Как поймать flaky

Поймать flaky — это два шага: сначала воспроизвести статистически (прогнать тест много раз и увидеть, что часть прогонов падает), а потом локализовать детерминированную причину того, что выглядит случайным. Гонки — приятное исключение: у них есть точный детектор, который сразу указывает на строку. Три встроенных инструмента Go:

  • -count=N — прогнать тест N раз в одном запуске. Частоту мигания видно сразу: go test -tags=flaky -count=200 ./timedep/. Так сняты числа для timedep, race, async, maporder. Для order -count не годится — общее состояние копится между итерациями (см. -shuffle ниже).
  • -race — детектор гонок. Для класса гонок это не вероятностная ловля, а точный указатель на строку. На небезопасном счётчике он печатает настоящий WARNING: DATA RACE:
==================
WARNING: DATA RACE
Read at 0x00c0000cc1d8 by goroutine 14:
  ...race.(*UnsafeCounter).Inc()
      /src/race/counter.go:14 +0x7d
Previous write at 0x00c0000cc1d8 by goroutine 9:
  ...race.(*UnsafeCounter).Inc()
      /src/race/counter.go:14 +0x8f
==================

Строка counter.go:14 — ровно тот c.n++. Про сам детектор и его устройство — в серии про конкурентность в Go.

  • -shuffle=on (Go 1.17+) — рандомизация порядка тестов. Вскрывает зависимость от порядка выполнения: go test -tags=flaky -shuffle=on ./order/ то проходит, то падает в зависимости от выпавшего порядка — при повторных запусках примерно через раз, а с фиксированным порядком (по умолчанию) стабильно зелёный. Важная оговорка про метод: считать частоту через -count здесь нельзя — общее пакетное состояние копится между итерациями внутри одного процесса, и после первого «наследившего» теста падение становится постоянным. Это артефакт -count, а не рост flakiness; для честной оценки перезапускайте go test заново (свежий процесс на каждый прогон).

Отдельный приём — запустить тест в изоляции (-run TestX) и вместе со всеми: если в изоляции зелёный, а в компании красный, — это класс общего состояния (№5).

Как отличить flaky от настоящего бага: flaky — это недетерминизм (результат зависит от тайминга/порядка); настоящий баг — детерминированно неверный результат (падает всегда одинаково). Если тест под -count=1000 не упал ни разу, а в CI мигает, — почти наверняка внешний тайминг (класс №1 или №3).

Как чинить по классам

Починка всегда одна по духу — убрать недетерминизм, а не обернуть его ретраем.

Класс Причина Починка
время time.Now()/Sleep внутри логики время — аргумент, а не скрытое чтение часов; в тесте подаём фиксированное
гонка общий доступ без синхронизации sync/atomic или sync.Mutex
async через sleep ожидание по таймеру синхронизация каналом по факту готовности
порядок map проверка порядка обхода сортировка ключей или сравнение как множеств
порядок/изоляция общее состояние пакета свой экземпляр на тест + t.Cleanup для очистки

Два примера. Время — вынести часы в параметр, тогда тест детерминирован без всякого Sleep:

// время — аргумент, а не time.Now() внутри: это и есть шов для теста
func ValidAt(t Token, now time.Time) bool { return now.Before(t.ExpiresAt) }

// в тесте подаём контролируемое время — 0 миганий при любом -count
base := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
tok := NewToken(base, 30*time.Minute)
_ = ValidAt(tok, base.Add(10*time.Minute)) // true, детерминированно

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

func Compute(x int) <-chan int {
	out := make(chan int, 1)
	go func() { out <- x * x }()
	return out
}

// в тесте: got := <-Compute(9) — блокируемся ровно до готовности, не спим

Что общее инъекция часов даёт дизайну — вынесение зависимостей в швы — тема отдельной статьи про тестируемый кодготовится, с 22 сентября; flaky-тесты и тестопригодность — две стороны одной медали детерминизма.

Ретраи и карантин

Соблазн велик: обернуть мигающий тест автоповтором (--rerun-fails, retry-хелперы) — и «красный» исчезнет. Но повтор не лечит, а прячет: он одинаково гасит и гниль в тесте, и настоящий редкий баг, который тест честно поймал.

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

Карантин — вынести flaky-тест из блокирующего прогона (тег skip/отдельный джоб), чтобы он не рушил пайплайн, — допустим как временная мера с обязательной заведённой задачей на починку. Карантин без follow-up превращается в свалку тестов, которые никто не чинит и не удаляет. Все пять классов стенда устранимы детерминизмом, поэтому примеров ретрая в нём нет намеренно.

Процесс: как не плодить flaky

Flaky дешевле не допускать, чем ловить. Что помогает системно:

  • -race и -shuffle=on в CI по умолчанию. Гонки и зависимость от порядка ловятся до мержа, а не в проде. Стенд под обоими зелёный — это проверяемая цель, а не пожелание.
  • Детерминизм по дизайну. Инъекция часов, фиксированный seed у rand, синхронизация каналами/sync вместо Sleep, изоляция состояния через t.Cleanup. Каждый убранный источник недетерминизма — это flaky, который не родится.
  • Авто-детект. Периодически прогонять набор с -count=N (ночной джоб): тест, мигнувший хоть раз, помечается. Метрика flakiness rate по набору показывает тренд.
  • Культура «красный — стоп». Пока красный воспринимается всерьёз, flaky не накапливаются. Как только «перезапусти, оно само» становится нормой — набор мёртв.

Смежное — тестирование в Go (инструментальная база), TDD и BDD на практике (как тесты вообще писать), тестирование across языков (пирамида и дублёры).

Демо и версии

  • Код: живой стенд digital-cookbook/testing/flaky/ — пять подкаталогов (timedep, race, async, maporder, order), в каждом починенная версия (в обычных тестах) и мигающая (под build-тегом flaky). Код в тексте — выжимки.
  • Как воспроизвести: go test ./... (только починки — всегда зелёный), go test -race ./... (починки чисты под детектором). Мигание — явно: go test -tags=flaky -count=200 ./timedep/, ... -race ./race/, ... -shuffle=on ./order/ (перезапускать заново — см. оговорку про -count выше) и т. д. (полный список — в README стенда). На Windows без C-компилятора -race гоняется в контейнере golang:1.26.
  • Оговорка: частоты мигания — счётные величины, но сами недетерминированы: зависят от машины и нагрузки и плавают между прогонами (у timedep — от 0/200 на linux/amd64 до 11/200 на windows/amd64 в наших замерах). Приведённые числа сверены на go1.26.3 — воспроизводите на своей сборке, а не сверяйтесь дословно; на быстрой машине временны́е flaky могут не проявиться вовсе.
  • Смежные статьи: конкурентность в Go (race detector, гонки, утечки), тестируемый кодготовится, с 22 сентября (инъекция часов/зависимостей), тестирование распределённых систем (polling вместо sleep), тестирование в Go, TDD и BDD.

Документация и первоисточники

  • Go Testing — flags-count, -race, -shuffle, -run; официальное описание.
  • Data Race Detector — как устроен -race, что он ловит и чего не гарантирует (только исполнённые гонки).
  • Go blog: Testing shuffle — рандомизация порядка тестов с Go 1.17.
  • Martin Fowler, Eradicating Non-Determinism in Tests — классический разбор источников недетерминизма и стратегии борьбы.
  • Google Testing Blog, Flaky Tests at Google — масштаб проблемы и подход к ней в большой кодовой базе.

Обсуждение в Telegram

Присоединиться →

Комментарии