Testando código Go concorrente com synctest
Pare de usar sleeps em testes concorrentes do Go.
Testar código Go concorrente sempre exigiu um pouco de disciplina. Goroutines são baratas, canais são simples e o cancelamento de contexto é idiomático — workers em background e temporizadores estão em todos os lugares 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("background work did not finish")
}
Esse teste pode passar no seu laptop e falhar no CI. Ou pode passar por seis meses e então falhar em um runner sobrecarregado. Ou pode ser lento porque alguém aumentou o sleep de 100 milissegundos para 2 segundos “apenas para estar seguro”.
Isso não é um bom teste — é apostar com um temporizador, e essa aposta fica mais cara à medida que a suíte de testes cresce.
O pacote testing/synctest dá aos desenvolvedores Go uma maneira melhor 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 contexto
- Melhor teste de goroutines em background
- Menos CI instável (flaky)
A versão um pouco opinativa: se seu teste Go concorrente depende de um time.Sleep real, você provavelmente deve tratar esse teste como suspeito.
O que é testing/synctest
testing/synctest é um pacote da biblioteca padrão 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 de parede real.
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 do synctest, o tempo pode avançar instantaneamente quando a bolha está bloqueada e esperando que o tempo avance — esse é o truque central por trás do pacote.
Por que testes Go concorrentes são instáveis (flaky)
Se você é novo em testes Go em geral, Testes Unitários Go: Estrutura & Melhores Práticas cobre o pacote de testes, testes orientados por tabela e padrões de mocking que formam a base sobre a qual este artigo é construído. Testes concorrentes geralmente são instáveis (flaky) por um de três motivos.
Primeiro, eles dependem do escalonador. Uma goroutine pode rodar imediatamente na sua máquina e mais tarde 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 em background terminar.
Terceiro, eles observam o estado muito cedo. O teste verifica o resultado antes que a operação em background tenha realmente sido concluída.
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("background work did not finish")
}
}
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 escreve done em uma goroutine e lê em outra sem sincronização.
Você pode corrigir este exemplo específico com um canal ou um sync.WaitGroup, e muitas vezes deve fazê-lo. Mas quando o código em teste usa temporizadores, prazos de contexto (context deadlines), time.AfterFunc, workers em background ou limpeza atrasada, o teste ainda pode ficar desconfortável — e é exatamente aí que 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.
- Temporizadores e sleeps usam um relógio falso.
synctest.Waitpode esperar que a atividade em background se estabilize.- 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 torna um mau design de concorrência bom. Mas ela 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 que realmente me importa.
ou:
Eu sei o que me importa, mas o código em teste não expõe uma maneira limpa de observá-lo.
Ambos são sinais de design que merecem ser levados a sério — eles apontam para lugares onde o código de produção pode se beneficiar de uma observabilidade mais limpa ou mecanismos de coordenação mais explícitos.
Considere uma função que completa trabalho em background:
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 parecer com isso:
func TestWorkerBad(t *testing.T) {
w := NewWorker()
w.Start()
time.Sleep(6 * time.Second)
select {
case got := <-w.Result():
if got != "done" {
t.Fatalf("got %q, want done", got)
}
default:
t.Fatal("worker did not finish")
}
}
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("got %q, want done", got)
}
default:
t.Fatal("worker did not finish")
}
})
}
O teste ainda expressa o fato de negócio — 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 de desenvolvedor.
Testando timeouts de contexto
Um dos melhores usos para testing/synctest é testar prazos (deadlines) e timeouts de context.Context. A propagação correta de context.Canceled e context.DeadlineExceeded através de camadas de serviço e handler é abordada em profundidade em Arquitetura de Tratamento de Erros Go: Limites e Padrões — synctest permite que você verifique esse comportamento sem passar tempo real.
Aqui está uma função simples que espera até que um contexto 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 forçaria você a alterar 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 canceled too early: %v", err)
default:
}
time.Sleep(30 * time.Second)
synctest.Wait()
select {
case err := <-done:
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("got %v, want %v", err, context.DeadlineExceeded)
}
default:
t.Fatal("context was not canceled")
}
})
}
Esse é o tipo de teste que synctest torna agradável.
Você pode manter valores de timeout realistas no código e ainda rodar testes rapidamente.
Testando cancelamento de contexto
Você também pode testar cancelamento explícito sem competir com a goroutine em background.
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 canceled too early: %v", err)
default:
}
cancel()
synctest.Wait()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("got %v, want %v", err, context.Canceled)
}
default:
t.Fatal("context was not canceled")
}
})
}
O detalhe importante é synctest.Wait.
Ele dá à goroutine em background uma chance de observar o cancelamento e se estabilizar 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, isso 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("goroutine did not run")
}
})
}
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 bloqueado 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 toda goroutine na bolha está duravelmente bloqueada 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("got %v, want %v", elapsed, time.Hour)
}
})
}
Parece que espera uma hora.
Não espera.
Isso é útil para testar:
- timeouts
- prazos (deadlines)
- retries
- backoff
- limpeza atrasada
- limites de taxa
- temporizadores
- tickers
- cancelamento de contexto
Mas há uma regra importante: o tempo falso só ajuda código que usa o pacote time dentro da bolha.
Se 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 retry
Loops de retry são uma fonte comum de testes lentos e instáveis.
Aqui está um pequeno helper de retry:
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 delay 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 delay 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("temporary failure")
}
return nil
})
if err != nil {
t.Fatalf("Retry returned error: %v", err)
}
if calls != 3 {
t.Fatalf("calls = %d, want 3", calls)
}
})
}
O teste representa duas esperas de 10 segundos.
Ainda assim, roda rapidamente.
É aqui que synctest muda a economia dos testes. Você não precisa mais de durações falsas minúsculas espalhadas pelos testes apenas para evitar um CI lento.
Testando cancelamento de retry
Você também pode testar cancelamento durante o delay de retry:
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("temporary failure")
})
}()
synctest.Wait()
cancel()
synctest.Wait()
select {
case err := <-errCh:
if !errors.Is(err, context.Canceled) {
t.Fatalf("got %v, want %v", err, context.Canceled)
}
default:
t.Fatal("Retry did not return after cancellation")
}
})
}
Este teste verifica que o loop de retry responde ao cancelamento em vez de dormir através do delay.
Essa é exatamente a espécie de comportamento que importa em produção.
Testando time.AfterFunc
time.AfterFunc é outro bom candidato.
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("cleanup happened too early")
default:
}
time.Sleep(1 * time.Minute)
synctest.Wait()
select {
case <-cache.Cleaned():
default:
t.Fatal("cleanup did not happen")
}
})
}
Este teste verifica ambos os lados:
- A limpeza não acontece antes do delay.
- A limpeza acontece depois do delay.
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 execução, e loops de longa execução precisam de um caminho de shutdown 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 parecer com isso:
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, want 3", counter.Ticks())
}
})
}
Este exemplo tem um detalhe de design deliberado: o worker tem um caminho de shutdown.
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 em background nunca sair, o teste falha em vez de silenciosamente deixar trabalho para trás — e isso é uma coisa boa.
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 do porquê ela é permitida viver para sempre. Em testes, “para sempre” quase nunca é aceitável.
Um bom padrão é:
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
Então faça a goroutine parar quando o contexto for cancelado.
O que “duravelmente bloqueado” significa na prática
Os documentos oficiais usam o termo “duravelmente bloqueado”.
Você não precisa memorizar cada detalhe do runtime, mas deve entender o significado prático.
Uma goroutine está duravelmente bloqueada quando 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 temporizador
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 processos externos
- algumas esperas de mutex
- interações com goroutines fora da bolha
É por isso que os 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 em torno de testes de integração que falam com a rede real.
Para que synctest é bom
testing/synctest é especialmente bom para testes unitários em torno de comportamento assíncrono.
Bons candidatos incluem:
- cancelamento de contexto
- timeouts de contexto
- loops de retry
- lógica de backoff
- limpeza atrasada
- workers movidos a temporizadores
- loops movidos a tickers
- goroutines em background
- comportamento de timeout
- coordenação de canais
time.AfterFunc- espera determinística por goroutines
O melhor caso de uso é código onde a parte difícil é tempo ou escalonamento, não I/O externo.
Para que synctest não é bom
testing/synctest não é um substituto para todos os testes de concorrência.
Não é um escalonador determinístico completo para cada possível corrida.
Não é um substituto para o detector de race (race detector).
Não é um substituto para testes de integração.
Não torna o I/O de rede real determinístico.
Não conserta um mau design de ciclo de vida de goroutine.
Não significa que você pode ignorar canais, contextos, propriedade e shutdown.
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 performance
- use tracing e profiling para comportamento de produção
synctest vs o detector de race
testing/synctest e o detector de race resolvem problemas diferentes.
O detector de race encontra acesso à 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, isso ainda é uma race 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 todo acesso concorrente no seu código é automaticamente seguro.
Rode testes concorrentes com:
go test -race ./...
O detector de race ainda é uma das melhores ferramentas que Go te dá. Combiná-lo com Linters Go: Ferramentas Essenciais para Qualidade de Código te dá uma base sólida de análise estática e verificação em tempo de execução 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 parecer com isso:
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 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 teste
- mais maneiras para 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 mantém o código de produção mais simples. Use um relógio injetado apenas quando o controle do relógio for parte do seu design de domínio ou quando você precisar de controle fora do que synctest fornece. Para uma visão mais ampla sobre 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 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 nisso.
synctest é mais útil quando o comportamento em teste envolve tempo, prazos de contexto, escalonamento em background ou callbacks assíncronos.
Os melhores testes frequentemente combinam ambos:
- código de produção tem sinais de shutdown ou conclusão explícitos
- synctest remove a espera de tempo real
- Wait torna a atividade em background determinística
Erros comuns
Erro 1: Envolver todo teste em synctest
Não use synctest em todo lugar. Se o código é síncrono, uma função de teste simples é mais clara, e adicionar o wrapper da bolha só introduz maquinaria desnecessária que torna os testes mais difíceis de ler e raciocinar.
Erro 2: Testar I/O de rede real dentro da bolha
Mantenha os testes synctest autocontidos. Se seu teste usa um socket 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 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.
Erro 3: Vazar goroutines
Se seu teste inicia uma goroutine, certifique-se de que ela tenha um caminho de saída claro. Use cancelamento de contexto, canais fechados ou métodos de stop 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: Depender de estado em nível de pacote
Canais, temporizadores e WaitGroups em nível de pacote podem quebrar o isolamento da bolha de maneiras sutis. Prefira criar todo o estado de teste dentro da função synctest.Test para que cada recurso pertença à bolha e seu tempo de vida seja claramente escopado ao teste.
Erro 5: Tratar tempo falso como tempo real
Tempo falso é para testes determinísticos, não para medição de performance. 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 escalonamento real em produção — use benchmarks e testes de carga para essas questões.
Erro 6: Ignorar o detector de race
synctest não é um substituto para go test -race, e as duas ferramentas resolvem problemas diferentes. Rode o detector de race junto com seus testes synctest para pegar acesso à memória concorrente inseguro que a bolha sozinha não pode detectar.
Uma checklist prática
Use esta checklist ao escrever testes com testing/synctest.
Use synctest quando
- o código inicia goroutines
- o código usa
time.Sleep - o código usa temporizadores ou tickers
- o código usa prazos de contexto
- o código tem comportamento de retry ou backoff
- o teste atualmente usa sleeps arbitrários
- o teste é instável (flaky) no CI
- o teste é lento porque espera por 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 performance
- o código não tem um caminho de shutdown limpo
Prefira este padrão
func TestSomething(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// Arrange.
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
// Act.
_ = ctx
// Deixe o trabalho em background se estabilizar.
synctest.Wait()
// Avance tempo falso se necessário.
time.Sleep(1 * time.Second)
synctest.Wait()
// Assert.
})
}
Este padrão é simples:
- configure dentro da bolha
- inicie trabalho dentro da bolha
- espere a atividade em background se estabilizar
- avance tempo falso apenas quando necessário
- assevere após sincronização
Onde usar testing/synctest em projetos reais
Os melhores lugares para olhar geralmente não são em lógica de negócio simples.
Procure por 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 por tempo real?
- Este teste é instável porque assume que uma goroutine já rodou?
- Este teste pode ser isolado de rede e processos externos?
- A goroutine em background pode ser parada limpa?
- Tempo falso tornaria a asserção mais clara?
Bons candidatos frequentemente vivem em:
- pacotes de worker
- pacotes de retry
- pacotes de cache
- pacotes de scheduler
- consumidores de fila
- wrappers de cliente HTTP
- middleware de timeout
- código de limpeza em background
- código de limitação de taxa
Comece com um teste instável. Não migre a base de código inteira de uma vez. Se sua suíte de testes usa testes orientados por tabela paralelos junto com código assíncrono, Testes Orientados por Tabela Paralelos em Go cobre os padrões t.Parallel() e armadilhas de corrida de dados que se encaixam 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("temporary failure")
}
return nil
})
if err != nil {
t.Fatalf("Retry returned error: %v", err)
}
if calls != 3 {
t.Fatalf("calls = %d, want 3", calls)
}
}
Isso espera cerca de um segundo porque dois delays de retry ocorrem.
Isso pode não soar ruim, mas multiplique por muitos testes e vários pacotes. Testes lentos fazem desenvolvedores rodarem testes com menos frequência.
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("temporary failure")
}
return nil
})
if err != nil {
t.Fatalf("Retry returned error: %v", err)
}
if calls != 3 {
t.Fatalf("calls = %d, want 3", calls)
}
})
}
O teste mantém o valor real do delay, a suíte permanece rápida e a intenção é mais clara. Esse é o valor principal de testing/synctest.
Como adotar synctest com segurança
Eu adotaria gradualmente.
Passo 1: Encontre testes concorrentes instáveis ou lentos
Procure por sleeps reais e testes pesados 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 worker, helpers de retry e componentes movidos a temporizadores 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 goroutines tenham caminhos de shutdown limpos.
Passo 4: Rode com o detector de race
Sempre rode com go test -race ./... após converter. Um teste synctest passando não significa que o código está livre de race; significa apenas que o tempo assíncrono agora é determinístico.
Passo 5: Revise o ciclo de vida da goroutine
Certifique-se de que toda goroutine iniciada pelo teste tenha uma maneira de sair antes que a bolha feche. Se 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.
Regra 1: Sem sleeps arbitrários em testes unitários concorrentes
Um sleep que espera por uma goroutine para talvez terminar é um cheiro. Substitua 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, contextos, temporizadores e workers dentro da bolha. Evite estado compartilhado em nível de pacote, que pode vazar entre testes e quebrar o isolamento que torna synctest útil.
Regra 3: Não use synctest como wrapper de teste de integração
Se o teste fala com um banco de dados real, rede real ou processo externo, mantenha-o fora do synctest a menos que você tenha uma razão muito específica para fazer isso.
Regra 4: Teste comportamento, não sorte do escalonador
O objetivo não é forçar uma goroutine a rodar. O objetivo é verificar 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 em background deve ter um caminho de shutdown, e testes devem provar que esse caminho funciona cancelando o contexto ou fechando o canal e então verificando que a goroutine sai limpa.
Pensamentos finais
testing/synctest é uma dessas características Go que parecem pequenas mas mudam como você escreve uma classe de testes. Não substitui bom design de concorrência, o detector de race ou a necessidade de testes de integração — mas torna muitos testes unitários assíncronos mais rápidos, mais limpos e muito menos dependentes da sorte do 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 de padrões Go de produção através de integração, estrutura de código e acesso a dados, veja Arquitetura de App em Produção.
A lição prática é simples:
Use synctest para testes unitários determinísticos em torno de goroutines, temporizadores, timeouts, retries e cancelamento.
Mantenha sleeps reais fora de testes concorrentes a menos que você tenha uma razão muito boa.
Esse único hábito fará muitas suítes de teste Go mais rápidas e menos instáveis.
Os fatos importantes atuais 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 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](“Testing Time (and other asynchronicities) - The Go Programming Language”)
- https://go.dev/doc/go1.25
- https://go.dev/blog/go1.25