Разберите, почему этот unit-тест может завершиться до фиксации ошибки и как корректно сообщить о ней:
func TestLoad(t *testing.T) {
go func() {
if err := load(); err != nil {
t.Fatal(err)
}
}()
}
t.Fatal нельзя использовать как способ остановить тест из произвольной goroutine. Он вызывает runtime.Goexit только в текущей goroutine, поэтому не завершает goroutine, выполняющую тело теста. В приведённом примере тест может закончиться раньше фоновой проверки, а вызов t.Fatal окажется несинхронизированным с завершением теста.
Корректный подход — дождаться результата фоновой работы, а ошибку передать в основную goroutine теста и вызвать t.Fatal или t.Error там.
Пакет testing предоставляет методы Error, Fatal, FailNow и другие для управления состоянием теста. Их семантика рассчитана на то, что функция теста запускается в одной управляющей goroutine, тогда как проверяемый код может использовать дополнительные goroutine.
FailNow, который лежит в основе t.Fatal, должен вызываться из goroutine, выполняющей функцию теста. Это связано с механизмом runtime.Goexit: он завершает только текущую goroutine, а не весь процесс и не другие goroutine.
Если тест запускает фоновую работу и сразу возвращает управление, testing-фреймворк считает функцию теста завершённой. Фоновая goroutine может ещё не запуститься, завершиться позже или попытаться сообщить об ошибке уже после окончания теста.
Даже если ошибка иногда фиксируется, такой тест зависит от планирования goroutine. Это приводит к нестабильным результатам, скрытым ошибкам и гонкам между завершением теста и фоновой проверкой.
Нужно явно определить жизненный цикл фоновой операции: запустить её, дождаться результата и только затем завершить тест. Обычно для этого используют канал результата, sync.WaitGroup или другой механизм синхронизации.
Буфер канала размером один позволяет worker отправить результат и завершиться без дополнительного получателя в тот же момент. Вызов t.Fatal выполняется в основной goroutine теста, поэтому он корректно прекращает выполнение именно тестовой функции.
Если worker должен сообщать несколько событий, можно использовать sync.WaitGroup и отдельный канал ошибок. Важно дождаться Wait, прежде чем завершать тест; один только WaitGroup не передаёт саму ошибку.
t.Error и t.Errorf не прекращают выполнение теста. Их можно вызывать из worker при корректной синхронизации, однако чаще безопаснее собирать результаты в worker, а затем выполнять проверки в основной goroutine. Так проще контролировать порядок, контекст и отсутствие обращения к testing.T после завершения теста.
Нельзя исправлять проблему простым добавлением time.Sleep: задержка не является гарантией завершения работы и делает тест медленным и нестабильным.
В тесте HTTP-клиента запрос запускался в goroutine, а основная функция сразу возвращалась. При локальном запуске тест обычно проходил, но в CI иногда ошибка от mock-сервера не успевала обработаться.
Рассматривались три варианта. time.Sleep был отвергнут: он не гарантировал результат и увеличивал время тестов. Прямой вызов t.Fatal из worker тоже не подошёл, поскольку FailNow завершает только worker-goroutine. Вызов t.Error из worker с ожиданием через WaitGroup был рабочим, но усложнял сбор нескольких ошибок.
Выбрали канал результата: worker отправлял структуру с ответом и ошибкой, тест ждал её и выполнял все критические проверки в основной goroutine. В результате исчезла зависимость от планировщика, а ошибка стала воспроизводимо привязана к конкретной проверке.
Можно ли вызывать t.Error из фоновой goroutine?
Да, методы тестового объекта допускают конкурентное использование в предусмотренных сценариях, но worker всё равно должен завершиться до возврата функции теста. Иначе сообщение может быть потеряно или возникнет обращение к тесту после окончания его жизненного цикла. Практически надёжнее передать ошибку через канал и вызвать t.Error в основной goroutine.
Чем t.Fatal отличается от t.Error в этом сценарии?
t.Error помечает тест как проваленный и продолжает выполнение текущей goroutine. t.Fatal помечает тест как проваленный и вызывает Goexit в текущей goroutine. Поэтому t.Fatal в worker не останавливает основной тест, а t.Error не прекращает worker автоматически; в обоих случаях требуется явное управление завершением goroutine.
Достаточно ли дождаться worker, если он может зависнуть?
Нет. Обычный <-errCh или Wait может навсегда заблокировать тест при зависшем worker. Для операций с ограниченным временем используют context.WithTimeout или select с time.After, после чего тест сообщает о тайм-ауте. При этом отмена контекста должна поддерживаться самим проверяемым кодом; таймер не может принудительно остановить произвольную goroutine.