Другое

Опросы с Synctest Bubble: Полное руководство

Узнайте, как реализовывать опросы с правильным ожиданием внутри синctest-пузырей в Go. Исправляйте проблемы с тикером и используйте synctest.Wait() для детерминированного времени в параллельных тестах.

Как реализовать опрос с правильной задержкой внутри синctest-пузыря в Go?

Golang 1.24 представляет synctest, который создает изолированные среды с синтетическими часами с помощью synctest.Run(f). Это особенно полезно для тестирования производственного кода, который зависит от временных меток UTC. Однако при тестировании кода, включающего фоновые горутины, запускаемые асинхронно, мне нужно правильно ожидать их результатов внутри синctest-пузыря перед продолжением выполнения теста.

Проблема в том, что моя текущая функция опроса, использующая time.NewTicker, не интегрируется должным образом с синтетическими часами synctest. Она не ожидает между циклами опроса, как ожидалось.

Вот упрощенный пример структуры моего теста:

go
func TestTimingWithSynctest(t *testing.T) {
    synctest.Run(func() {
        // запуск другого тестового кода..
        
        // теперь мне нужно фактически ожидать между циклами опроса внутри пузыря
        polling.Run(...)

        // снова запуск другого тестового кода..
    })
}

А вот моя текущая реализация функции опроса:

go
// Package polling предоставляет утилиты для опроса.
package polling

import (
    "context"
    "time"
)

// Run многократно вызывает action() до тех пор, пока она не вернет true или ошибку,
// или пока не будет отменен ctx или не истечет timeout.
func Run(ctx context.Context, interval, timeout time.Duration,
    action func() (bool, error)) error {
    if timeout > 0 {
        var cancel context.CancelFunc
        // создаем дочерний контекст, объединяющий переданный ctx с timeout
        // затем либо отмена извне, либо timeout отменяют опрос
        ctx, cancel = context.WithTimeout(ctx, timeout)
        defer cancel()
    }

    ticker := time.NewTicker(interval)
    defer ticker.Stop()

    for {
        done, err := action()
        if err != nil {
            return err
        }
        if done {
            return nil
        }

        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-ticker.C:
            // продолжаем цикл
        }
    }
}

Как можно изменить эту функцию опроса, чтобы она работала должным образом с синтетическими часами synctest, обеспечивая реальную задержку между циклами опроса?

Ключ к реализации опроса с правильной задержкой внутри синctest-пузыря заключается в использовании synctest.Wait() для корректного advancement синтетических часов. Вместо того чтобы полагаться на time.Ticker, который не интегрируется с синтетическими часами, вы должны вручную контролировать время с помощью synctest.Wait(), ожидая, пока все горутины в пузыре станут неактивными перед advancement времени.


Содержание


Понимание синctest-пузырей и тайминга

Когда вы вызываете synctest.Run(func()), создается так называемый “пузырь” - изолированная среда, в которой горутины работают в контролируемой и независимой среде. Внутри этого пузыря:

  • Настенные часы стоят на месте, пока выполняется код
  • Время продвигается только тогда, когда все горутины в пузыре надежно заблокированы
  • Синтетические часы обеспечивают детерминированный тайминг для тестирования
  • Операции с каналами, таймерами или тикерами из вне пузыря вызывают панику

Ключевое понимание заключается в том, что время автоматически продвигается, когда все горутины в пузыре заблокированы, что именно и обеспечивает synctest.Wait().

Проблема традиционного опроса в Synctest

Ваша текущая реализация опроса с использованием time.NewTicker() не работает должным образом в синctest-пузырях, потому что:

  1. Горутина тикера работает независимо от основной тестовой горутины
  2. Время не продвигается, так как обе горутины активны
  3. Канал тикера никогда не получает тиков от синтетических часов
  4. Ваш цикл опроса застревает в ожидании тиков, которые никогда не приходят

Как отмечено в трекере проблем Go, “Когда пузырь содержит долгоживущую фон горутину опроса, такую как ту, которая читает из time.Ticker, пузырь будет неограниченно продвигать время”. Это создает непредсказуемое поведение в тестах.

Решение: правильное использование Synctest.Wait()

Решением является замена подхода на основе тикера на ручное управление временем с помощью synctest.Wait(). Вот подход:

  1. Удалите тикер и используйте ручное advancement времени
  2. Вызывайте synctest.Wait() после каждой итерации опроса, чтобы убедиться, что все горутины заблокированы
  3. Используйте time.Sleep() с синтетическим временем для создания интервала опроса
  4. Синтетические часы автоматически продвинутся, когда все горутины будут заблокированы

В официальной документации Go подчеркивается, что “пакет synctest содержит только две функции: Run и Wait”. Функция Wait() является ключевой - она “ожидает, пока каждая горутина в пузыре вызывающего заблокируется”.

Реализация измененной функции опроса

Вот как изменить вашу функцию опроса для правильной работы с synctest:

go
// Package polling предоставляет утилиты для опроса.
package polling

import (
    "context"
    "time"
    "testing/synctest"
)

