O artigo do blog oficial do Go discute a importância e os desafios de testar código assíncrono, introduzindo o pacote testing/synctest, que agora está disponível para uso geral desde o Go 1.25, após um período como pacote experimental no Go 1.24. O pacote visa simplificar significativamente a escrita de testes para código concorrente e assíncrono.
O que é uma Função Assíncrona?
O artigo começa definindo a diferença entre funções síncronas e assíncronas. Uma função síncrona executa uma ação e retorna um resultado imediatamente, enquanto uma função assíncrona retorna imediatamente após ser chamada, executando a ação em segundo plano, em algum momento no futuro. Um exemplo simples é a função Cleanup que remove um diretório de cache de forma síncrona, em contraste com CleanupInBackground, que executa a remoção em uma goroutine separada.
func (c *Cache) Cleanup() {
os.RemoveAll(c.cacheDir)
}
func (c *Cache) CleanupInBackground() {
go os.RemoveAll(c.cacheDir)
}
Funções como context.WithDeadline também são assíncronas, pois criam um contexto que será cancelado em um momento futuro específico.
Desafios dos Testes Assíncronos
Testar funções síncronas é direto: configura-se o estado inicial, chama-se a função e verifica-se o resultado. No entanto, testar funções assíncronas é mais complexo porque é preciso esperar que a operação em segundo plano seja concluída antes de verificar o resultado. Se a espera for muito curta, o teste pode falhar porque a operação ainda não foi concluída. Se a espera for muito longa, o teste se torna lento.
Um desafio ainda maior é verificar se algo não aconteceu. Pode-se verificar que algo não aconteceu ainda, mas como ter certeza de que não acontecerá depois?
Exemplo Prático: Testando context.WithDeadline
O artigo usa a função context.WithDeadline como exemplo para ilustrar os desafios. Um teste comum para essa função é verificar se o contexto é cancelado após o prazo (deadline) especificado. Uma primeira tentativa de implementar esse teste pode ser:
func TestWithDeadlineAfterDeadline(t *testing.T) {
deadline := time.Now().Add(1 * time.Second)
ctx, _ := context.WithDeadline(t.Context(), deadline)
time.Sleep(time.Until(deadline))
if err := ctx.Err(); err != context.DeadlineExceeded {
t.Fatalf("context not canceled after deadline")
}
}
Este teste dorme até o deadline e então verifica se o contexto foi cancelado. O problema é que o teste pode ser flaky, ou seja, pode falhar ocasionalmente, mesmo quando o código está correto, devido a pequenas variações no tempo de execução. Para tentar corrigir isso, pode-se adicionar um tempo extra de espera:
time.Sleep(time.Until(deadline) + 100*time.Millisecond)
No entanto, isso torna o teste mais lento e ainda não garante que ele seja completamente confiável, especialmente em ambientes de CI (Continuous Integration) com alta carga.
O Dilema: Lento ou Flaky
Testes que dependem do tempo real são inerentemente lentos ou flaky. Aumentar o tempo de espera torna o teste mais lento, mas reduz a probabilidade de flakiness. Diminuir o tempo de espera torna o teste mais rápido, mas aumenta a probabilidade de flakiness.
Alternativas: Funções Síncronas e Instrumentação para Testabilidade
Uma abordagem é evitar funções assíncronas sempre que possível, favorecendo funções síncronas que podem ser executadas em goroutines separadas se necessário. Isso simplifica o teste, pois a execução da função pode ser controlada diretamente no teste.
// CleanupInBackground é difícil de testar.
cache.CleanupInBackground()
// Cleanup é fácil de testar,
// e fácil de rodar em background quando necessário.
go cache.Cleanup()
No entanto, nem sempre é possível evitar funções assíncronas. Nesses casos, uma abordagem melhor é instrumentar o código para torná-lo mais testável. Isso envolve o uso de “fake time” (tempo falso) e a capacidade de esperar que toda a atividade em segundo plano seja concluída antes de verificar o resultado.
Um exemplo de como isso pode ser implementado para o teste context.WithDeadline é:
func TestWithDeadlineAfterDeadline(t *testing.T) {
clock := fakeClock()
timeout := 1 * time.Second
deadline := clock.Now().Add(timeout)
ctx, _ := context.WithDeadlineClock(
t.Context(), deadline, clock)
clock.Advance(timeout)
context.WaitUntilIdle(ctx)
if err := ctx.Err(); err != context.DeadlineExceeded {
t.Fatalf("context not canceled after deadline")
}
}
Neste exemplo, fakeClock é uma implementação de um relógio falso que permite controlar o tempo no teste. context.WithDeadlineClock é uma versão modificada da função context.WithDeadline que aceita um relógio como parâmetro. clock.Advance avança o tempo no relógio falso, e context.WaitUntilIdle espera que todas as goroutines relacionadas ao contexto sejam concluídas.
Essa abordagem resolve os problemas de lentidão e flakiness, pois o tempo pode ser controlado de forma precisa e a conclusão das operações em segundo plano pode ser garantida antes de verificar o resultado.
Os dois princípios fundamentais de escrever código concorrente testável são:
- Usar tempo falso (se o código usa tempo).
- Ter alguma forma de esperar pela quiescência (quando toda a atividade em segundo plano é concluída).
O artigo reconhece que a instrumentação do código para testabilidade pode ser complexa, especialmente a parte de identificar quando toda a atividade em segundo plano foi concluída.
Artigo Original
Este e um resumo em português do artigo original publicado no blog oficial do Go.
Titulo original: Testing Time (and other asynchronicities)
Leia o artigo completo em ingles no Go Blog
Autor original: Damien Neil