Testando código Go concorrente com synctest
Pare de dormir em testes concorrentes em Go.
Testar código Go concorrente sempre exigiu um pouco de disciplina.
Goroutines são baratas, canais são simples e o cancelamento via context é idiomático — workers de fundo e timers estão em todo lugar em serviços Go reais.
Mas testar tudo isso de forma confiável é mais difícil do que escrevê-lo.

O padrão ruim usual é familiar:
go doSomething()
time.Sleep(100 * time.Millisecond)
if !done {
t.Fatal("o trabalho de fundo não terminou")
}
Esse teste pode passar no seu laptop e falhar no CI. Ou pode passar por seis meses e então falhar em um runner carregado. Ou pode ser lento porque alguém aumentou o sleep de 100 milissegundos para 2 segundos “para ser seguro”.
Isso não é um bom teste — é um jogo de azar com um timer, e essa aposta fica mais cara conforme a suíte de testes cresce.
O pacote testing/synctest oferece aos desenvolvedores Go uma melhor maneira de testar muitas formas de código assíncrono e dependente de tempo. Ele permite que um teste rode dentro de uma bolha isolada, dá a essa bolha um relógio falso e fornece uma maneira de esperar até que as goroutines dentro da bolha estejam bloqueadas.
O resultado é simples, mas poderoso:
- Sem sleeps arbitrários
- Testes de timeout mais rápidos
- Testes concorrentes mais determinísticos
- Melhor teste de cancelamento de context
- Melhor teste de goroutines de fundo
- Menos CI instável (flaky)
A versão um pouco opinativa: se o seu teste concorrente Go depende de um time.Sleep real, você provavelmente deveria tratar esse teste como suspeito.
O que é testing/synctest
testing/synctest é um pacote da biblioteca padrão da Go para testar código concorrente.
Ele fornece duas funções principais:
package synctest
func Test(t *testing.T, f func(*testing.T))
func Wait()
synctest.Test executa uma função dentro de uma bolha de teste isolada. Qualquer goroutine iniciada dentro dessa bolha também faz parte da bolha, o tempo dentro da bolha é falso, e o pacote time funciona contra esse relógio falso em vez do relógio real da parede.
synctest.Wait espera até que todas as outras goroutines na bolha estejam duravelmente bloqueadas. Isso soa abstrato, mas o efeito prático é fácil de entender:
synctest.Test(t, func(t *testing.T) {
time.Sleep(10 * time.Second)
})
Isso não faz seu teste esperar 10 segundos reais. Dentro da bolha synctest, o tempo pode avançar instantaneamente quando a bolha está bloqueada e esperando o tempo avançar — esse é o truque central por trás do pacote.
Por que testes concorrentes em Go são instáveis (flaky)
Se você é novo em testes Go em geral, Go Unit Testing: Estrutura & Melhores Práticas cobre o pacote de testes, testes orientados a tabelas e padrões de mocking que formam a base sobre a qual este artigo se constrói. Testes concorrentes geralmente são instáveis por um de três motivos.
Primeiro, eles dependem do agendador. Uma goroutine pode rodar imediatamente na sua máquina e depois no CI.
Segundo, eles dependem do tempo real. Um teste que dorme por 50 milissegundos assume que 50 milissegundos são tempo suficiente para o trabalho de fundo terminar.
Terceiro, eles observam o estado muito cedo. O teste verifica o resultado antes que a operação de fundo tenha realmente concluído.
Aqui está um exemplo simples:
func TestBackgroundWorkBad(t *testing.T) {
done := false
go func() {
done = true
}()
time.Sleep(10 * time.Millisecond)
if !done {
t.Fatal("o trabalho de fundo não terminou")
}
}
Este teste tem dois problemas.
O óbvio é o sleep. Não há garantia de que 10 milissegundos seja a quantidade certa de tempo.
O menos óbvio é a corrida de dados (data race). O teste grava done em uma goroutine e o lê em outra sem sincronização.
Você pode corrigir este exemplo específico com um canal ou um sync.WaitGroup, e muitas vezes deve. Mas quando o código em teste usa timers, prazos de context, time.AfterFunc, workers de fundo ou limpeza atrasada, o teste ainda pode se tornar estranho — e é exatamente onde testing/synctest ajuda.
A ideia central: rodar o teste dentro de uma bolha
Uma bolha synctest isola as goroutines criadas dentro dela.
Use-a assim:
func TestSomethingConcurrent(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// Teste código concorrente aqui.
})
}
Dentro da bolha:
- Goroutines iniciadas pelo teste pertencem à bolha.
- Timers e sleeps usam um relógio falso.
synctest.Waitpode esperar até que a atividade de fundo se assente.- O teste deve evitar depender de goroutines externas, I/O de rede real ou processos externos.
A bolha não é mágica. Ela não faz um mau design de concorrência bom. Mas dá ao seu teste um ambiente controlado onde o tempo e o comportamento de bloqueio são mais determinísticos.
O problema com time.Sleep em testes
Um time.Sleep real em um teste geralmente significa uma de duas coisas:
Eu não sei como esperar pelo evento do qual eu realmente me importo.
ou:
Eu sei do que eu me importo, mas o código em teste não expõe uma maneira limpa de observá-lo.
Ambos são sinais de design que valem a pena levar a sério — eles apontam para lugares onde o código de produção pode se beneficiar de observabilidade mais limpa ou mecanismos de coordenação mais explícitos.
Considere uma função que conclui trabalho em segundo plano:
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
}
Um teste ruim pode ser assim:
func TestWorkerBad(t *testing.T) {
w := NewWorker()
w.Start()
time.Sleep(6 * time.Second)
select {
case got := <-w.Result():
if got != "done" {
t.Fatalf("recebido %q, esperado done", got)
}
default:
t.Fatal("o worker não terminou")
}
}
Este teste espera seis segundos reais.
Isso é lento. Se você tiver muitos testes assim, a suíte se torna dolorosa.
Um teste melhor com synctest pode avançar o tempo falso instantaneamente:
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("recebido %q, esperado done", got)
}
default:
t.Fatal("o worker não terminou")
}
})
}
O teste ainda expressa o fato de negócios — o worker deve terminar após 5 segundos — mas não gasta 5 segundos reais fazendo isso. Essa é a diferença entre testar comportamento dependente de tempo e desperdiçar tempo do desenvolvedor.
Testando timeouts de context
Um dos melhores usos para testing/synctest é testar prazos e timeouts de context.Context. Como o cancelamento de context e os orçamentos de timeout devem ser estruturados entre as camadas de serviço é abordado em Go context.Context Certo, e propagar corretamente context.Canceled e context.DeadlineExceeded através dessas camadas é coberto em Arquitetura de Tratamento de Erros Go: Fronteiras e Padrões — synctest permite que você verifique esse comportamento sem tempo real passando.
Aqui está uma função simples que espera até que um context seja cancelado:
func WaitForCancel(ctx context.Context, done chan<- error) {
go func() {
<-ctx.Done()
done <- ctx.Err()
}()
}
Sem synctest, testar isso com um timeout de 30 segundos tornaria o teste lento ou o obrigaria a mudar o timeout apenas para o teste.
Com synctest, você pode testar a duração real do timeout rapidamente:
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("context cancelado cedo demais: %v", err)
default:
}
time.Sleep(30 * time.Second)
synctest.Wait()
select {
case err := <-done:
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("recebido %v, esperado %v", err, context.DeadlineExceeded)
}
default:
t.Fatal("o context não foi cancelado")
}
})
}
Esse é o tipo de teste que synctest torna agradável.
Você pode manter valores realistas de timeout no código e ainda assim rodar testes rapidamente.
Testando cancelamento de context
Você também pode testar cancelamento explícito sem competir com a goroutine de fundo.
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("context cancelado cedo demais: %v", err)
default:
}
cancel()
synctest.Wait()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("recebido %v, esperado %v", err, context.Canceled)
}
default:
t.Fatal("o context não foi cancelado")
}
})
}
O detalhe importante é synctest.Wait.
Ele dá à goroutine de fundo uma chance de observar o cancelamento e se assentar antes que o teste verifique o resultado.
O que synctest.Wait faz
synctest.Wait espera até que todas as outras goroutines na bolha estejam duravelmente bloqueadas.
Em linguagem normal, significa:
Espere até que as goroutines dentro deste teste tenham alcançado um ponto de bloqueio estável.
Isso é útil quando o teste inicia uma goroutine e precisa saber que a goroutine terminou ou está esperando.
Por exemplo:
func TestWaitExample(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
done := false
go func() {
done = true
}()
synctest.Wait()
if !done {
t.Fatal("a goroutine não executou")
}
})
}
Isso é intencionalmente pequeno, mas demonstra a ideia.
synctest.Wait não é apenas um sleep mais bonito — é um ponto de sincronização dentro da bolha, e essa distinção importa mais do que parece à primeira vista.
Um sleep diz:
Eu espero que tempo suficiente tenha passado.
Wait diz:
Eu quero que a bolha alcance um estado de bloqueio estável.
O segundo é muito melhor para testes porque descreve uma condição observável em vez de uma suposição sobre o tempo decorrido.
Tempo falso em uma bolha synctest
Dentro de uma bolha synctest, o pacote time usa um relógio falso.
O relógio falso começa em um tempo fixo. Ele avança apenas quando todas as goroutines na bolha estão duravelmente bloqueadas e o tempo precisa avançar para desbloquear algo.
Isso significa que este teste é rápido:
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("recebido %v, esperado %v", elapsed, time.Hour)
}
})
}
Lê-se como se esperasse uma hora.
Não espera.
Isso é útil para testar:
- timeouts
- prazos
- novas tentativas (retries)
- backoff
- limpeza atrasada
- limites de taxa
- timers
- tickers
- cancelamento de context
Mas há uma regra importante: o tempo falso só ajuda código que usa o pacote time dentro da bolha.
Se o seu código depende de um sistema externo, I/O de rede real ou tempo medido fora da bolha, synctest não pode tornar isso determinístico.
Testando um loop de nova tentativa
Loops de nova tentativa são uma fonte comum de testes lentos e instáveis.
Aqui está um pequeno helper de nova tentativa:
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
}
Um teste normal pode reduzir o atraso para 1 milissegundo apenas para manter a suíte rápida.
Isso não é terrível, mas significa que o teste não está mais exercitando o valor real usado pelo código de produção.
Com synctest, você pode manter o atraso real:
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("falha temporária")
}
return nil
})
if err != nil {
t.Fatalf("Retry retornou erro: %v", err)
}
if calls != 3 {
t.Fatalf("chamadas = %d, esperado 3", calls)
}
})
}
O teste representa duas esperas de 10 segundos.
Ele ainda executa rapidamente.
É aqui que synctest muda a economia de testes. Você não precisa mais de durações falsas minúsculas espalhadas pelos testes apenas para evitar CI lento.
Testando cancelamento de nova tentativa
Você também pode testar cancelamento durante o atraso de nova tentativa:
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("falha temporária")
})
}()
synctest.Wait()
cancel()
synctest.Wait()
select {
case err := <-errCh:
if !errors.Is(err, context.Canceled) {
t.Fatalf("recebido %v, esperado %v", err, context.Canceled)
}
default:
t.Fatal("Retry não retornou após o cancelamento")
}
})
}
Este teste verifica se o loop de nova tentativa responde ao cancelamento em vez de dormir através do atraso.
Esse é exatamente o tipo de comportamento que importa em produção.
Testando time.AfterFunc
time.AfterFunc é outro bom ajuste.
Suponha que você tenha uma função que agenda limpeza:
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
}
O teste pode avançar o tempo falso:
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("a limpeza aconteceu cedo demais")
default:
}
time.Sleep(1 * time.Minute)
synctest.Wait()
select {
case <-cache.Cleaned():
default:
t.Fatal("a limpeza não aconteceu")
}
})
}
Este teste verifica ambos os lados:
- A limpeza não acontece antes do atraso.
- A limpeza acontece depois do atraso.
E não espera um minuto real.
Testando tickers
Tickers também podem ser testados com tempo falso, mas tenha cuidado. Tickers são frequentemente usados em loops de longa duração, e loops de longa duração precisam de um caminho de desligamento limpo.
Aqui está um contador baseado em ticker pequeno:
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
}
Um teste pode ser assim:
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("ticks = %d, esperado 3", counter.Ticks())
}
})
}
Este exemplo tem um detalhe de design intencional: o worker tem um caminho de desligamento.
Isso não é bom apenas para testes. É bom para produção.
Testes frequentemente revelam se suas goroutines podem realmente parar.
synctest e vazamentos de goroutines
testing/synctest é útil aqui porque synctest.Test espera que as goroutines na bolha saiam antes de retornar, o que significa que goroutines vazadas são mais difíceis de ignorar. Se uma goroutine de fundo nunca sair, o teste falha em vez de silenciosamente deixar trabalho para trás — e isso é uma boa coisa.
Código concorrente deve ter propriedade clara. Se uma função inicia uma goroutine, deve haver uma maneira explícita de pará-la, ou um motivo documentado por que ela é permitida a viver para sempre. Em testes, “para sempre” quase nunca é aceitável. Propriedade mais um teto de concorrência é o hábito de design que este artigo associa a: Limitando Concorrência em Go cobre os padrões do lado de produção cujos caminhos de vazamento uma bolha captura em milissegundos.
Um bom padrão é:
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
Então faça a goroutine parar quando o context for cancelado.
O que “durably blocked” (bloqueado duravelmente) significa na prática
Os docs oficiais usam o termo “durably blocked”.
Você não precisa memorizar cada detalhe do runtime, mas deve entender o significado prático.
Uma goroutine está duravelmente bloqueada quando ela está bloqueada de uma maneira que só pode ser desbloqueada por algo dentro da mesma bolha synctest.
Exemplos incluem:
- receber de um canal criado dentro da bolha
- enviar para um canal criado dentro da bolha
- esperar em um
sync.WaitGroupassociado à bolha - dormir com
time.Sleep - esperar em certas operações de timer
Algumas coisas não estão duravelmente bloqueadas porque algo fora da bolha pode desbloqueá-las.
Exemplos incluem:
- I/O de rede
- chamadas de sistema
- operações de processo externo
- algumas esperas de mutex
- interações com goroutines fora da bolha
É por isso que testes synctest devem ser autocontidos e mantidos livres de sincronização externa que a bolha não pode ver. Não use synctest como um wrapper para testes de integração que falam com a rede real.
Para o que synctest é bom
testing/synctest é especialmente bom para testes unitários em torno de comportamento assíncrono.
Bons candidatos incluem:
- cancelamento de context
- timeouts de context
- loops de nova tentativa
- lógica de backoff
- limpeza atrasada
- workers guiados por timer
- loops guiados por ticker
- goroutines de fundo
- comportamento de timeout
- coordenação de canais
time.AfterFunc- espera determinística por goroutines
O caso de uso ideal é código onde a parte difícil é tempo ou agendamento, não I/O externo.
Para o que synctest não é bom
testing/synctest não é um substituto para todo teste de concorrência.
Não é um agendador totalmente determinístico para toda corrida possível.
Não é um substituto para o detector de corrida (race detector).
Não é um substituto para testes de integração.
Não torna o I/O de rede real determinístico.
Não corrige um mau design de ciclo de vida de goroutines.
Não significa que você pode ignorar canais, contexts, propriedade e desligamento.
Use synctest para a camada certa: testes unitários determinísticos para comportamento concorrente e dependente de tempo.
Use outras ferramentas para outras camadas:
- use
go test -racepara detectar corridas de dados - use testes de integração para dependências reais
- use testes de carga para throughput e contenção
- use benchmarks para desempenho
- use tracing e profiling para comportamento de produção
synctest vs o detector de corrida
testing/synctest e o detector de corrida resolvem problemas diferentes.
O detector de corrida encontra acesso de memória concorrente inseguro.
synctest ajuda você a controlar o tempo assíncrono e a espera em testes.
Você deve frequentemente usar ambos.
Por exemplo, esta ainda é uma corrida mesmo dentro de uma bolha synctest se não houver sincronização adequada:
value := 0
go func() {
value = 1
}()
_ = value
synctest.Wait pode fornecer um ponto de sincronização para alguns padrões de teste, mas não significa que cada acesso concorrente no seu código é automaticamente seguro.
Rode testes concorrentes com:
go test -race ./...
O detector de corrida ainda é uma das melhores ferramentas que a Go te dá. Em pareamento com Go Linters: Ferramentas Essenciais para Qualidade de Código dá a você uma base sólida de análise estática e verificação em runtime para qualquer base de código concorrente.
synctest vs relógios falsos manuais
Antes de testing/synctest, muitas equipes usavam relógios falsos manuais.
Isso ainda pode ser um bom design.
Uma interface de relógio manual pode ser assim:
type Clock interface {
Now() time.Time
After(time.Duration) <-chan time.Time
Sleep(time.Duration)
}
Então o código de produção usa um relógio real e os testes usam um relógio falso.
Isso dá controle explícito, mas tem um custo:
- mais interfaces
- mais encanamento (plumbing)
- mais abstrações apenas para testes
- mais maneiras de o código contornar o relógio falso acidentalmente
synctest é atraente porque código comum que usa o pacote time pode rodar contra tempo falso dentro da bolha de teste.
Isso reduz a necessidade de injeção de relógio em muitos casos.
Minha opinião: use synctest quando ele mantenha o código de produção mais simples. Use um relógio injetado apenas quando o controle de relógio for parte do seu design de domínio ou quando você precisar de controle além do que synctest fornece. Para uma visão mais ampla dos padrões de injeção de dependência em Go — incluindo quando e como injetar abstrações testáveis — veja Injeção de Dependência em Go: Padrões & Melhores Práticas.
synctest vs canais e WaitGroups
Não substitua boa sincronização com synctest.
Se o seu código pode expor um canal de conclusão, um callback ou um método Wait, isso frequentemente é um bom design.
Por exemplo:
type Server struct {
done chan struct{}
}
func (s *Server) Done() <-chan struct{} {
return s.done
}
Um teste pode esperar diretamente por isso.
synctest é mais útil quando o comportamento em teste envolve tempo, prazos de context, agendamento de fundo ou callbacks assíncronos.
Os melhores testes frequentemente combinam ambos:
- código de produção tem sinais de desligamento ou conclusão explícitos
- synctest remove esperas de tempo real
- Wait torna a atividade de fundo determinística
Erros comuns
Erro 1: Envolvendo cada teste em synctest
Não use synctest em toda parte. Se o código é síncrono, uma função de teste simples é mais clara, e adicionar o wrapper de bolha apenas introduz mecanismos desnecessários que tornam os testes mais difíceis de ler e raciocinar.
Erro 2: Testando I/O de rede real dentro da bolha
Mantenha testes synctest autocontidos. Se o seu teste usa um soquete de rede real, serviço externo, banco de dados ou subprocesso, ele pertence a um teste de integração em vez de dentro de uma bolha synctest. Use falsos (fakes) para testes unitários e reserve dependências reais para testes de integração separados onde o isolamento da bolha não se aplica. Quando a dependência é um handler HTTP, o httptest.NewTestServer do Go 1.27 remove a necessidade de um soquete real inteiramente — ele roda o servidor sobre uma rede falsa em memória, que é exatamente o que a bolha precisa, e se pareia com synctest para timing determinístico de ida e volta como mostrado no guia de atualização para Go 1.27.
Erro 3: Vazando goroutines
Se o seu teste inicia uma goroutine, certifique-se de que ela tenha um caminho de saída claro. Use cancelamento de context, canais fechados ou métodos de parada explícitos — uma goroutine que nunca para é tanto um cheiro de produção quanto um cheiro de teste que synctest revelará em vez de esconder.
Erro 4: Dependendo de estado de nível de pacote
Canais, timers e WaitGroups de nível de pacote podem quebrar o isolamento da bolha de maneiras sutis. Prefira criar todo o estado do teste dentro da função synctest.Test para que cada recurso pertença à bolha e seu ciclo de vida seja claramente escopado ao teste.
Erro 5: Tratando tempo falso como tempo real
Tempo falso é para testes determinísticos, não medição de desempenho. Um teste que avança uma hora instantaneamente não te diz nada útil sobre custo de CPU, contenção de lock, uso de memória ou comportamento de agendamento real em produção — use benchmarks e testes de carga para essas perguntas.
Erro 6: Ignorando o detector de corrida
synctest não é um substituto para go test -race, e as duas ferramentas resolvem problemas diferentes. Rode o detector de corrida ao lado dos seus testes synctest para pegar acesso de memória concorrente inseguro que a bolha sozinha não pode detectar.
Uma lista de verificação prática
Use esta lista de verificação ao escrever testes com testing/synctest.
Use synctest quando
- o código inicia goroutines
- o código usa
time.Sleep - o código usa timers ou tickers
- o código usa prazos de context
- o código tem comportamento de nova tentativa ou backoff
- o teste atualmente usa sleeps arbitrários
- o teste é instável (flaky) no CI
- o teste é lento porque espera tempo real
Evite synctest quando
- o código é síncrono
- o teste depende de I/O de rede real
- o teste depende de processos externos
- o teste é realmente um teste de integração
- você está tentando medir desempenho
- o código não tem um caminho de desligamento limpo
Prefira este padrão
func TestSomething(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// Arranje.
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
// Aja.
_ = ctx
// Deixe o trabalho de fundo se assentar.
synctest.Wait()
// Avance o tempo falso se necessário.
time.Sleep(1 * time.Second)
synctest.Wait()
// Afirme.
})
}
Este padrão é simples:
- configure dentro da bolha
- inicie o trabalho dentro da bolha
- espere a atividade de fundo se assentar
- avance o tempo falso apenas quando necessário
- afirme após a sincronização
Onde usar testing/synctest em projetos reais
Os melhores lugares para olhar geralmente não estão em lógica de negócios simples.
Procure testes com esses cheiros:
grep -R "time.Sleep" .
grep -R "time.After" .
grep -R "WithTimeout" .
grep -R "WithDeadline" .
grep -R "NewTicker" .
grep -R "AfterFunc" .
Então pergunte:
- Este teste é lento porque espera tempo real?
- Este teste é instável (flaky) porque assume que uma goroutine já rodou?
- Este teste pode ser isolado de rede e processos externos?
- A goroutine de fundo pode ser parada limpa mente?
- O tempo falso tornaria a afirmação mais clara?
Bons candidatos frequentemente vivem em:
- pacotes de workers
- pacotes de nova tentativa
- pacotes de cache
- pacotes de agendador
- consumidores de fila
- wrappers de cliente HTTP
- middleware de timeout
- código de limpeza de fundo
- código de limitação de taxa
Comece com um teste instável (flaky). Não migre toda a base de código de uma vez. Se sua suíte de testes usa testes orientados a tabelas paralelos ao lado de código assíncrono, Testes Orientados a Tabelas Paralelos em Go cobre os padrões de t.Parallel() e armadilhas de condição de corrida que se pareiam naturalmente com a abordagem synctest.
Exemplo: antes e depois
Aqui está um teste ruim realista:
func TestRetryBad(t *testing.T) {
calls := 0
err := Retry(context.Background(), 3, 500*time.Millisecond, func() error {
calls++
if calls < 3 {
return errors.New("falha temporária")
}
return nil
})
if err != nil {
t.Fatalf("Retry retornou erro: %v", err)
}
if calls != 3 {
t.Fatalf("chamadas = %d, esperado 3", calls)
}
}
Isto espera cerca de um segundo porque dois atrasos de nova tentativa ocorrem.
Isso pode não soar ruim, mas multiplique por muitos testes e vários pacotes. Testes lentos fazem desenvolvedores rodar testes menos frequentemente.
Agora a versão 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("falha temporária")
}
return nil
})
if err != nil {
t.Fatalf("Retry retornou erro: %v", err)
}
if calls != 3 {
t.Fatalf("chamadas = %d, esperado 3", calls)
}
})
}
O teste mantém o valor real do atraso, a suíte permanece rápida, e a intenção é mais clara. Esse é o principal valor de testing/synctest.
Como adotar synctest com segurança
Eu o adotaria gradualmente.
Passo 1: Encontre testes concorrentes instáveis ou lentos
Procure sleeps reais e testes intensivos em timeout. Os comandos grep na seção anterior são um bom ponto de partida para identificar candidatos em toda a base de código.
Passo 2: Escolha um pacote
Escolha um pacote que tenha comportamento assíncrono claro, mas não requer serviços externos reais. Pacotes de workers, helpers de nova tentativa e componentes guiados por timer são alvos iniciais ideais.
Passo 3: Converta um teste
Envolve o teste em synctest.Test e substitua sleeps arbitrários por synctest.Wait, sleeps de tempo falso ou sincronização explícita. A conversão geralmente é pequena — a parte mais difícil é garantir que as goroutines tenham caminhos de desligamento limpos.
Passo 4: Rode com o detector de corrida
Sempre rode com go test -race ./... após a conversão. Um teste synctest passando não significa que o código é livre de corridas; apenas significa que o tempo assíncrono agora é determinístico.
Passo 5: Revise o ciclo de vida das goroutines
Certifique-se de que toda goroutine iniciada pelo teste tenha uma maneira de sair antes que a bolha feche. Se ela não tiver, synctest.Test revelará o vazamento em vez de ignorá-lo silenciosamente.
Passo 6: Repita apenas onde melhora a clareza
Não converta testes apenas por moda. Um bom teste synctest deve ser mensuravelmente mais rápido, mais claro de ler, ou menos instável do que a versão que substituiu — se não for, a conversão não valeu a pena.
Minhas regras opinativas
Use estas como regras práticas de polegar (heurísticas).
Regra 1: Sem sleeps arbitrários em testes unitários concorrentes
Um sleep que espera para uma goroutine terminar talvez seja um cheiro. Substitua-o por canais, WaitGroups, callbacks, synctest.Wait ou tempo falso — qualquer coisa que espere por uma condição em vez de esperar que tempo suficiente tenha passado.
Regra 2: Mantenha testes synctest autocontidos
Crie goroutines, canais, contexts, timers e workers dentro da bolha. Evite estado compartilhado de nível de pacote, que pode vazar entre testes e quebrar o isolamento que torna synctest útil.
Regra 3: Não use synctest como um wrapper de teste de integração
Se o teste fala com um banco de dados real, rede real ou processo externo, mantenha-o fora de synctest a menos que você tenha um motivo muito específico para fazer isso.
Regra 4: Teste comportamento, não sorte do agendador
O objetivo não é forçar uma goroutine a rodar. O objetivo é verificar o comportamento observável após o sistema ter alcançado um estado significativo, o que synctest.Wait torna possível sem depender de suposições de tempo.
Regra 5: Mantenha caminhos de cancelamento explícitos
Cada goroutine de fundo deve ter um caminho de desligamento, e testes devem provar que esse caminho funciona cancelando o context ou fechando o canal e então verificando que a goroutine sai limpa mente.
Pensamentos finais
testing/synctest é um daqueles recursos Go que parece pequeno, mas muda como você escreve uma classe de testes. Ele não substitui um bom design de concorrência, o detector de corrida, ou a necessidade de testes de integração — mas ele torna muitos testes unitários assíncronos mais rápidos, mais limpos e muito menos dependentes de sorte de tempo.
Isso importa porque código concorrente já é difícil o suficiente. Testes devem reduzir incerteza, não adicioná-la. Para uma visão mais ampla dos padrões Go de produção através de integração, estrutura de código e acesso a dados, veja Arquitetura de Aplicações em Produção.
A conclusão prática é simples:
Use synctest para testes unitários determinísticos em torno de goroutines, timers, timeouts, novas tentativas e cancelamento.
Mantenha sleeps reais fora de testes concorrentes, a menos que você tenha um motivo muito bom.
Esse um hábito tornará muitas suítes de teste Go mais rápidas e menos instáveis (flaky).
Os fatos atuais importantes são: testing/synctest tornou-se geralmente disponível no Go 1.25, ele expõe synctest.Test e synctest.Wait, ele roda testes dentro de uma bolha isolada, e o tempo dentro dessa bolha usa um relógio falso que avança apenas quando as goroutines estão duravelmente bloqueadas.
Fontes
- https://pkg.go.dev/testing/synctest
- https://go.dev/blog/testing-time
- https://go.dev/blog/synctest
- [https://go.dev/blog/testing-time](“Testando Tempo (e outras assincronias) - A Linguagem de Programação Go”)
- https://go.dev/doc/go1.25
- https://go.dev/blog/go1.25