Testes Determinísticos com testing/synctest
Objective
Código que espera (retry com backoff, timeouts, caches com TTL, tickers) é o que os times menos
testam: um teste real dorme por minutos ou faz polling com timeout curto e vira flaky. O
testing/synctest, disponível para todos desde o Go 1.25, roda o teste dentro de uma "bolha" cujo
relógio falso só anda quando toda goroutine da bolha está bloqueada de forma durável, então esperar
não custa tempo de parede e a ordem dos eventos é determinística.
Esta lição testa um retry, um deadline e o faxineiro de um cache que somam 23 minutos falsos e mede
o tempo de parede, mostra o que o synctest.Wait garante (inclusive ao race detector), mede o flake
que o synctest.Sleep (Go 1.27) corrige, roda HTTP numa bolha com httptest.NewTestServer (Go
1.27) e quebra cada regra para registrar o erro real. Medido com go1.27.0 darwin/arm64 num Apple
M4 Max (14 núcleos, macOS); o número de execuções aparece ao lado de cada número.
Use Cases
- Testar backoff exponencial, deadlines e períodos de espera sem sleeps reais.
- Garantir que uma entrada de cache existe aos 9 minutos e some aos 10, de forma determinística.
- Testar timeouts de cliente HTTP contra handlers lentos, sem portas e sem flakes.
- Trocar polls do tipo "dorme 10 ms e torce" e pegar goroutines que o teste esqueceu de parar.
Deep Dive
A bolha e o relógio falso
O sistema testado usa time e context puros, sem interface de relógio:
gofunc Retry(ctx context.Context, attempts int, base time.Duration, op func() error) error {
var err error
for i := range attempts {
if err = op(); err == nil {
return nil
}
if i == attempts-1 {
break
}
t := time.NewTimer(base << i) // base, 2*base, 4*base...
select {
case <-t.C:
case <-ctx.Done():
t.Stop()
return context.Cause(ctx)
}
}
return err
}
func TestRetryBackoff(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start, calls := time.Now(), 0
err := Retry(t.Context(), 10, time.Second, func() error {
if calls++; calls < 8 {
return errors.New("unavailable")
}
return nil
})
t.Logf("start=%s calls=%d err=%v fake elapsed=%v",
start.Format(time.RFC3339), calls, err, time.Since(start))
})
}Um segundo teste embrulha o t.Context() em context.WithTimeout(..., 5*time.Minute) e repete
uma operação que sempre falha, com base de 1 minuto:
textsut_test.go:20: start=2000-01-01T00:00:00Z calls=8 err=<nil> fake elapsed=2m7s --- PASS: TestRetryBackoff (0.00s) sut_test.go:31: err=context deadline exceeded fake elapsed=5m0s --- PASS: TestRetryDeadline (0.00s)
Toda bolha começa à meia-noite UTC de 2000-01-01. Sete esperas de 1, 2, 4 ... 64 s somam
exatamente 127 s, o deadline dispara em exatamente 5m0s, e timers, time.After e t.Context()
seguem o relógio da bolha.
Para o tempo de parede, um teste roda um retry de 10 tentativas (511 s de backoff), o deadline de 5
minutos e 10 minutos de expiração de cache numa bolha, lendo time.Now() fora dela (relógio real).
Ele registrou fake=23m31s wall=140.875µs; em 10 execuções o tempo de parede ficou entre 97 e 152 µs.
O que significa "durably blocked"
O tempo só avança quando toda goroutine da bolha está bloqueada de um jeito que só outra goroutine
da mesma bolha pode encerrar. A documentação lista as operações duráveis: envio ou recebimento num
channel da bolha, um select só com channels da bolha, sync.Cond.Wait, sync.WaitGroup.Wait
(quando o Add rodou na bolha) e time.Sleep. Travar um sync.Mutex ou RWMutex, I/O e syscalls
não são duráveis, porque algo de fora poderia encerrá-los. Por isso uma espera de mutex congela o
relógio:
gosynctest.Test(t, func(t *testing.T) {
var mu sync.Mutex
mu.Lock()
go func() { time.Sleep(time.Second); mu.Unlock() }() // quem dorme está bloqueado de forma durável
mu.Lock() // bloqueado, mas não de forma durável
})textpanic: test timed out after 3s goroutine 7 [sync.Mutex.Lock, synctest bubble 1]: goroutine 8 [sleep (durable), synctest bubble 1]:
O trace (filtrado para essas duas goroutines) marca as esperas duráveis. Quem dorme acordaria em 1s falso, mas o relógio espera a goroutine do mutex, que espera quem dorme, até estourar o timeout.
synctest.Wait: deixar tudo assentar
O synctest.Wait() retorna quando toda outra goroutine da bolha está bloqueada de forma durável ou
terminou. Ele também é um ponto de sincronização para o race detector, coisa que time.Sleep não é:
gofunc TestFlagWithWait(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
done := false
go func() { done = true }()
synctest.Wait()
t.Log("done =", done)
})
}
// TestFlagWithoutWait: igual, com time.Sleep(time.Nanosecond) no lugar de synctest.Wait()text$ go test -race -run 'TestFlagWith' -v . wait_test.go:14: done = true --- PASS: TestFlagWithWait (0.00s) WARNING: DATA RACE wait_test.go:23: done = true testing.go:1865: race detected during execution of test --- FAIL: TestFlagWithoutWait (0.00s)
Os dois imprimem true (o sleep de 1 ns não termina antes de a outra goroutine acabar) e, sem
-race, os dois passam em 3 de 3 execuções. Só o Wait cria uma aresta happens-before, então só
essa versão está livre de race.
Despertares no mesmo instante e synctest.Sleep (Go 1.27)
O cache testado remove chaves expiradas a partir de uma goroutine faxineira com time.Ticker:
gofunc TestCacheExpiryPlainSleep(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
c := NewCache(10*time.Minute, time.Minute) // ttl, intervalo da varredura
defer c.Close()
c.Put("k")
time.Sleep(10 * time.Minute) // o 10º tick do faxineiro dispara no mesmo instante
if c.Len() != 0 {
t.Fatalf("after 10m: Len=%d, want 0", c.Len())
}
})
}text$ go test -count=1000 -run 'TestCacheExpiryPlainSleep$' . | grep -c -- '--- FAIL' 515 $ go test -count=1000 -run 'TestCacheExpiryPlainSleep$' . | grep -m1 -A1 -- '--- FAIL' --- FAIL: TestCacheExpiryPlainSleep (0.00s) sut_test.go:60: after 10m: Len=1, want 0
Em 6 execuções de 1000 iterações, de 493 a 515 falharam. Teste e faxineiro acordam no mesmo
instante falso e qualquer um pode rodar primeiro (o post "testing time" diz que o Go 1.25
embaralhou timers do mesmo instante), então uma suposição escondida de ordem vira cara ou coroa.
O Go 1.27 adiciona func Sleep(d time.Duration), "exactly equivalent to" time.Sleep(d) seguido
de synctest.Wait(). O teste corrigido faz synctest.Sleep(9 * time.Minute), confere
Len() == 1, synctest.Sleep(time.Minute), confere Len() == 0; ele e o par escrito por extenso
passaram em 1000 de 1000 iterações, 3 execuções cada.
HTTP numa bolha: httptest.NewTestServer (Go 1.27)
Um servidor em loopback deixa goroutines paradas em I/O de rede, que não é durável. O Go 1.27
adiciona func NewTestServer(t testing.TB, handler http.Handler) *Server: rede em memória,
cleanup próprio e falha do teste se o handler entrar em panic.
govar slow = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(10 * time.Second):
io.WriteString(w, "late")
case <-r.Context().Done():
}
})
synctest.Test(t, func(t *testing.T) { // tempo de parede medido fora, como antes
srv := httptest.NewTestServer(t, slow)
c := srv.Client()
c.Timeout = 5 * time.Second
start := time.Now()
_, err := c.Get("http://api.internal/orders")
t.Logf("URL=%s err=%v fake elapsed=%v", srv.URL, err, time.Since(start))
})texthttp_test.go:28: URL=http://example.com err=Get "http://api.internal/orders": context deadline exceeded fake elapsed=5s http_test.go:30: wall=1.888709ms
O cliente chega ao servidor qualquer que seja o host da URL (srv.URL é http://example.com), o
timeout dispara em exatamente 5 s falsos e o tempo de parede foi de 1,2 a 2,1 ms em 5 execuções. O
mesmo teste com httptest.NewServer(slow) nunca terminou: o trace mostrou goroutines em [IO wait, synctest bubble 1] sem (durable), o relógio nunca chegou a 5 s e o -timeout 5s deu panic.
Quebrando as regras: o que você recebe de fato
Cada linha vem de um teste de propósito único rodado com go1.27.0:
textreceive on a bubble channel nobody sends to panic: deadlock: all goroutines in bubble are blocked root returns, a goroutine still blocked panic: deadlock: main bubble goroutine has exited but blocked goroutines remain (same message for a goroutine left in a time.Tick loop) send on a bubble channel from outside fatal error: send on synctest channel from outside bubble synctest.Wait outside any bubble panic: goroutine is not in a bubble t.Run inside the bubble panic: testing: t.Run called inside synctest bubble synctest.Test inside a bubble panic: synctest.Run called from within a synctest bubble
Duas descobertas. Os panics de goroutine esquecida transformam vazamento de goroutine em falha de
teste, a mesma classe de bug que o goleak caça. E usar um channel da bolha de fora é um fatal error, não o panic recuperável que a documentação do pacote descreve: um recover() em defer
nunca rodou e o binário de teste inteiro morreu, levando junto todos os outros testes do pacote.
Trade-offs
- Sem injeção de relógio, mas só dentro da bolha:
time.Now, timers e deadlines decontextpuros são falsificados, o que é melhor que passar uma interface de relógio por todo construtor. O que foi iniciado fora da bolha (um cliente compartilhado, um worker global) roda no tempo real. - Mutex e I/O travam o relógio: um
sync.Mutexsegurado durante um sleep, sockets reais, arquivos ou subprocessos nunca deixam o tempo andar. Usenet.Pipe, um fake em memória ou oNewTestServer. Segundo a documentação (não medido), umvar wg sync.WaitGroupno nível do pacote também não entra na bolha. - Tudo precisa terminar: a bolha dá panic se goroutines sobrevivem à função de teste, então
loops de background precisam de um caminho de parada (
Close, umt.Context()cancelado), quase sempre uma correção de design de que o serviço já precisava. - Antes versus hoje: o Go 1.24 trazia o pacote atrás de
GOEXPERIMENT=synctestcomosynctest.Run(func()); o Go 1.25 trocou porsynctest.Test(t, f), parou o tempo quando a goroutine raiz retorna e embaralhou timers do mesmo instante; o Go 1.27 adicionousynctest.Sleepehttptest.NewTestServer.