Testando data e hora em .NET: por que DateTime.UtcNow arruína seus testes
Testar data e hora no .NET parece simples, mas a chamada DateTime.UtcNow espalhada pelo código cria testes que aparentam cobertura e não protegem nada. Neste artigo, você entende o problema e conhece as duas soluções do ecossistema: a abstração manual IDateTimeProvider e o TimeProvider, nativo desde o .NET 8 e presente no .NET 10, a versão LTS atual.
Poucos elementos aparecem com tanta frequência em um sistema corporativo quanto a data e a hora. Campos de auditoria, expiração, agendamentos, faturamento e retenção dependem de uma pergunta trivial: que horas são agora?
O problema é que uma chamada estática responde a essa pergunta, o que dificulta testar data e hora. Além disso, ela é uma das dependências externas mais mal compreendidas da plataforma.
O teste que concorda com qualquer coisa
Considere uma entidade comum, com os dois campos de auditoria que praticamente todo sistema possui:
public class Customer
{
public string Name { get; private set; }
public DateTime CreatedAtUtc { get; private set; }
public DateTime UpdatedAtUtc { get; private set; }
public Customer(string name)
{
Name = name;
CreatedAtUtc = DateTime.UtcNow;
UpdatedAtUtc = CreatedAtUtc;
}
public void ChangeName(string name)
{
Name = name;
UpdatedAtUtc = DateTime.UtcNow;
}
}
A regra de negócio é explícita: qualquer alteração no nome atualiza o carimbo de modificação. Na prática, o teste para essa regra costuma ter esta forma:
[Fact]
public void ShouldUpdateUpdatedAtWhenNameChanges()
{
var customer = new Customer("Balta");
customer.ChangeName("André Baltieri");
Assert.True(customer.UpdatedAtUtc <= DateTime.UtcNow);
}
O teste passa em qualquer máquina, em qualquer fuso e a qualquer hora. No entanto, ele não prova absolutamente nada, e a demonstração disso é direta.
Primeira mutação: remover a regra
Suponha que alguém apague a linha responsável pela regra:
public void ChangeName(string name)
{
Name = name;
// UpdatedAtUtc = DateTime.UtcNow;
}
O teste continua verde, porque UpdatedAtUtc conserva o valor do construtor, que também está no passado. Ou seja, a asserção só pergunta se o carimbo é anterior ao presente, e qualquer instante registrado satisfaz essa condição.
Segunda mutação: trocar o fuso
O segundo defeito é mais sutil e bem mais perigoso em produção:
public void ChangeName(string name)
{
Name = name;
UpdatedAtUtc = DateTime.Now;
}
Em um servidor no horário de Brasília, essa troca grava um valor três horas anterior ao instante real. Assim, o registro passa a ter data de alteração anterior à data de criação.
Isso quebra a ordenação cronológica, corrompe sincronizações incrementais e distorce relatórios por período. Mesmo assim, o teste segue verde, porque três horas atrás também é menor que agora.
Essas mutações mostram por que testar data e hora com o relógio real é inútil.
Elas revelam um conceito central: um teste só tem valor se consegue falhar diante de uma implementação incorreta. Na literatura de testes de mutação, esses defeitos se chamam mutantes, e a suíte acima não elimina nenhum deles.
Por que testar data e hora exige determinismo
Escrevemos testes para detectar defeitos cedo, refatorar com segurança e documentar o comportamento esperado. Nenhum desses objetivos sobrevive sem determinismo.
As boas práticas de teste unitário da Microsoft recomendam evitar dependências de infraestrutura nos testes. Afinal, elas deixam a suíte lenta e frágil.
Um teste determinístico produz sempre o mesmo resultado para a mesma entrada. Por outro lado, o teste do início lê a mesma fonte de tempo que a implementação, e portanto mede a si mesmo.
Se você quiser revisar a base conceitual antes de seguir, o artigo sobre fundamentos dos testes de unidade ajuda bastante. Em seguida, volte aqui para testar data e hora com controle total.
O relógio é uma dependência oculta
Existe uma assimetria curiosa nas revisões de código. Uma entidade que abre uma conexão de banco diretamente vira alvo de crítica imediata, mas a mesma classe chama DateTime.UtcNow em cinco métodos sem que ninguém comente.
DateTime.UtcNow é uma chamada estática, então não permite substituição por polimorfismo. Além disso, ela lê estado global, o relógio mantido pelo sistema operacional.
Sobretudo, ela é entrada de dado externo, e é isso que torna difícil testar data e hora.
Ela equivale à resposta de uma API remota. Como não aparece na assinatura de nenhum método, ela se torna uma dependência oculta.
Os sintomas são reconhecíveis: testes que passam de manhã e falham à noite, ou que falham no pipeline porque o agente roda em UTC. Também aparecem quebras em 29 de fevereiro e chamadas a Thread.Sleep para esperar o tempo real passar.
Como testar data e hora com IDateTimeProvider
A solução clássica inverte a dependência do relógio e a expõe como uma interface substituível nos testes:
public interface IDateTimeProvider
{
DateTime UtcNow { get; }
}
public sealed class SystemDateTimeProvider : IDateTimeProvider
{
public DateTime UtcNow => DateTime.UtcNow;
}
O tamanho mínimo da interface é uma decisão de projeto, e não um descuido. Por isso, resista à tentação de acrescentar Now, Today, LocalTimeZone e conversões diversas.
Cada membro extra exige implementação em todo fake de teste, e a maioria nunca será usada. Dessa forma, o princípio de segregação de interfaces recomenda começar com o mínimo e crescer sob demanda real.
A implementação controlável para testar data e hora é igualmente pequena:
public sealed class FakeDateTimeProvider(DateTime utcNow) : IDateTimeProvider
{
public DateTime UtcNow { get; private set; } = utcNow;
public void Advance(TimeSpan interval) => UtcNow = UtcNow.Add(interval);
}
Em seguida, registre a implementação real no contêiner de injeção de dependência uma única vez:
builder.Services.AddSingleton<IDateTimeProvider, SystemDateTimeProvider>();
O tempo de vida singleton faz sentido porque a classe não guarda estado mutável nem recursos descartáveis. Portanto, não há motivo para criar uma instância por requisição.
Onde a dependência deve residir
Uma decisão negligenciada é onde injetar o provedor de tempo. A resposta é: o mais perto possível da borda da aplicação e o mais longe possível do domínio.
Entidades e objetos de valor não precisam conhecer o relógio. Em vez disso, elas recebem o instante relevante como parâmetro:
public class Subscription(DateTime expiresAtUtc)
{
public DateTime ExpiresAtUtc { get; private set; } = expiresAtUtc;
public bool IsActiveAt(DateTime utcNow) => utcNow < ExpiresAtUtc;
}
A entidade continua pura, sem contêiner, sem interface e sem configuração. Quem resolve o instante é o serviço de aplicação, que já opera na fronteira com a infraestrutura:
public class SubscriptionService(
IDateTimeProvider dateTimeProvider,
ISubscriptionRepository repository)
{
public async Task<RenewalResult> RenewAsync(Guid id)
{
// Lê o relógio uma única vez por operação
var now = dateTimeProvider.UtcNow;
var subscription = await repository.GetByIdAsync(id);
if (subscription.IsActiveAt(now))
return RenewalResult.AlreadyActive(subscription.ExpiresAtUtc);
subscription.RenewFrom(now, TimeSpan.FromDays(30));
return RenewalResult.Renewed(subscription.ExpiresAtUtc);
}
}
Repare que o serviço lê o relógio uma única vez e propaga o valor para todas as decisões seguintes. Afinal, leituras múltiplas na mesma operação podem cair em segundos diferentes e gerar inconsistências difíceis de reproduzir.
Testar data e hora com uma referência fixa
Com a dependência explícita, testar data e hora deixa de ser um palpite. Agora o teste controla o tempo em vez de consultá-lo:
private static readonly DateTime Reference =
new(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc);
[Fact(DisplayName = "Should be active when expiration is in the future")]
public void ShouldBeActiveWhenExpirationIsInTheFuture()
{
var subscription = new Subscription(Reference.AddDays(30));
var result = subscription.IsActiveAt(Reference);
Assert.True(result);
}
A constante Reference congela o presente e elimina a variabilidade. Assim, se alguém inverter a comparação na implementação, o teste falha na hora.
O uso de DateTimeKind.Utc merece atenção, porque um DateTime criado sem essa indicação recebe DateTimeKind.Unspecified. Segundo a documentação, esses valores são ambíguos e conversões posteriores podem tratá-los como horário local.
Para entender melhor esses tipos, vale ler o artigo sobre DateTime no C# e .NET. Em resumo, a recomendação oficial é gravar e trafegar datas em UTC com o Kind explícito.
Testar data e hora nas fronteiras
Defeitos ligados a tempo raramente acontecem no meio de um intervalo. Na verdade, eles aparecem nos limites.
Por isso, ao testar data e hora, priorize os limites.
A pergunta quase nunca documentada é esta: no instante exato da expiração, a assinatura está ativa? Testes parametrizados transformam a resposta em especificação executável:
[Theory(DisplayName = "Should evaluate the expiration boundary correctly")]
[InlineData(-1, true)]
[InlineData(0, false)]
[InlineData(1, false)]
public void ShouldEvaluateExpirationBoundary(int offsetInSeconds, bool expected)
{
var subscription = new Subscription(Reference);
var result = subscription.IsActiveAt(Reference.AddSeconds(offsetInSeconds));
Assert.Equal(expected, result);
}
Um segundo antes da expiração a assinatura está ativa, mas no instante exato e um segundo depois não está. Com isso, a regra sai da memória de alguém e passa a morar no repositório.
Além disso, Theory com InlineData evita múltiplas ações em um mesmo teste. Cada linha vira um caso independente, com falha isolada e mensagem específica.
Como testar data e hora com TimeProvider
A partir do .NET 8, a plataforma oferece essa abstração de forma nativa com a classe System.TimeProvider. Para .NET 5 a 7, .NET Framework 4.6.2 ou superior e .NET Standard 2.0, a mesma API vem no pacote Microsoft.Bcl.TimeProvider.
O TimeProvider vai além de informar a hora. Ele expõe GetUtcNow e GetLocalNow, além de GetTimestamp e GetElapsedTime para medir intervalos, LocalTimeZone para o fuso e CreateTimer para temporizadores.
A implementação padrão vem da propriedade estática TimeProvider.System, que usa DateTimeOffset.UtcNow e TimeZoneInfo.Local. O consumo no código de aplicação é direto:
public record PasswordResetToken(Guid UserId, DateTimeOffset ExpiresAt);
public class PasswordResetService(TimeProvider timeProvider)
{
public PasswordResetToken Generate(Guid userId)
=> new(userId, timeProvider.GetUtcNow().AddMinutes(15));
public bool IsValid(PasswordResetToken token)
=> timeProvider.GetUtcNow() < token.ExpiresAt;
}
builder.Services.AddSingleton(TimeProvider.System);
Note que GetUtcNow retorna DateTimeOffset, e não DateTime. Essa escolha é deliberada, pois o DateTimeOffset carrega o deslocamento em relação ao UTC e elimina a ambiguidade que o DateTimeKind só mitiga em parte.
FakeTimeProvider e o fim do Thread.Sleep
A contrapartida de teste fica no pacote Microsoft.Extensions.TimeProvider.Testing, que traz a classe FakeTimeProvider:
dotnet add package Microsoft.Extensions.TimeProvider.Testing
Sem argumentos, o FakeTimeProvider começa em 1º de janeiro de 2000, à meia-noite UTC, e não avança sozinho. Por outro lado, o construtor que recebe um DateTimeOffset fixa qualquer instante inicial, e o método Advance move o relógio de forma explícita:
private static readonly Guid UserId =
Guid.Parse("8f14e45f-ceea-4e7a-9b1c-3d2f5a6b7c8d");
[Fact(DisplayName = "Should invalidate the token after 15 minutes")]
public void ShouldInvalidateTokenAfterFifteenMinutes()
{
var time = new FakeTimeProvider(
new DateTimeOffset(2026, 1, 15, 12, 0, 0, TimeSpan.Zero));
var service = new PasswordResetService(time);
var token = service.Generate(UserId);
time.Advance(TimeSpan.FromMinutes(15));
Assert.False(service.IsValid(token));
}
Quinze minutos passam em tempo de execução desprezível. Assim, não há espera, não há instabilidade e não há acoplamento à velocidade do agente de integração contínua.
O ganho vai além da leitura da hora, porque Task.Delay, Task.WaitAsync e o construtor de CancellationTokenSource aceitam um TimeProvider. Na prática, políticas de repetição e cancelamentos por tempo limite também ficam controláveis.
O FakeTimeProvider ainda permite configurar o fuso local com SetLocalTimeZone. Com isso, você consegue testar data e hora em cenários de horário de verão e de transição de fuso.
Testar data e hora em esperas assíncronas
Um caso comum é uma operação que aguarda um intervalo com Task.Delay. Nesse caso, passe o TimeProvider para a sobrecarga do método:
public class DelayedOperation(TimeProvider timeProvider)
{
public async Task ExecuteAsync(TimeSpan delay)
{
// A espera usa o relógio injetado, e não o relógio real
await Task.Delay(delay, timeProvider);
}
}
No teste, a tarefa só termina quando o relógio falso avança o intervalo combinado:
[Fact]
public async Task DelayedOperationCompletesAfterDelay()
{
var time = new FakeTimeProvider();
var operation = new DelayedOperation(time);
var task = operation.ExecuteAsync(TimeSpan.FromMinutes(5));
Assert.False(task.IsCompleted);
time.Advance(TimeSpan.FromMinutes(5));
await task;
Assert.True(task.IsCompleted);
}
Assim, você consegue testar data e hora em fluxos assíncronos sem esperar cinco minutos reais. Esse mesmo padrão vale para timeouts e políticas de repetição.
Testar data e hora com injeção de dependência
Quando o serviço vem do contêiner, registre o FakeTimeProvider como TimeProvider na coleção de serviços do teste. Dessa forma, toda a árvore de dependências passa a enxergar o relógio controlado:
var time = new FakeTimeProvider();
var services = new ServiceCollection();
services.AddSingleton<TimeProvider>(time);
services.AddSingleton<PasswordResetService>();
var provider = services.BuildServiceProvider();
var service = provider.GetRequiredService<PasswordResetService>();
Em seguida, chame Advance no mesmo objeto time para mover o relógio de todos os serviços resolvidos. Com isso, testar data e hora em testes de integração fica tão simples quanto nos testes de unidade.
Há também o método SetUtcNow, que posiciona o relógio em um instante absoluto. Ele é útil quando o cenário exige uma data específica, como o último dia de um mês.
Boas práticas da documentação oficial
A documentação do FakeTimeProvider lista recomendações que funcionam bem como checklist de revisão. Primeiro, injete sempre o TimeProvider em vez de consultar DateTime ou DateTimeOffset diretamente.
Em seguida, trabalhe em UTC e converta para horário local só na apresentação. Também exercite casos de fronteira como meia-noite, virada de mês, anos bissextos e transições de horário de verão.
Descarte os temporizadores criados por CreateTimer para evitar vazamento de recursos. Além disso, avance o tempo de forma deliberada, deixando a intenção do teste evidente.
Por fim, ao testar data e hora, não misture TimeProvider.System com FakeTimeProvider no mesmo teste. Caso contrário, o comportamento deixa de ser previsível.
Quando ainda faz sentido uma abstração própria
O TimeProvider cobre a grande maioria dos cenários, mas uma implementação derivada continua legítima. A própria documentação mostra o padrão: herdar de TimeProvider e sobrescrever membros como GetUtcNow e LocalTimeZone.
Esse recurso ajuda a testar data e hora em casos específicos, como fixar uma data histórica ou forçar um fuso sem o pacote de testes. Já a interface própria de um membro só continua defensável em bases que ainda não podem adotar o pacote adicional.
O princípio se generaliza
O raciocínio que usamos para testar data e hora também vale para toda entrada não determinística. Por exemplo, Guid.NewGuid produz um valor diferente a cada chamada e impede asserções sobre identificadores gerados.
Da mesma forma, Random sem semente fixa gera sequências irreprodutíveis. Variáveis de ambiente, caminhos de arquivo, cultura corrente e geradores criptográficos pertencem à mesma categoria.
Toda fonte de não determinismo deve aparecer na assinatura, entrar pela composição e ser substituível no teste. Assim, a suíte volta a ser confiável.
Conclusão
O teste do início deste artigo continua verde diante de duas implementações incorretas. Isso não é falha de asserção, mas consequência de comparar o relógio consigo mesmo.
A correção não exige reescrever a arquitetura. Ela exige reconhecer DateTime.UtcNow como dependência externa e tratá-la com o mesmo rigor de um repositório ou de um cliente HTTP.
No .NET 8 ou superior, incluindo o .NET 10, TimeProvider e FakeTimeProvider resolvem o problema com suporte de primeira classe. Em bases mais antigas, uma interface de um único membro resolve a maior parte dos casos.
Em ambos os caminhos, o resultado é o que importa ao testar data e hora: um teste que fica vermelho quando alguém viola a regra.
FAQ
Devo usar DateTime ou DateTimeOffset nas minhas entidades?
DateTimeOffset é preferível para representar um instante na linha do tempo, porque carrega o deslocamento em relação ao UTC. DateTime com DateTimeKind.Utc é aceitável quando toda a persistência já ocorre em UTC.
Ainda vale a pena criar um IDateTimeProvider no .NET 10?
Na maioria dos casos, não. O TimeProvider é a abstração oficial e conta com uma implementação de teste mantida pela Microsoft.
E se meu projeto está em .NET Framework?
O pacote Microsoft.Bcl.TimeProvider disponibiliza o TimeProvider para .NET Framework 4.6.2 ou superior e para .NET Standard 2.0. Os cenários assíncronos usam métodos de extensão em TimeProviderTaskExtensions.
Qual deve ser o tempo de vida do TimeProvider no contêiner?
Singleton. Tanto TimeProvider.System quanto implementações típicas não guardam estado mutável por requisição.
Como testo um BackgroundService que executa a cada intervalo?
Injete o TimeProvider e crie o temporizador com CreateTimer. No teste, use FakeTimeProvider e chame Advance para simular os disparos, descartando os temporizadores ao final.
Por que evitar Thread.Sleep em testes?
Porque ele acopla a suíte ao tempo real, deixa os testes lentos e traz instabilidade. Com um provedor controlável, o mesmo cenário roda sem espera.
Como sei se meus testes de data realmente protegem o código?
Aplique mutações deliberadas: remova a atribuição, inverta a comparação ou troque UtcNow por Now. Se a suíte continuar verde, ela não verifica a regra que diz verificar.