В unit тесте завершения горутины дожидаются через time.Sleep: какой дефект делает такой тест недетерминиров...

В unit-тесте завершения горутины дожидаются через time.Sleep: какой дефект делает такой тест недетерминированным?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

time.Sleep не подтверждает завершение горутины, а лишь делает предположение о достаточной длительности ожидания. Поэтому тест может быть нестабильным: при короткой паузе он проверит состояние слишком рано, а при длинной — будет неоправданно медленным. Нужно синхронизироваться по событию завершения, например через канал или sync.WaitGroup.

Исторический контекст

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

Постановка проблемы

Планировщик Go, загрузка процессора, фоновые процессы и особенности CI могут изменить момент, когда горутина получит процессор. Если задержка меньше необходимого времени, тест получает промежуточное состояние и иногда падает; если она больше, каждый запуск тратит лишнее время.

Особенно опасно, когда тест после Sleep проверяет отсутствие или наличие результата. Такая проверка зависит не от контракта программы, а от случайного соотношения между задержкой и планированием горутин.

Подробное решение

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

func TestWorker(t *testing.T) { done := make(chan struct{}) go func() { work() close(done) }() select { case <-done: case <-time.After(time.Second): t.Fatal("worker did not finish") } }

Закрытие канала сообщает о завершении работы, а select с тайм-аутом предотвращает вечное зависание теста. Тайм-аут здесь является защитой от зависания и диагностикой, но не механизмом синхронизации.

sync.WaitGroup подходит, когда нужно дождаться нескольких горутин и их результат передаётся другим способом. Канал удобнее, если завершение само является событием или нужно передать ошибку. Если тест проверяет состояние, одной синхронизации завершения может быть недостаточно: доступ к общим данным также должен быть корректно синхронизирован.

Ситуация из практики

Тест фонового обработчика сначала использовал паузу в 100 миллисекунд. Локально он проходил, но в CI иногда видел пустой список результатов: под нагрузкой обработчик не успевал стартовать. Увеличение паузы до секунды уменьшило вероятность сбоя, но замедлило весь набор и не устранило проблему принципиально.

Рассматривались polling с коротким интервалом и явный канал завершения. Polling мог быть полезен для проверки состояния, которое не имеет отдельного события, но требовал тайм-аутов и усложнял диагностику. Выбрали канал завершения и отдельную передачу ошибки; тест стал детерминированным и завершался сразу после результата, а тайм-аут оставили только как защиту.

Что кандидаты часто упускают

  1. Вопрос: Достаточно ли заменить time.Sleep на цикл с частыми проверками состояния?

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

  2. Вопрос: Где следует устанавливать тайм-аут ожидания в конкурентном тесте?

    Ответ: На границе ожидания события, например в select, а не в виде безусловной задержки перед проверкой. Тайм-аут должен быть достаточно большим для нормального CI, но ограниченным, чтобы ошибка проявлялась быстро. В сообщении полезно указывать, какого события тест ожидал, иначе зависание трудно диагностировать.

  3. Вопрос: Почему sync.WaitGroup нужно настраивать до запуска горутин?

    Ответ: Счётчик должен быть увеличен до того, как ожидающая горутина начнёт выполнять Wait. Если вызвать Add внутри запускаемой горутины, ожидающая сторона может увидеть нулевой счётчик и завершить ожидание раньше фактической регистрации работы. Корректная схема — сначала выполнить Add, затем запустить горутину, а внутри неё гарантировать Done, обычно через defer.