Post
Disponível em: English

testing/synctest: O Jeito Certo de Testar Código Concorrente em Go

E aí, pessoal!

Testar código concorrente em Go sempre foi chato. Você escreve uma goroutine, e de repente o teste precisa de um time.Sleep pra esperar ela terminar. O sleep é curto demais na máquina lenta do CI e o teste flake. Você aumenta o sleep. Agora a suite demora pra caramba.

O pacote testing/synctest, estável desde o Go 1.25, resolve exatamente isso. Ele te dá um ambiente controlado onde goroutines e timers se comportam de forma determinista, sem nenhum tempo real passar.


O problema das abordagens comuns

Imagine um cache que expira entradas depois de um timeout:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
type Cache struct {
    mu    sync.Mutex
    items map[string]item
}

type item struct {
    value     string
    expiresAt time.Time
}

func (c *Cache) Set(key, value string, ttl time.Duration) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = item{
        value:     value,
        expiresAt: time.Now().Add(ttl),
    }
}

func (c *Cache) Get(key string) (string, bool) {
    c.mu.Lock()
    defer c.mu.Unlock()
    it, ok := c.items[key]
    if !ok || time.Now().After(it.expiresAt) {
        return "", false
    }
    return it.value, true
}

Testando a expiração do jeito ingênuo:

1
2
3
4
5
6
7
8
9
10
11
func TestCacheExpiration(t *testing.T) {
    c := &Cache{items: make(map[string]item)}
    c.Set("key", "value", 100*time.Millisecond)

    time.Sleep(200 * time.Millisecond) // flakey em máquinas lentas

    _, ok := c.Get("key")
    if ok {
        t.Fatal("esperava key expirada")
    }
}

Esse teste adiciona 200ms na sua suite, e ainda flake quando a máquina está carregada. Não é um bom teste.


Como o synctest funciona

O testing/synctest cria um ambiente isolado chamado de bubble. Dentro da bubble:

  • Todas as goroutines compartilham um clock falso que começa num ponto fixo no tempo
  • time.Sleep, time.After, time.NewTimer e similares não usam tempo real
  • O clock falso só avanca quando todas as goroutines dentro da bubble estao bloqueadas

Isso significa que voce pode testar codigo que dorme por horas em microssegundos de tempo real.

O pacote tem duas funcoes:

1
2
func Test(t *testing.T, f func(t *testing.T)) // executa f numa nova bubble
func Wait()                                    // espera ate todas as goroutines da bubble estarem bloqueadas

Reescrevendo o teste com synctest

1
2
3
4
5
6
7
8
9
10
11
12
13
func TestCacheExpiration(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        c := &Cache{items: make(map[string]item)}
        c.Set("key", "value", 100*time.Millisecond)

        time.Sleep(200 * time.Millisecond) // sleep falso, nenhum tempo real passa

        _, ok := c.Get("key")
        if ok {
            t.Fatal("esperava key expirada")
        }
    })
}

O teste roda em microssegundos. O time.Sleep dentro da bubble avanca o clock falso, entao o time.Now() dentro do Get ve o tempo certo. Sem flakiness, sem espera.


Testando goroutines com Wait

Wait e util quando voce inicia goroutines dentro da bubble e precisa deixar elas terminarem antes de fazer as assertions.

Imagine um worker que processa jobs em background:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
type Worker struct {
    jobs    chan string
    results []string
    mu      sync.Mutex
}

func NewWorker() *Worker {
    w := &Worker{jobs: make(chan string, 10)}
    go w.run()
    return w
}

func (w *Worker) run() {
    for job := range w.jobs {
        time.Sleep(50 * time.Millisecond) // simula processamento
        w.mu.Lock()
        w.results = append(w.results, job)
        w.mu.Unlock()
    }
}

func (w *Worker) Submit(job string) {
    w.jobs <- job
}

func (w *Worker) Results() []string {
    w.mu.Lock()
    defer w.mu.Unlock()
    return append([]string{}, w.results...)
}

Testando:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func TestWorkerProcessaJobs(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        w := NewWorker()

        w.Submit("job-1")
        w.Submit("job-2")
        w.Submit("job-3")

        synctest.Wait() // espera ate todas as goroutines da bubble estarem bloqueadas

        results := w.Results()
        if len(results) != 3 {
            t.Fatalf("esperava 3 resultados, got %d", len(results))
        }
    })
}

O synctest.Wait() bloqueia ate que cada goroutine da bubble esteja bloqueada em receive de channel, timer ou similar. Nesse ponto, os tres jobs ja foram processados e a assertion e segura.