// Run многократно вызывает action() до тех пор, пока она не вернет true или ошибку,
// или пока не будет отменен ctx или достигнут timeout.
// Эта версия оптимизирована для использования с синctest-пузырями.
func Run(ctx context.Context, interval, timeout time.Duration,
    action func() (bool, error)) error {
    
    if timeout > 0 {
        var cancel context.CancelFunc
        ctx, cancel = context.WithTimeout(ctx, timeout)
        defer cancel()
    }

    startTime := time.Now()
    
    for {
        // Проверяем, не превысили ли мы timeout
        if timeout > 0 && time.Since(startTime) >= timeout {
            return context.DeadlineExceeded
        }

        // Вызываем функцию действия
        done, err := action()
        if err != nil {
            return err
        }
        if done {
            return nil
        }

        // Ожидаем, пока все горутины в пузыре станут неактивными
        // Это позволяет синтетическим часам продвинуться
        synctest.Wait()
        
        // Спим в течение интервала опроса с использованием синтетического времени
        // Часы продвинутся во время этого сна, когда все горутины заблокированы
        time.Sleep(interval)
    }
}

Полный пример

Вот полный пример использования измененной функции опроса в синctest-пузыре:

go
func TestTimingWithSynctest(t *testing.T) {
    synctest.Run(func() {
        // Устанавливаем флаг, который будет установлен фон горутиной
        var flag bool
        var flagMutex sync.Mutex
        
        // Запускаем фон горутину, которая установит флаг после некоторого времени
        go func() {
            time.Sleep(100 * time.Millisecond) // Синтетическое время
            flagMutex.Lock()
            flag = true
            flagMutex.Unlock()
        }()
        
        // Используем функцию опроса для ожидания установки флага
        err := polling.Run(context.Background(), 50*time.Millisecond, 500*time.Millisecond, func() (bool, error) {
            flagMutex.Lock()
            defer flagMutex.Unlock()
            return flag, nil
        })
        
        if err != nil {
            t.Fatalf("Опрос не удался: %v", err)
        }
        
        // Проверяем, что флаг теперь установлен
        flagMutex.Lock()
        defer flagMutex.Unlock()
        if !flag {
            t.Fatal("Ожидаемый флаг должен быть установлен")
        }
    })
}

Ключевые моменты в этом примере:

  1. Фон горутина устанавливает флаг после 100мс синтетического времени
  2. Функция опроса проверяет каждые 50мс с использованием измененной реализации
  3. synctest.Wait() вызывается автоматически между попытками опроса
  4. Время автоматически продвигается, когда фон горутина спит, а основная горутина ожидает

Лучшие практики для опроса в Synctest

1. Всегда используйте synctest.Wait()

Всегда вызывайте synctest.Wait() после каждой итерации опроса для правильного advancement времени:

go
for {
    // ... вызов действия ...
    synctest.Wait()
    time.Sleep(interval)
}

2. Держите фон горутины простыми

Фон горутины должны быть простыми и предсказуемыми. Сложные горутины могут затруднить понимание поведения тайминга.

3. Последовательно используйте синтетическое время

Все операции со временем должны использовать синтетические часы. Избегайте смешивания операций реального времени внутри пузыря.

4. Правильно обрабатывайте отмену контекста

Убедитесь, что ваша функция опроса правильно обрабатывает отмену контекста, особенно при использовании таймаутов:

go
if timeout > 0 {
    if time.Since(startTime) >= timeout {
        return context.DeadlineExceeded
    }
}

5. Тестируйте граничные случаи

Тестируйте различные сценарии:

  • Быстрый успех (action возвращает true немедленно)
  • Сценарии таймаута
  • Условия ошибки
  • Конкурентные модификации

6. Используйте мьютексы для общего состояния

При совместном использовании состояния между горутинами в пузыре всегда используйте правильную синхронизацию:

go
var value int
var valueMutex sync.Mutex

// В фон горутине:
valueMutex.Lock()
value = 42
valueMutex.Unlock()

// В функции опроса:
valueMutex.Lock()
currentValue := value
valueMutex.Unlock()

Источники

  1. Testing concurrent code with testing/synctest - The Go Programming Language
  2. Go synctest: Solving Flaky Tests - VictoriaMetrics
  3. testing/synctest: experimental package for testing concurrent code - golang/go
  4. proposal: testing/synctest: create bubbles with Start rather than Run - golang/go
  5. The Synctest Package (new in Go 1.25) - Applied Go
  6. Coming in Go 1.24: testing/synctest experiment for time and concurrency testing - Dan Peterson
  7. Concurrent code testing with synctest bubbles in Go - Mago’s Blog

Заключение

Реализация опроса с правильной задержкой внутри синctest-пузыря требует понимания того, как работают синтетические часы, и стратегического использования synctest.Wait(). Основные выводы:

  1. Замените опрос на основе тикера на ручное управление временем с помощью synctest.Wait()
  2. Вызывайте synctest.Wait() после каждой итерации опроса, чтобы убедиться, что все горутины заблокированы и время продвигается
  3. Используйте time.Sleep() с синтетическим временем для предсказуемых интервалов
  4. Держите фон горутины простыми и используйте правильную синхронизацию для общего состояния
  5. Тестируйте различные сценарии, включая быстрый успех, таймауты и условия ошибки

Следуя этим практикам, вы можете создавать надежные, детерминированные тесты для кода, который использует опрос и тайминг в конкурентных сценариях. Пакет synctest позволяет тестировать такой код без нестабильности, которая обычно связана с тестами, зависящими от времени.

Авторы
Проверено модерацией
Модерация