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.