O TimeProvider do .NET 8 e um único ajuste no SynchronizationContext podem economizar segundos em testes unitários de lógica de retentativa que, de outra forma, ficariam ociosos em esperas reais. Um teste que antes levava sete segundos agora termina em algumas dezenas de milissegundos, e a mudança não custa nada extra para ser executada em produção.

Por que o atraso importa

Muitos auxiliares de retentativa usam exponential backoff: espera 1 segundo, depois 2 segundos, depois 4 segundos, e assim por diante. Em produção, isso protege os serviços de sobrecarregar um endpoint que está falhando. Em uma suíte de testes, o mesmo código chama Task.Delay em um relógio real, então a thread de teste realmente entra em espera. Multiplique isso por dezenas de testes e o pipeline de integração contínua (CI) aumenta em minutos sem nenhum ganho funcional. O "imposto do sono" (sleep tax) é puro tempo desperdiçado.

Como o TimeProvider funciona

O .NET 8 introduziu o TimeProvider, uma abstração sobre o relógio do sistema. Uma classe que precisa da hora atual ou precisa de um atraso pode aceitar uma instância de TimeProvider em vez de chamar DateTime.UtcNow ou Task.Delay diretamente. Em produção, você passa TimeProvider.System, que encaminha para o relógio real. Em um teste, você fornece um FakeTimeProvider. O fake permite que você avance o tempo manualmente com Advance(TimeSpan). Internamente, o Task.Delay lê o provedor, então avançar o relógio falso satisfaz instantaneamente qualquer atraso pendente.

A ideia é simples: substituir o relógio real por um controlável e, em seguida, saltar para o ponto onde o código sob teste teria retomado a execução.

A armadilha do SynchronizationContext no xUnit

O conceito funciona em um aplicativo de console, mas no xUnit ele causou um deadlock. O teste chamou Advance() enquanto o auxiliar de retentativa estava aguardando um atraso. O xUnit instala seu próprio SynchronizationContext, que captura as continuações e as executa na thread de teste. Quando o Advance() moveu o relógio para frente, a continuação foi enfileirada no thread pool em vez da thread de teste. A thread de teste continuou avançando o tempo, empurrando o relógio simulado para além do momento em que a próxima retentativa deveria ocorrer. O novo temporizador foi definido para um ponto futuro que nunca seria alcançado, pois não restava nenhuma thread para mover o relógio novamente. O teste travou indefinidamente.

A correção de uma única linha

Adicionar uma única linha no início do teste restaura o comportamento esperado:

SynchronizationContext.SetSynchronizationContext(null);

Limpar o contexto personalizado força as continuações do await a serem executadas no thread pool, onde os callbacks do temporizador falso podem ser executados. Com essa mudança, o mesmo teste cai de sete segundos para aproximadamente 36 milissegundos.

Benefícios mais amplos

Além de eliminar esperas ociosas, um relógio falso facilita o teste de lógicas sensíveis ao fuso horário. Você pode verificar se uma cota diária é resetada na meia-noite local correta sem esperar que o relógio real marque doze horas. A mesma abordagem funciona para qualquer código que utilize o tempo atual em ramificações: injete o provedor, evite chamadas ocultas a Thread.Sleep ou Task.Delay e você obterá testes determinísticos e rápidos.

O que observar

  • Todo atraso deve ser roteado através do TimeProvider injetado. Um Thread.Sleep perdido ou um Task.Delay direto ainda invocará o relógio real e reintroduzirá latência.
  • Se uma biblioteca da qual você depende chamar internamente Task.Delay sem expor um provedor, você não poderá controlar o tempo dela sem um shim mais invasivo. Nesses casos, o benefício pode ser limitado.
  • Use o grep na pasta de testes para procurar por Task.Delay e Thread.Sleep para identificar esperas ocultas. Refatorar essas chamadas para usar o provedor é a única maneira de manter o relógio falso eficaz.

Conclusão

Substituir o relógio do sistema pelo TimeProvider e desabilitar o SynchronizationContext do xUnit transforma testes de retentativa lentos em verificações quase instantâneas, liberando recursos de CI e tornando o código dependente de tempo mais fácil de verificar. O esforço é de apenas algumas linhas de código; o retorno é medido em segundos economizados por execução de teste.