Каждый, кто писал тесты для конкурентного Go, знает эту боль. Нужно проверить, что что-то произошло (или не произошло) после работы горутины. А единственный способ это сделать — воткнуть
time.Sleep и надеяться, что на CI этого хватит.Выглядело это примерно так:
func TestTimeout(t *testing.T) {
// ... настраиваем логику таймаута ...
time.Sleep(10 * time.Millisecond) // авось хватит
if !timedOut {
t.Fatal("expected timeout, got none")
}
}10 миллисекунд на один тест — мелочь. Но когда таких тестов сотни, набегают секунды пустого ожидания. А на нагруженном CI раннере 10ms иногда недостаточно, и тест становится flaky.
Что делает synctest
Пакет
testing/synctest появился экспериментально в Go 1.24 за флагом GOEXPERIMENT=synctest. В Go 1.25 он вышел в GA с обновлённым API — флаг больше не нужен. Вместо старого synctest.Run используем synctest.Test. Старый API удалён в Go 1.26.Пакет решает проблему двумя механизмами.
Bubble.
synctest.Test() оборачивает тест в изолированную среду. Все горутины, запущенные внутри, принадлежат этому «пузырю».Виртуальное время. Внутри пузыря
time.Sleep, таймеры и тикеры работают на фейковых часах. Реальное время не идёт. Часы сдвигаются только тогда, когда все горутины в пузыре заблокированы и ждут. Тест с пятисекундным таймаутом выполняется за микросекунды.`synctest.Wait()` блокирует выполнение, пока все остальные горутины в пузыре не окажутся в состоянии устойчивой блокировки. После возврата из
Wait() мы точно знаем, что система обработала всё, что могла.Вот тот же тест, переписанный на synctest:
import "testing/synctest"
func TestTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
const timeout = 5 * time.Second
ctx, cancel := context.WithTimeout(
context.Background(), timeout,
)
defer cancel()
time.Sleep(timeout - time.Nanosecond)
synctest.Wait()
if err := ctx.Err(); err != nil {
t.Fatalf("context expired early: %v", err)
}
time.Sleep(time.Nanosecond)
synctest.Wait()
if ctx.Err() != context.DeadlineExceeded {
t.Fatalf("expected DeadlineExceeded, got %v", ctx.Err())
}
})
}
Никакого реального ожидания. Никаких flaky. Никаких изменений в тестируемом коде.
Что считается «устойчивой блокировкой»
Не все блокирующие операции равны.
synctest.Wait() учитывает только те, которые могут быть разблокированы исключительно горутинами внутри пузыря.Учитываются: отправка и получение из каналов, созданных внутри пузыря,
time.Sleep, sync.Cond.Wait, sync.WaitGroup.Wait.Не учитываются:
sync.Mutex (обычно захватывается ненадолго), сетевые вызовы и чтение файлов (их может разблокировать ядро ОС).Сетевые тесты
Для тестирования сетевого взаимодействия внутри пузыря используем
net.Pipe() — это in-memory соединения, которые synctest считает устойчиво блокирующими:func TestServerHandshake(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
defer clientConn.Close()
go runServer(serverConn)
go runClient(clientConn)
synctest.Wait()
// Все горутины заблокированы — хендшейк завершён
})
}Запуск
На Go 1.25+ никаких дополнительных флагов:
go test ./...
Если вы до сих пор боретесь с flaky-тестами в конкурентном коде, synctest убирает главную причину — гадание на
time.Sleep. Тесты становятся быстрыми и детерминированными.📍 Навигация: Вакансии • Задачи • Собесы
🐸 Библиотека Go-разработчика
#GoToProduction