Тесты конкурентного кода, которые не флакают
В каждом проекте с горутинами есть тест, который проходит локально, падает в CI и снова проходит после перезапуска. Обычно его помечают как известную проблему и гоняют пайплайн до зелёного.
Причина почти всегда во времени. Код опирается на time.Sleep , context.WithTimeout и time.AfterFunc, а реальные часы идут вперёд независимо от того, успела горутина или нет. Тест проверяет таймаут в пять секунд — значит, честно висит пять секунд и всё равно иногда не угадывает момент.
Go 1.25 вывел testing/synctest из экспериментального статуса и закрывает эту проблему на уровне рантайма. В пакете всего две функции.
synctest.Test запускает код в изолированном пузыре, где time работает на фальшивых часах: время стоит, пока хоть одна горутина может выполняться, и прыгает вперёд, когда все заблокированы.
func TestReadTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ch := make(chan int)
_, err := ReadWithTimeout(ch, 60*time.Second)
if err == nil {
t.Fatal("expected timeout, got nil")
}
})
}
Таймаут в минуту, а тест выполняется мгновенно: горутина заблокировалась на чтении из канала, часы в пузыре прыгнули на минуту вперёд, таймер сработал. Ни параметризации таймаута ради тестируемости, ни подмены часов через интерфейс.
Вторая функция нужна, когда в пузыре работают фоновые горутины. synctest.Wait блокируется, пока все они не окажутся заблокированы на канале, таймере или подобном:
func TestWorkerProcessesJobs(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
w := NewWorker()
go w.Run(t.Context())
w.Submit("a")
w.Submit("b")
synctest.Wait() // обе задачи разобраны
if got := w.Processed(); got != 2 {
t.Fatalf("processed = %d, want 2", got)
}
})
}
Без Wait пришлось бы ставить time.Sleep наугад и надеяться, что воркер успел, — ровно то, из-за чего тест и флакал. Детектор гонок про Wait знает, так что -race работает корректно.
Правок в коде обычно не требуется, но одна встречается регулярно. Если компонент внутри себя вызывает context.Background(), контекст создаётся вне пузыря и под фальшивые часы не попадает. Такие места переделывают так, чтобы контекст приходил снаружи и в тесте передавался t.Context().
Границы у пузыря жёсткие. Горутины, запущенные до synctest.Test или в init, живут по настоящим часам. Сторонние абстракции над временем не перехватываются — подменяется только стандартный time, так что самописный Clock придётся инжектить как раньше.
И ещё: synctest.Run из Go 1.24 объявлен устаревшим, а в 1.26 удалён — если пробовали пакет на экспериментальной стадии, вызовы нужно заменить на Test.
Начинать разумнее с того теста, который все обходят стороной: обернуть в synctest.Test, убрать time.Sleep в пользу Wait и прогнать тысячу раз.
#backendvkhub #go
В каждом проекте с горутинами есть тест, который проходит локально, падает в CI и снова проходит после перезапуска. Обычно его помечают как известную проблему и гоняют пайплайн до зелёного.
Причина почти всегда во времени. Код опирается на time.Sleep , context.WithTimeout и time.AfterFunc, а реальные часы идут вперёд независимо от того, успела горутина или нет. Тест проверяет таймаут в пять секунд — значит, честно висит пять секунд и всё равно иногда не угадывает момент.
Go 1.25 вывел testing/synctest из экспериментального статуса и закрывает эту проблему на уровне рантайма. В пакете всего две функции.
synctest.Test запускает код в изолированном пузыре, где time работает на фальшивых часах: время стоит, пока хоть одна горутина может выполняться, и прыгает вперёд, когда все заблокированы.
func TestReadTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ch := make(chan int)
_, err := ReadWithTimeout(ch, 60*time.Second)
if err == nil {
t.Fatal("expected timeout, got nil")
}
})
}
Таймаут в минуту, а тест выполняется мгновенно: горутина заблокировалась на чтении из канала, часы в пузыре прыгнули на минуту вперёд, таймер сработал. Ни параметризации таймаута ради тестируемости, ни подмены часов через интерфейс.
Вторая функция нужна, когда в пузыре работают фоновые горутины. synctest.Wait блокируется, пока все они не окажутся заблокированы на канале, таймере или подобном:
func TestWorkerProcessesJobs(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
w := NewWorker()
go w.Run(t.Context())
w.Submit("a")
w.Submit("b")
synctest.Wait() // обе задачи разобраны
if got := w.Processed(); got != 2 {
t.Fatalf("processed = %d, want 2", got)
}
})
}
Без Wait пришлось бы ставить time.Sleep наугад и надеяться, что воркер успел, — ровно то, из-за чего тест и флакал. Детектор гонок про Wait знает, так что -race работает корректно.
Правок в коде обычно не требуется, но одна встречается регулярно. Если компонент внутри себя вызывает context.Background(), контекст создаётся вне пузыря и под фальшивые часы не попадает. Такие места переделывают так, чтобы контекст приходил снаружи и в тесте передавался t.Context().
Границы у пузыря жёсткие. Горутины, запущенные до synctest.Test или в init, живут по настоящим часам. Сторонние абстракции над временем не перехватываются — подменяется только стандартный time, так что самописный Clock придётся инжектить как раньше.
И ещё: synctest.Run из Go 1.24 объявлен устаревшим, а в 1.26 удалён — если пробовали пакет на экспериментальной стадии, вызовы нужно заменить на Test.
Начинать разумнее с того теста, который все обходят стороной: обернуть в synctest.Test, убрать time.Sleep в пользу Wait и прогнать тысячу раз.
#backendvkhub #go