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

Обычный плохой паттерн знаком всем:
go doSomething()
time.Sleep(100 * time.Millisecond)
if !done {
t.Fatal("фоновая работа не завершена")
}
Этот тест может проходить на вашем ноутбуке и падать в CI. Или он может работать шесть месяцев, а затем упасть на перегруженном раннере. Или он может быть медленным, потому что кто-то увеличил время сна с 100 миллисекунд до 2 секунд «просто для надежности».
Это не хорошее тестирование — это игра в рулетку с таймером, и эта ставка становится дороже по мере роста набора тестов.
Пакет testing/synctest предоставляет разработчикам Go лучший способ тестирования многих форм асинхронного и зависящего от времени кода. Он позволяет тесту выполняться внутри изолированной «пузыря», предоставляет этому пузырю поддельные часы и предлагает способ дождаться, пока горутины внутри пузыря не перейдут в состояние блокировки.
Результат прост, но мощен:
- Отсутствие произвольных снов (sleep)
- Более быстрые тесты с таймаутами
- Более детерминированные конкурентные тесты
- Лучшее тестирование отмены контекста
- Лучшее тестирование фоновых горутин
- Меньше нестабильных падений в 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: Структура и лучшие практики охватывает пакет тестирования, табличные тесты и паттерны мокирования, которые составляют основу, на которой строится эта статья. Конкурентные тесты обычно нестабильны по одной из трех причин.
Во-первых, они зависят от планировщика. Горутина может выполняться немедленно на вашей машине, но позже в CI.
Во-вторых, они зависят от реального времени. Тест, который спит 50 миллисекунд, предполагает, что 50 миллисекунд достаточно для завершения фоновой работы.
В-третьих, они наблюдают за состоянием слишком рано. Тест проверяет результат до того, как фоновая операция фактически завершится.
Вот простой пример:
func TestBackgroundWorkBad(t *testing.T) {
done := false
go func() {
done = true
}()
time.Sleep(10 * time.Millisecond)
if !done {
t.Fatal("фоновая работа не завершена")
}
}
У этого теста есть две проблемы.
Очевидная — это сон. Нет гарантии, что 10 миллисекунд — это правильное количество времени.
Менее очевидная — это гонка данных. Тест записывает done в одной горутине и считывает ее в другой без синхронизации.
Вы можете исправить этот конкретный пример с помощью канала или sync.WaitGroup, и часто так и следует делать. Но когда тестируемый код использует таймеры, дедлайны контекста, time.AfterFunc, фоновых рабочих процессов или отложенную очистку, тест все равно может стать неуклюжим — и именно здесь testing/synctest помогает.
Основная идея: запускайте тест внутри пузыря
Пузырь synctest изолирует горутины, созданные внутри него.
Используйте его так:
func TestSomethingConcurrent(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// Тестируйте конкурентный код здесь.
})
}
Внутри пузыря:
- Горутины, запущенные тестом, принадлежат пузырю.
- Таймеры и сон используют поддельные часы.
synctest.Waitможет ожидать, пока фоновая активность не успокоится.- Тест должен избегать зависимости от внешних горутин, реального сетевого ввода-вывода или внешних процессов.
Пузырь не волшебный. Он не делает плохой конкурентный дизайн хорошим. Но он предоставляет вашему тесту контролируемую среду, где время и поведение блокировки более детерминированы.
Проблема с 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("рабочий процесс не завершил работу")
}
}
Этот тест ждет шесть реальных секунд.
Это медленно. Если у вас много таких тестов, набор тестов становится мучительным.
Более лучший тест с 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. Правильная передача 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 — это не просто более приятный сон — это точка синхронизации внутри пузыря, и это различие важнее, чем кажется на первый взгляд.
Сон говорит:
Я надеюсь, что прошло достаточно времени.
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)
}
})
}
Кажется, что он ждет час.
Но это не так.
Это полезно для тестирования:
- таймаутов
- дедлайнов
- повторных попыток
- экспоненциальной задержки (backoff)
- отложенной очистки
- ограничений скорости
- таймеров
- тикеров
- отмены контекста
Но есть одно важное правило: поддельное время помогает только коду, который использует пакет time внутри пузыря.
Если ваш код зависит от внешней системы, реального сетевого ввода-вывода или времени, измеряемого вне пузыря, 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 ожидает выхода горутин из пузыря перед возвратом, что означает, что утечка горутин сложнее игнорировать. Если фоновая горутина никогда не выйдет, тест упадет, вместо того чтобы молча оставлять работу позади — и это хорошо.
Конкурентный код должен иметь четкое владение. Если функция запускает горутину, должен быть явный способ ее остановки или документированная причина, почему ей разрешено жить вечно. В тестах «всегда» почти никогда не приемлемо.
Хорошим паттерном является:
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
Затем заставьте горутину остановиться, когда контекст отменен.
Что означает «стабильно заблокирован» на практике
В официальной документации используется термин «стабильно заблокирован».
Вам не нужно запоминать каждую деталь рантайма, но вы должны понимать практическое значение.
Горутина стабильно заблокирована, когда она заблокирована таким образом, что ее может разблокировать только что-то внутри того же пузыря synctest.
Примеры включают:
- получение из канала, созданного внутри пузыря
- отправка в канал, созданный внутри пузыря
- ожидание на
sync.WaitGroup, связанном с пузырем - сон с
time.Sleep - ожидание определенных операций таймера
Некоторые вещи не являются стабильно заблокированными, потому что что-то вне пузыря может их разблокировать.
Примеры включают:
- сетевой ввод-вывод
- системные вызовы
- операции внешних процессов
- некоторые ожидания мьютексов
- взаимодействие с горутинами вне пузыря
Вот почему тесты synctest должны быть самодостаточными и свободны от внешней синхронизации, которую пузырь не может видеть. Не используйте synctest как обертку вокруг интеграционных тестов, которые общаются с реальной сетью.
Для чего хорош synctest
testing/synctest особенно хорош для модульных тестов вокруг асинхронного поведения.
Хорошие кандидаты включают:
- отмену контекста
- таймауты контекста
- циклы повторных попыток
- логику экспоненциальной задержки
- отложенную очистку
- рабочих процессов, управляемых таймерами
- циклы, управляемые тикерами
- фоновые горутины
- поведение таймаутов
- координацию каналов
time.AfterFunc- детерминированное ожидание горутин
Лучший случай использования — код, где сложная часть — это время или планирование, а не внешний ввод-вывод.
Для чего synctest не подходит
testing/synctest — не замена всем конкурентным тестам.
Это не полноценный детерминированный планировщик для каждой возможной гонки.
Это не замена детектору гонок.
Это не замена интеграционным тестам.
Он не делает реальный сетевой ввод-вывод детерминированным.
Он не исправляет плохой дизайн жизненного цикла горутин.
Это не значит, что вы можете игнорировать каналы, контексты, владение и отключение.
Используйте synctest для правильного слоя: детерминированные модульные тесты для конкурентного и зависящего от времени поведения.
Используйте другие инструменты для других слоев:
- используйте
go test -raceдля обнаружения гонок данных - используйте интеграционные тесты для реальных зависимостей
- используйте нагрузочные тесты для пропускной способности и конкуренции
- используйте бенчмарки для производительности
- используйте трассировку и профилирование для поведения в продакшене
synctest против детектора гонок
testing/synctest и детектор гонок решают разные проблемы.
Детектор гонок находит небезопасный конкурентный доступ к памяти.
synctest помогает вам контролировать асинхронное время и ожидание в тестах.
Вы часто должны использовать оба.
Например, это все еще гонка, даже внутри пузыря synctest, если нет правильной синхронизации:
value := 0
go func() {
value = 1
}()
_ = value
synctest.Wait может предоставить точку синхронизации для некоторых паттернов тестов, но это не означает, что каждый конкурентный доступ в вашем коде автоматически безопасен.
Запускайте конкурентные тесты с:
go test -race ./...
Детектор гонок по-прежнему один из лучших инструментов, которые дает вам Go. Сочетание его с Линтеры Go: Основные инструменты для качества кода дает вам надежную базовую линию статического анализа и проверки во время выполнения для любой конкурентной кодовой базы.
synctest против ручных поддельных часов
До 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 против каналов и WaitGroups
Не заменяйте хорошую синхронизацию synctest.
Если ваш код может экспортировать канал завершения, callback или метод Wait, это часто хороший дизайн.
Например:
type Server struct {
done chan struct{}
}
func (s *Server) Done() <-chan struct{} {
return s.done
}
Тест может ждать на этом напрямую.
synctest наиболее полезен, когда тестируемое поведение включает время, дедлайны контекста, фоновое планирование или асинхронные callbacks.
Лучшие тесты часто сочетают оба:
- производственный код имеет явные сигналы отключения или завершения
- synctest убирает ожидание реального времени
- Wait делает фоновую активность детерминированной
Общие ошибки
Ошибка 1: Обертывание каждого теста в synctest
Не используйте synctest везде. Если код синхронный, простая тестовая функция яснее, а добавление обертки пузыря только вводит ненужную механику, которая делает тесты сложнее для чтения и понимания.
Ошибка 2: Тестирование реального сетевого ввода-вывода внутри пузыря
Держите тесты synctest самодостаточными. Если ваш тест использует реальный сетевой сокет, внешний сервис, базу данных или подпроцесс, он принадлежит интеграционному тесту, а не пузырю synctest. Используйте фейки для модульных тестов и оставляйте реальные зависимости для отдельных интеграционных тестов, где изоляция пузыря не применяется.
Ошибка 3: Утечка горутин
Если ваш тест запускает горутину, убедитесь, что у нее есть четкий путь выхода. Используйте отмену контекста, закрытые каналы или явные методы остановки — горутина, которая никогда не останавливается, является запахом как в продакшене, так и в тестах, который synctest выявит, а не скроет.
Ошибка 4: Зависимость от состояния уровня пакета
Каналы, таймеры и WaitGroups уровня пакета могут нарушить изоляцию пузыря тонкими способами. Предпочитайте создание всего тестового состояния внутри функции synctest.Test, чтобы каждый ресурс принадлежал пузырю, и его время жизни четко ограничивалось тестом.
Ошибка 5: Отношение к поддельному времени как к реальному
Поддельное время предназначено для детерминированных тестов, а не для измерения производительности. Тест, который мгновенно продвигает один час, ничего полезного не говорит о стоимости CPU, конкуренции за блокировки, использовании памяти или реальном поведении планирования в продакшене — используйте бенчмарки и нагрузочные тесты для этих вопросов.
Ошибка 6: Игнорирование детектора гонок
synctest — не замена go test -race, и эти два инструмента решают разные проблемы. Запускайте детектор гонок вместе с вашими тестами synctest, чтобы поймать небезопасный конкурентный доступ к памяти, который пузырь сам по себе не может обнаружить.
Практический чек-лист
Используйте этот чек-лист при написании тестов с testing/synctest.
Используйте synctest, когда
- код запускает горутины
- код использует
time.Sleep - код использует таймеры или тикеры
- код использует дедлайны контекста
- код имеет поведение повторных попыток или экспоненциальной задержки
- тест в настоящее время использует произвольные сны
- тест нестабилен в CI
- тест медленный, потому что он ждет реального времени
Избегайте synctest, когда
- код синхронный
- тест зависит от реального сетевого ввода-вывода
- тест зависит от внешних процессов
- тест на самом деле является интеграционным
- вы пытаетесь измерить производительность
- код не имеет чистого пути отключения
Предпочитайте этот паттерн
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" .
Затем спросите:
- Медленный ли этот тест, потому что он ждет реального времени?
- Нестабилен ли этот тест, потому что он предполагает, что горутина уже выполнилась?
- Можно ли изолировать этот тест от сети и внешних процессов?
- Можно ли cleanly остановить фоновую горутину?
- Сделаит ли поддельное время утверждение яснее?
Хорошие кандидаты часто живут в:
- пакетах рабочих процессов
- пакетах повторных попыток
- пакетах кэша
- пакетах планировщиков
- потребителях очередей
- обертках 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, callbacks, synctest.Wait или поддельным временем — чем угодно, что ждет условия, а не надеется, что прошло достаточно времени.
Правило 2: Держите тесты synctest самодостаточными
Создавайте горутины, каналы, контексты, таймеры и рабочих процессов внутри пузыря. Избегайте общего состояния уровня пакета, которое может утечь между тестами и нарушить изоляцию, которая делает synctest полезным.
Правило 3: Не используйте synctest как обертку интеграционного теста
Если тест общается с реальной базой данных, реальной сетью или внешним процессом, держите его вне synctest, если у вас нет очень конкретной причины для этого.
Правило 4: Тестируйте поведение, а не удачу планировщика
Цель не в том, чтобы заставить горутину выполниться. Цель — проверить наблюдаемое поведение после того, как система достигла значимого состояния, что synctest.Wait делает возможным без зависимости от предположений о времени.
Правило 5: Держите пути отмены явными
Каждая фоновая горутина должна иметь путь отключения, и тесты должны доказывать, что этот путь работает, отменяя контекст или закрывая канал, а затем проверяя, что горутина выходит cleanly.
Финальные мысли
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](“Тестирование времени (и других асинхронностей) - Язык программирования Go”)
- https://go.dev/doc/go1.25
- https://go.dev/blog/go1.25