Тестирование конкурентного кода на Go с помощью synctest
«Перестаньте спать в конкурентных тестах на Go».
Тестирование конкурентного кода на Go всегда требует определенной дисциплины. Горутины дешевы, каналы просты, а отмена контекста — идиоматичный подход; фоновые воркеры и таймеры повсюду в реальных сервисах на Go.
Но тестировать все это с гарантией — сложнее, чем писать.

Обычная плохая практика знакома всем:
go doSomething()
time.Sleep(100 * time.Millisecond)
if !done {
t.Fatal("фоновая работа не завершилась")
}
Этот тест может проходить на вашем ноутбуке и падать в CI. Или он может работать полгода, а затем сломаться на загруженном раннерe. Или он может быть медленным, потому что кто-то увеличил время сна с 100 миллисекунд до 2 секунд «просто для страховки».
Это не хорошее тестирование — это азартная игра с таймером, и эта ставка становится дороже по мере роста тестового набора.
Пакет testing/synctest дает разработчикам на Go лучший способ тестировать многие виды асинхронного и зависящего от времени кода. Он позволяет тесту работать внутри изолированной «пузыря», дает этому пузырю фиктивное время (fake clock) и предоставляет способ ждать, пока горутины внутри пузыря не будут заблокированы.
Результат прост, но мощен:
- Никаких произвольных задержек (sleeps)
- Более быстрые тесты таймаутов
- Более детерминированные конкурентные тесты
- Лучшее тестирование отмены контекста
- Лучшее тестирование фоновых горутин
- Меньше «непредсказуемых» (flaky) ошибок в CI
Чуть более категоричная версия: если ваш конкурентный тест на Go зависит от реального time.Sleep, вы, скорее всего, должны считать этот тест подозрительным.
Что такое testing/synctest
testing/synctest — это пакет стандартной библиотеки Go для тестирования конкурентного кода.
Он предоставляет две основные функции:
package synctest
func Test(t *testing.T, f func(*testing.T))
func Wait()
synctest.Test выполняет функцию внутри изолированного тестового пузыря. Любые горутины, запущенные внутри этого пузыря, также становятся частью пузыря, время внутри пузыря фиктивное, а пакет time работает с этим фиктивным временем, а не с реальным настенным часами.
synctest.Wait ждет, пока все другие горутины в пузыре не будут стабильно заблокированы. Это звучит абстрактно, но практический эффект легко понять:
synctest.Test(t, func(t *testing.T) {
time.Sleep(10 * time.Second)
})
Это не заставляет ваш тест ждать 10 реальных секунд. Внутри пузыря synctest время может мгновенно продвигаться вперед, когда пузырь заблокирован и ожидает продвижения времени — это и есть ключевой трюк пакета.
Почему конкурентные тесты на Go непредсказуемы
Если вы новичок в тестировании Go в целом, статья Юнит-тестирование в Go: структура и лучшие практики охватывает пакет testing, табличные тесты и паттерны мок-объектов, которые являются основой, на которую опирается эта статья. Конкурентные тесты обычно непредсказуемы по одной из трех причин.
Во-первых, они зависят от планировщика (scheduler). Горутина может выполниться немедленно на вашей машине и позже в CI.
Во-вторых, они зависят от реального времени. Тест, который спит 50 миллисекунд, предполагает, что 50 миллисекунд — это достаточно времени для завершения фоновой работы.
В-третьих, они наблюдают состояние слишком рано. Тест проверяет результат до того, как фоновая операция действительно завершилась.
Вот простой пример:
func TestBackgroundWorkBad(t *testing.T) {
done := false
go func() {
done = true
}()
time.Sleep(10 * time.Millisecond)
if !done {
t.Fatal("фоновая работа не завершилась")
}
}
В этом тесте две проблемы.
Очевидная — это задержка (sleep). Нет гарантии, что 10 миллисекунд — это правильное количество времени.
Менее очевидная — это гонка данных (data race). Тест записывает done в одной горутине и читает его в другой без синхронизации.
Этот конкретный пример можно исправить с помощью канала или sync.WaitGroup, и часто это стоит сделать. Но когда тестируемый код использует таймеры, дедлайны контекста, time.AfterFunc, фоновых воркеров или отложенную очистку, тест все равно может стать неудобным — и именно здесь testing/synctest помогает.
Ключевая идея: запуск теста внутри пузыря
Пузырь synctest изолирует горутины, созданные внутри него.
Используйте его так:
func TestSomethingConcurrent(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// Тестируйте конкурентный код здесь.
})
}
Внутри пузыря:
- Горутины, запущенные тестом, принадлежат пузырю.
- Таймеры и задержки (sleeps) используют фиктивное время.
synctest.Waitможет ждать, пока фоновая активность не уляжется.- Тест должен избегать зависимости от внешних горутин, реального сетевого I/O или внешних процессов.
Пузырь — это не магия. Он не превращает плохой конкурентный дизайн в хороший. Но он дает вашему тесту контролируемую среду, где время и поведение блокировки более детерминированы.
Проблема с time.Sleep в тестах
Реальный time.Sleep в тесте обычно означает одно из двух:
Я не знаю, как ждать событие, которое меня действительно интересует.
или:
Я знаю, что меня интересует, но тестируемый код не предоставляет чистого способа это наблюдать.
Оба сигнала указывают на место, где продакшн-код может выиграть от более чистой наблюдаемости или более явных механизмов координации.
Рассмотрим функцию, которая завершает работу в фоновом режиме:
type Worker struct {
out chan string
}
func NewWorker() *Worker {
return &Worker{
out: make(chan string, 1),
}
}
func (w *Worker) Start() {
go func() {
time.Sleep(5 * time.Second)
w.out <- "done"
}()
}
func (w *Worker) Result() <-chan string {
return w.out
}
Плохой тест может выглядеть так:
func TestWorkerBad(t *testing.T) {
w := NewWorker()
w.Start()
time.Sleep(6 * time.Second)
select {
case got := <-w.Result():
if got != "done" {
t.Fatalf("получено %q, ожидалось done", got)
}
default:
t.Fatal("воркер не завершил работу")
}
}
Этот тест ждет 6 реальных секунд.
Это медленно. Если у вас много таких тестов, набор становится мучительным.
Лучший тест с synctest может мгновенно продвинуть фиктивное время:
func TestWorkerWithSynctest(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
w := NewWorker()
w.Start()
time.Sleep(5 * time.Second)
synctest.Wait()
select {
case got := <-w.Result():
if got != "done" {
t.Fatalf("получено %q, ожидалось done", got)
}
default:
t.Fatal("воркер не завершил работу")
}
})
}
Тест все еще выражает бизнес-факт — воркер должен завершиться через 5 секунд — но не тратит 5 реальных секунд на это. В этом разница между тестированием поведения, зависящего от времени, и трата времени разработчика.
Тестирование таймаутов контекста
Одно из лучших применений testing/synctest — тестирование дедлайнов и таймаутов context.Context. Как следует структурировать отмену контекста и бюджеты таймаутов между слоями сервиса, рассматривается в статье Go context.Context: Правильное использование, а правильная распространяемость context.Canceled и context.DeadlineExceeded через эти слои описана в статье Архитектура обработки ошибок в Go: границы и паттерны — synctest позволяет проверить это поведение без прохождения реального времени.
Вот простая функция, которая ждет отмены контекста:
func WaitForCancel(ctx context.Context, done chan<- error) {
go func() {
<-ctx.Done()
done <- ctx.Err()
}()
}
Без synctest тестирование этого с таймаутом 30 секунд либо сделало бы тест медленным, либо заставило бы изменить таймаут только для теста.
С synctest вы можете быстро протестировать реальную длительность таймаута:
func TestWaitForCancelWithTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
done := make(chan error, 1)
WaitForCancel(ctx, done)
synctest.Wait()
select {
case err := <-done:
t.Fatalf("контекст отменен слишком рано: %v", err)
default:
}
time.Sleep(30 * time.Second)
synctest.Wait()
select {
case err := <-done:
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("получено %v, ожидалось %v", err, context.DeadlineExceeded)
}
default:
t.Fatal("контекст не был отменен")
}
})
}
Это тот тип теста, который synctest делает приятным.
Вы можете использовать реалистичные значения таймаутов в коде и все равно запускать тесты быстро.
Тестирование отмены контекста
Вы также можете тестировать явную отмену без гонки с фоновой горутине.
func TestWaitForCancelWithExplicitCancel(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
WaitForCancel(ctx, done)
synctest.Wait()
select {
case err := <-done:
t.Fatalf("контекст отменен слишком рано: %v", err)
default:
}
cancel()
synctest.Wait()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("получено %v, ожидалось %v", err, context.Canceled)
}
default:
t.Fatal("контекст не был отменен")
}
})
}
Важная деталь — это synctest.Wait.
Он дает фоновой горутине шанс заметить отмену и улясть перед тем, как тест проверяет результат.
Что делает synctest.Wait
synctest.Wait ждет, пока все другие горутины в пузыре не будут стабильно заблокированы.
Простыми словами, это означает:
Ждите, пока горутины внутри этого теста не достигнут стабильной точки блокировки.
Это полезно, когда тест запускает горутина и нуждается в знании того, что горутина либо завершилась, либо ждет.
Например:
func TestWaitExample(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
done := false
go func() {
done = true
}()
synctest.Wait()
if !done {
t.Fatal("горутина не выполнилась")
}
})
}
Это намеренно маленький пример, но он демонстрирует идею.
synctest.Wait — это не просто более изящный sleep — это точка синхронизации внутри пузыря, и это различие важнее, чем кажется на первый взгляд.
Sleep говорит:
Я надеюсь, что прошло достаточно времени.
Wait говорит:
Я хочу, чтобы пузырь достиг стабильного заблокированного состояния.
Второй вариант гораздо лучше для тестов, потому что он описывает наблюдаемое состояние, а не догадку о прошедшем времени.
Фиктивное время в пузыре synctest
Внутри пузыря synctest пакет time использует фиктивные часы.
Фиктивные часы начинаются с фиксированного времени. Они продвигаются вперед только тогда, когда все горутины в пузыре стабильно заблокированы и время нужно продвинуть, чтобы разблокировать что-то.
Это означает, что этот тест быстрый:
func TestFakeTime(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
time.Sleep(1 * time.Hour)
elapsed := time.Since(start)
if elapsed != time.Hour {
t.Fatalf("получено %v, ожидалось %v", elapsed, time.Hour)
}
})
}
Он читается так, будто ждет час.
Но нет.
Это полезно для тестирования:
- таймаутов
- дедлайнов
- повторных попыток (retries)
- backoff-логики
- отложенной очистки
- лимитов скорости
- таймеров
- тикеров
- отмены контекста
Но есть одно важное правило: фиктивное время помогает только коду, который использует пакет time внутри пузыря.
Если ваш код зависит от внешней системы, реального сетевого I/O или времени, измеряемого вне пузыря, synctest не может сделать это детерминированным.
Тестирование цикла повторных попыток
Циклы повторных попыток — частый источник медленных и непредсказуемых тестов.
Вот небольшой помощник для повторных попыток:
func Retry(ctx context.Context, attempts int, delay time.Duration, fn func() error) error {
var last error
for i := 0; i < attempts; i++ {
if err := fn(); err != nil {
last = err
} else {
return nil
}
if i == attempts-1 {
break
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
return last
}
Обычный тест может уменьшить задержку до 1 миллисекунды, просто чтобы сохранить набор быстрым.
Это не страшно, но это означает, что тест больше не проверяет реальное значение, используемое продакшн-кодом.
С synctest вы можете сохранить реальную задержку:
func TestRetryEventuallySucceeds(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx := t.Context()
calls := 0
err := Retry(ctx, 3, 10*time.Second, func() error {
calls++
if calls < 3 {
return errors.New("временный сбой")
}
return nil
})
if err != nil {
t.Fatalf("Retry вернул ошибку: %v", err)
}
if calls != 3 {
t.Fatalf("вызовов = %d, ожидалось 3", calls)
}
})
}
Тест представляет собой две 10-секундные ожидания.
И все равно он выполняется быстро.
Здесь synctest меняет экономику тестирования. Вам больше не нужно разбрасывать фиктивные маленькие длительности по тестам, только чтобы избежать медленной CI.
Тестирование отмены во время повторных попыток
Вы также можете тестировать отмену во время задержки повторной попытки:
func TestRetryStopsWhenContextCanceled(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
errCh := make(chan error, 1)
go func() {
errCh <- Retry(ctx, 10, 10*time.Second, func() error {
return errors.New("временный сбой")
})
}()
synctest.Wait()
cancel()
synctest.Wait()
select {
case err := <-errCh:
if !errors.Is(err, context.Canceled) {
t.Fatalf("получено %v, ожидалось %v", err, context.Canceled)
}
default:
t.Fatal("Retry не вернул управление после отмены")
}
})
}
Этот тест проверяет, что цикл повторных попыток реагирует на отмену, а не спит во время задержки.
Это именно то поведение, которое важно в продакшене.
Тестирование time.AfterFunc
time.AfterFunc — еще один хороший кандидат.
Предположим, у вас есть функция, которая планирует очистку:
type Cache struct {
cleaned chan struct{}
}
func NewCache() *Cache {
return &Cache{
cleaned: make(chan struct{}, 1),
}
}
func (c *Cache) CleanupAfter(d time.Duration) {
time.AfterFunc(d, func() {
c.cleaned <- struct{}{}
})
}
func (c *Cache) Cleaned() <-chan struct{} {
return c.cleaned
}
Тест может продвинуть фиктивное время:
func TestCleanupAfter(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
cache := NewCache()
cache.CleanupAfter(1 * time.Minute)
synctest.Wait()
select {
case <-cache.Cleaned():
t.Fatal("очистка произошла слишком рано")
default:
}
time.Sleep(1 * time.Minute)
synctest.Wait()
select {
case <-cache.Cleaned():
default:
t.Fatal("очистка не произошла")
}
})
}
Этот тест проверяет обе стороны:
- Очистка не происходит до задержки.
- Очистка происходит после задержки.
И он не ждет реальной минуты.
Тестирование тикеров
Тикеры также можно тестировать с фиктивным временем, но будьте осторожны. Тикеры часто используются в циклах длительного выполнения, а циклы длительного выполнения нуждаются в чистом пути завершения работы.
Вот маленький счетчик на основе тикера:
type Counter struct {
ticks int
done chan struct{}
}
func NewCounter() *Counter {
return &Counter{
done: make(chan struct{}),
}
}
func (c *Counter) Start(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
defer ticker.Stop()
defer close(c.done)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.ticks++
}
}
}()
}
func (c *Counter) Wait() {
<-c.done
}
func (c *Counter) Ticks() int {
return c.ticks
}
Тест может выглядеть так:
func TestCounterTicks(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
counter := NewCounter()
counter.Start(ctx, 10*time.Second)
time.Sleep(35 * time.Second)
synctest.Wait()
cancel()
counter.Wait()
if counter.Ticks() != 3 {
t.Fatalf("тики = %d, ожидалось 3", counter.Ticks())
}
})
}
В этом примере есть намеренная деталь дизайна: у воркера есть путь завершения работы.
Это хорошо не только для тестов. Это хорошо для продакшена.
Тесты часто показывают, могут ли ваши горутины действительно остановиться.
synctest и утечки горутин
testing/synctest полезен здесь, потому что synctest.Test ждет выхода горутин из пузыря перед возвратом, что означает, что утекающие горутины сложнее игнорировать. Если фоновая горутина никогда не завершается, тест падает, а не молча оставляет работу позади — и это хорошо.
Конкурентный код должен иметь четкое владение (ownership). Если функция запускает горутина, должен быть явный способ ее остановить или задокументированная причина, почему она разрешена жить вечно. В тестах «вечно» почти никогда не приемлемо. Владение плюс потолок конкурентности — это привычка в проектировании, которую эта статья сочетает со статьей Ограничение конкурентности в Go которая охватывает продакшн-паттерны, чьи пути утечки пузырь ловит за миллисекунды.
Хороший паттерн:
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
Заставьте горутина останавливаться, когда контекст отменяется.
Что на практике значит «стабильно заблокирован»
Официальные документы используют термин «durably blocked» (стабильно заблокирован).
Вам не нужно запоминать каждую деталь рантайма, но вы должны понимать практический смысл.
Горутина стабильно заблокирована, когда она заблокирована таким образом, который может быть разблокирован только чем-то внутри того же пузыря synctest.
Примеры включают:
- получение из канала, созданного внутри пузыря
- отправку в канал, созданного внутри пузыря
- ожидание на
sync.WaitGroup, связанном с пузырем - сон с
time.Sleep - ожидание на определенных операциях таймера
Некоторые вещи не являются стабильно заблокированными, потому что что-то вне пузыря может их разблокировать.
Примеры включают:
- сетевой I/O
- системные вызовы
- операции внешних процессов
- некоторые ожидания на мьютексах
- взаимодействия с горутинами вне пузыря
Поэтому тесты synctest должны быть самодостаточными и свободными от внешней синхронизации, которую пузырь не видит. Не используйте synctest как обертку для интеграционных тестов, которые общаются с реальной сетью.
Для чего хорош synctest
testing/synctest особенно хорош для юнит-тестов вокруг асинхронного поведения.
Хорошие кандидаты включают:
- отмену контекста
- таймауты контекста
- циклы повторных попыток
- backoff-логику
- отложенную очистку
- таймер-ориентированных воркеров
- тикер-ориентированные циклы
- фоновые горутины
- поведение таймаутов
- координацию каналов
time.AfterFunc- детерминированное ожидание горутин
Лучший случай использования — код, где трудная часть — это время или планирование, а не внешний I/O.
Для чего synctest не подходит
testing/synctest не является заменой для всех конкурентных тестов.
Это не полный детерминированный планировщик для всех возможных гонок.
Это не замена детектору гонок.
Это не замена интеграционным тестам.
Это не делает реальный сетевой I/O детерминированным.
Это не чинит плохой дизайн жизненного цикла горутин.
Это не означает, что вы можете игнорировать каналы, контексты, владение и завершение работы.
Используйте synctest для правильного слоя: детерминированные юнит-тесты для конкурентного и зависящего от времени поведения.
Используйте другие инструменты для других слоев:
- используйте
go test -raceдля обнаружения гонок данных - используйте интеграционные тесты для реальных зависимостей
- используйте нагрузочные тесты для пропускной способности и конкуренции
- используйте бенчмарки для производительности
- используйте трейсинг и профилирование для поведения в продакшене
synctest vs детектор гонок
testing/synctest и детектор гонок решают разные проблемы.
Детектор гонок находит небезопасный конкурентный доступ к памяти.
synctest помогает вам контролировать асинхронное время и ожидание в тестах.
Вы часто должны использовать оба.
Например, это все еще гонка даже внутри пузыря synctest, если нет правильной синхронизации:
value := 0
go func() {
value = 1
}()
_ = value
synctest.Wait может предоставить точку синхронизации для некоторых тестовых паттернов, но это не означает, что каждый конкурентный доступ в вашем коде автоматически безопасен.
Запускайте конкурентные тесты с:
go test -race ./...
Детектор гонок по-прежнему является одним из лучших инструментов, которые дает вам Go. Его сочетание со статьей Линтеры для Go: необходимые инструменты для качества кода дает вам надежную базу статического анализа и проверки во время выполнения для любой конкурентной кодовой базы.
synctest vs ручные фиктивные часы
До testing/synctest многие команды использовали ручные фиктивные часы.
Это может по-прежнему быть хорошим дизайном.
Интерфейс ручных часов может выглядеть так:
type Clock interface {
Now() time.Time
After(time.Duration) <-chan time.Time
Sleep(time.Duration)
}
Затем продакшн-код использует реальные часы, а тесты используют фиктивные.
Это дает явный контроль, но имеет цену:
- больше интерфейсов
- больше «проводки» (plumbing)
- больше абстракций только для тестов
- больше способов, чтобы код случайно обошел фиктивные часы
synctest привлекателен тем, что обычный код, использующий пакет time, может работать с фиктивным временем внутри тестового пузыря.
Это снижает потребность в внедрении часов во многих случаях.
Моё мнение: используйте synctest, когда он упрощает продакшн-код. Используйте внедренные часы только тогда, когда контроль над временем является частью вашего доменного дизайна или когда вам нужен контроль за пределами того, что предоставляет synctest. Для более широкого взгляда на паттерны внедрения зависимостей в Go — включая, когда и как внедрять тестируемые абстракции, — см. Внедрение зависимостей в Go: паттерны и лучшие практики.
synctest vs каналы и WaitGroups
Не заменяйте хорошую синхронизацию synctest.
Если ваш код может предоставить канал завершения, колбэк или метод Wait, это часто хороший дизайн.
Например:
type Server struct {
done chan struct{}
}
func (s *Server) Done() <-chan struct{} {
return s.done
}
Тест может ждать его напрямую.
synctest наиболее полезен, когда тестируемое поведение включает время, дедлайны контекста, фоновое планирование или асинхронные колбэки.
Лучшие тесты часто комбинируют оба:
- продакшн-код имеет явные сигналы завершения или завершения работы
- synctest убирает ожидание реального времени
- Wait делает фоновую активность детерминированной
Частые ошибки
Ошибка 1: Оборачивание каждого теста в synctest
Не используйте synctest везде. Если код синхронный, обычная тестовая функция яснее, и добавление обертки пузыря вносит лишь лишнюю механику, которая делает тесты труднее читать и анализировать.
Ошибка 2: Тестирование реального сетевого I/O внутри пузыря
Держите тесты synctest самодостаточными. Если ваш тест использует реальный сетевой сокет, внешний сервис, базу данных или подпроцесс, он принадлежит интеграционным тестам, а не внутри пузыря synctest. Используйте фейки для юнит-тестов и отводите реальные зависимости для отдельных интеграционных тестов, где изоляция пузыря не применяется. Когда зависимость — это HTTP-обработчик, httptest.NewTestServer в Go 1.27 убирает потребность в реальном сокете полностью — он запускает сервер поверх в памяти фиктивной сети, что именно нужно пузырю, и сочетается с synctest для детерминированного тайминга раунд-трипа, как показано в Руководство по обновлению Go 1.27.
Ошибка 3: Утечка горутин
Если ваш тест запускает горутина, убедитесь, что у нее есть четкий путь выхода. Используйте отмену контекста, закрытые каналы или явные методы остановки — горутина, которая никогда не останавливается, это и пахнет продакшена, и пахнет теста, который synctest обнажит, а не скроет.
Ошибка 4: Зависимость от пакетного уровня состояния
Каналы, таймеры и WaitGroup на уровне пакета могут нарушать изоляцию пузыря тонкими способами. Предпочитайте создание всего тестового состояния внутри функции synctest.Test, чтобы каждый ресурс принадлежал пузырю, и его время жизни было четко ограничено тестом.
Ошибка 5: Отношение к фиктивному времени как к реальному
Фиктивное время предназначено для детерминированных тестов, а не для измерения производительности. Тест, который мгновенно продвигает один час, не дает полезной информации о стоимости CPU, конкуренции за блокировки, использовании памяти или реальном поведении планировщика в продакшене — используйте бенчмарки и нагрузочные тесты для таких вопросов.
Ошибка 6: Игнорирование детектора гонок
synctest не является заменой go test -race, и эти два инструмента решают разные проблемы. Запускайте детектор гонок вместе с вашими тестами synctest, чтобы поймать небезопасный конкурентный доступ к памяти, который сам по себе пузырь не может обнаружить.
Практический чек-лист
Используйте этот чек-лист при написании тестов с testing/synctest.
Используйте synctest, когда
- код запускает горутины
- код использует
time.Sleep - код использует таймеры или тикеры
- код использует дедлайны контекста
- код имеет поведение повторных попыток или backoff
- тест в настоящее время использует произвольные задержки
- тест непредсказуем в CI
- тест медленный, потому что ждет реального времени
Избегайте synctest, когда
- код синхронный
- тест зависит от реального сетевого I/O
- тест зависит от внешних процессов
- тест на самом деле является интеграционным
- вы пытаетесь измерить производительность
- у кода нет чистого пути завершения работы
Предпочитайте этот паттерн
func TestSomething(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// Подготовьте (Arrange).
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
// Действуйте (Act).
_ = ctx
// Дайте фоновой работе улягаться.
synctest.Wait()
// Продвиньте фиктивное время при необходимости.
time.Sleep(1 * time.Second)
synctest.Wait()
// Ассертируйте (Assert).
})
}
Этот паттерн прост:
- настройте внутри пузыря
- запустите работу внутри пузыря
- подождите, пока фоновая активность не уляжется
- продвиньте фиктивное время только при необходимости
- ассертируйте после синхронизации
Где использовать testing/synctest в реальных проектах
Лучшие места для поиска обычно не в простой бизнес-логике.
Ищите тесты с этими «запахами»:
grep -R "time.Sleep" .
grep -R "time.After" .
grep -R "WithTimeout" .
grep -R "WithDeadline" .
grep -R "NewTicker" .
grep -R "AfterFunc" .
Затем спросите:
- Этот тест медленный, потому что ждет реального времени?
- Этот тест непредсказуем, потому что предполагает, что горутина уже выполнилась?
- Можно ли изолировать этот тест от сети и внешних процессов?
- Можно ли чисто остановить фоновую горутина?
- Сделало бы фиктивное время ассерты яснее?
Хорошие кандидаты часто находятся в:
- пакетах воркеров
- пакетах повторных попыток
- пакетах кэшей
- пакетах планировщиков
- потребителях очередей
- обертках HTTP-клиентов
- middleware таймаутов
- коде фоновой очистки
- коде лимитирования скорости
Начните с одного непредсказуемого теста. Не мигрируйте всю кодовую базу сразу. Если ваш тестовый набор использует параллельные табличные тесты наряду с асинхронным кодом, статья Параллельные табличные тесты в Go охватывает паттерны t.Parallel() и ловушки гонок данных, которые естественно сочетаются с подходом synctest.
Пример: до и после
Вот реалистичный плохой тест:
func TestRetryBad(t *testing.T) {
calls := 0
err := Retry(context.Background(), 3, 500*time.Millisecond, func() error {
calls++
if calls < 3 {
return errors.New("временный сбой")
}
return nil
})
if err != nil {
t.Fatalf("Retry вернул ошибку: %v", err)
}
if calls != 3 {
t.Fatalf("вызовов = %d, ожидалось 3", calls)
}
}
Это ждет около одной секунды, потому что происходят две задержки повторных попыток.
Это может не казаться плохим, но умножьте это на много тестов и несколько пакетов. Медленные тесты заставляют разработчиков запускать тесты реже.
Теперь версия с synctest:
func TestRetryWithSynctest(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
calls := 0
err := Retry(t.Context(), 3, 500*time.Millisecond, func() error {
calls++
if calls < 3 {
return errors.New("временный сбой")
}
return nil
})
if err != nil {
t.Fatalf("Retry вернул ошибку: %v", err)
}
if calls != 3 {
t.Fatalf("вызовов = %d, ожидалось 3", calls)
}
})
}
Тест сохраняет реальное значение задержки, набор остается быстрым, а намерение яснее. Это и есть основная ценность testing/synctest.
Как безопасно внедрить synctest
Я бы внедрял его постепенно.
Шаг 1: Найдите непредсказуемые или медленные конкурентные тесты
Ищите реальные задержки и тесты с большим количеством таймаутов. Команды grep из предыдущего раздела — хорошая отправная точка для выявления кандидатов по всей кодовой базе.
Шаг 2: Выберите один пакет
Выберите пакет, который имеет четкое асинхронное поведение, но не требует реальных внешних сервисов. Пакеты воркеров, помощники повторных попыток и компоненты, управляемые таймерами, являются идеальными первыми целями.
Шаг 3: Конвертируйте один тест
Заверните тест в synctest.Test и замените произвольные задержки на synctest.Wait, задержки фиктивного времени или явную синхронизацию. Конвертация обычно мала — самая трудная часть — убедиться, что у горутин есть чистые пути завершения работы.
Шаг 4: Запустите с детектором гонок
Всегда запускайте с go test -race ./... после конвертации. Прошедший тест synctest не означает, что код свободен от гонок; это только означает, что асинхронное время теперь детерминировано.
Шаг 5: Проверьте жизненный цикл горутин
Убедитесь, что каждая горутина, запущенная тестом, имеет способ выйти до закрытия пузыря. Если ее нет, synctest.Test обнажит утечку, а не молча проигнорирует ее.
Шаг 6: Повторяйте только там, где это улучшает ясность
Не конвертируйте тесты ради моды. Хороший тест synctest должен быть измеримо быстрее, яснее для чтения или менее непредсказуемым, чем версия, которую он заменил, — если нет, конвертация не стоила того.
Мои категоричные правила
Используйте эти как практические правила.
Правило 1: Никаких произвольных задержек в конкурентных юнит-тестах
Задержка, которая ждет, что горутина, возможно, завершится, — это «запах». Замените ее на каналы, WaitGroups, колбэки, synctest.Wait или фиктивное время — на что угодно, что ждет условия, а не надеется, что прошло достаточно времени.
Правило 2: Держите тесты synctest самодостаточными
Создавайте горутины, каналы, контексты, таймеры и воркеров внутри пузыря. Избегайте общего состояния на уровне пакета, которое может протекать между тестами и нарушать изоляцию, делающую synctest полезным.
Правило 3: Не используйте synctest как обертку для интеграционных тестов
Если тест общается с реальной базой данных, реальной сетью или внешним процессом, держите его вне synctest, если у вас нет очень конкретной причины для этого.
Правило 4: Тестируйте поведение, а не удачу планировщика
Цель не в том, чтобы заставить горутина выполниться. Цель в том, чтобы проверить наблюдаемое поведение после того, как система достигла значимого состояния, что synctest.Wait делает возможным без зависимости от допущений о таймингах.
Правило 5: Держите пути отмены явными
Каждая фоновая горутина должна иметь путь завершения работы, и тесты должны доказать, что этот путь работает, отменяя контекст или закрывая канал, а затем проверяя, что горутина чисто выходит.
Финальные мысли
testing/synctest — это одна из тех фич Go, которая выглядит небольшой, но меняет то, как вы пишете целый класс тестов. Это не заменяет хороший конкурентный дизайн, детектор гонок или потребность в интеграционных тестах — но это делает многие асинхронные юнит-тесты быстрее, чище и гораздо менее зависимыми от удачи с таймингами.
Это важно, потому что конкурентный код уже достаточно сложен. Тесты должны уменьшать неопределенность, а не добавлять к ней. Для более широкого взгляда на продакшн-паттерны Go в области интеграции, структуры кода и доступа к данным, см. Архитектура приложения в продакшене.
Практический вывод прост:
Используйте synctest для детерминированных юнит-тестов вокруг горутин, таймеров, таймаутов, повторных попыток и отмены.
Держите реальные задержки подальше от конкурентных тестов, если у вас нет очень хорошей причины.
Эта одна привычка сделает многие тестовые наборы Go быстрее и менее непредсказуемыми.
Важные текущие факты: testing/synctest стал общедоступным в Go 1.25, он предоставляет synctest.Test и synctest.Wait, он запускает тесты внутри изолированного пузыря, и время внутри этого пузыря использует фиктивные часы, которые продвигаются только тогда, когда горутины стабильно заблокированы.
Источники
- https://pkg.go.dev/testing/synctest
- https://go.dev/blog/testing-time
- https://go.dev/blog/synctest
- [https://go.dev/blog/testing-time](“Testing Time (and other asynchronicities) - The Go Programming Language”)
- https://go.dev/doc/go1.25
- https://go.dev/blog/go1.25