Sem synctest, esse teste precisaria de um time.Sleep ou de um mecanismo de sincronizacao mais complexo so pra deixar a assertion estavel.


Exemplo real: retry com backoff

Logica de retry e um caso classico onde os testes sao dolorosos por causa dos sleeps entre tentativas:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func Retry(ctx context.Context, fn func() error, maxAttempts int, backoff time.Duration) error {
    var err error
    for i := range maxAttempts {
        err = fn()
        if err == nil {
            return nil
        }
        if i < maxAttempts-1 {
            select {
            case <-ctx.Done():
                return ctx.Err()
            case <-time.After(backoff):
            }
        }
    }
    return err
}

Sem synctest, testar 5 tentativas com 1 segundo de backoff significa 4 segundos de sleep no teste. Com synctest:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
func TestRetrySuccedeNaTerceiraAttempt(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        tentativas := 0
        fn := func() error {
            tentativas++
            if tentativas < 3 {
                return errors.New("não pronto")
            }
            return nil
        }

        err := Retry(context.Background(), fn, 5, 1*time.Second)
        if err != nil {
            t.Fatalf("erro inesperado: %v", err)
        }
        if tentativas != 3 {
            t.Fatalf("esperava 3 tentativas, got %d", tentativas)
        }
    })
}

func TestRetryRespeitaCancelamentoDeContexto(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        ctx, cancel := context.WithTimeout(context.Background(), 2500*time.Millisecond)
        defer cancel()

        tentativas := 0
        fn := func() error {
            tentativas++
            return errors.New("sempre falha")
        }

        err := Retry(ctx, fn, 10, 1*time.Second)
        if !errors.Is(err, context.DeadlineExceeded) {
            t.Fatalf("esperava DeadlineExceeded, got %v", err)
        }
        // com 2.5s de timeout e 1s de backoff, esperamos 3 tentativas
        if tentativas != 3 {
            t.Fatalf("esperava 3 tentativas, got %d", tentativas)
        }
    })
}

Os dois testes rodam na hora. O timeout de 2.5 segundos e os backoffs de 1 segundo sao todos falsos, gerenciados pelo clock da bubble.


Testando debounce

Debounce e outro padrao onde o synctest brilha. Voce pode verificar exatamente quantas vezes a funcao dispara sem nenhum timing de relogio real:

1
2
3
4
5
6
7
8
9
func Debounce(fn func(), delay time.Duration) func() {
    var timer *time.Timer
    return func() {
        if timer != nil {
            timer.Stop()
        }
        timer = time.AfterFunc(delay, fn)
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func TestDebounce(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        count := 0
        debounced := Debounce(func() { count++ }, 100*time.Millisecond)

        debounced()
        debounced()
        debounced()

        time.Sleep(200 * time.Millisecond) // avanca o clock falso

        synctest.Wait()

        if count != 1 {
            t.Fatalf("esperava 1 chamada, got %d", count)
        }
    })
}

O que o synctest não resolve

Algumas coisas pra ter em mente:

Goroutines externas ficam fora da bubble. Se o seu codigo inicia goroutines antes da chamada de synctest.Test, ou por mecanismos que escapam da bubble, elas não sao controladas pelo clock falso.

So funcoes de tempo da stdlib sao afetadas. Se voce usa uma abstracao de clock de terceiros, o synctest não a controla. Voce precisaria injetar o clock manualmente como antes.

A bubble não substitui o race detector. Rode os testes com -race normalmente. O synctest torna o timing deterministico, mas não previne data races.


Como usar

testing/synctest faz parte da stdlib desde o Go 1.25. Sem mudancas de import path, sem build tags. So importar:

1
import "testing/synctest"

Se voce esta no Go 1.24, estava disponivel como experimento via GOEXPERIMENT=synctest.


Conclusao

O testing/synctest remove a maior parte da dor de testar codigo concorrente em Go. O padrao e simples: envolva o teste em synctest.Test, use synctest.Wait onde precisar deixar as goroutines estabilizarem, e deixa o clock falso cuidar de todo o timing.

Os exemplos de retry e debounce ja cobrem uma boa parte dos padroes concorrentes que as pessoas tem dificuldade de testar de forma limpa. Se voce tem codigo com time.After, time.Sleep, ou goroutines que processam coisas de forma assincrona, vale adotar esse pacote.

Eu tambem falei sobre esse tema no meu canal do YouTube, caso queira ver na pratica:


Referencias