TimeProvider: Zeit endlich testbar

TimeProvider: Zeit endlich testbar

- Matt

DateTime.Now ist der Klassiker unter den untestbaren Abhängigkeiten. Eine Methode, die prüft, ob ein Token abgelaufen ist, lässt sich nicht sinnvoll testen, wenn die Zeit direkt aus der statischen Klasse kommt. Die üblichen Auswege waren ein selbstgebautes IDateTimeProvider-Interface oder ein Test, der eine halbe Minute wartet.

Seit .NET 8 gibt es dafür TimeProvider in der Basisklassenbibliothek. Kein eigenes Interface mehr, kein Paket.

Verwendung

Die Klasse nimmt den Provider entgegen, statt die Zeit selbst zu holen:

public sealed class TokenCache(TimeProvider zeit)
{
    private string? _token;
    private DateTimeOffset _gueltigBis;

    public void Speichern(string token, TimeSpan lebensdauer)
    {
        _token = token;
        _gueltigBis = zeit.GetUtcNow() + lebensdauer;
    }

    public string? Holen() => zeit.GetUtcNow() < _gueltigBis ? _token : null;
}

In der Anwendung wird TimeProvider.System registriert:

builder.Services.AddSingleton(TimeProvider.System);

Im Test

Dafür gibt es ein eigenes Paket:

dotnet add package Microsoft.Extensions.TimeProvider.Testing

FakeTimeProvider lässt die Uhr springen, ohne dass irgendetwas wartet:

[TestClass]
public class TokenCacheTests
{
    [TestMethod]
    public void Token_ist_nach_Ablauf_der_Lebensdauer_ungueltig()
    {
        var zeit = new FakeTimeProvider(new DateTimeOffset(2025, 6, 28, 12, 0, 0, TimeSpan.Zero));
        var cache = new TokenCache(zeit);

        cache.Speichern("abc", TimeSpan.FromMinutes(30));
        Assert.AreEqual("abc", cache.Holen());

        zeit.Advance(TimeSpan.FromMinutes(31));
        Assert.IsNull(cache.Holen());
    }
}

Der Test läuft in Millisekunden statt in einer halben Stunde, und er ist deterministisch. Kein Thread.Sleep, keine Toleranzgrenzen, kein gelegentliches Rotwerden auf dem Buildserver.

Mehr als nur die Uhr

TimeProvider deckt nicht nur GetUtcNow() ab, sondern auch Timer und Verzögerungen. Das ist der Teil, der oft untergeht:

await Task.Delay(TimeSpan.FromSeconds(30), zeit);

using var timer = zeit.CreateTimer(
    _ => AufraeumenAusfuehren(),
    null,
    TimeSpan.Zero,
    TimeSpan.FromMinutes(5));

Beides reagiert im Test auf Advance. Damit lässt sich ein Hintergrunddienst, der alle fünf Minuten aufräumt, tatsächlich prüfen, statt ihn beim Testen auszuklammern.

Für Zeitzonen gibt es GetLocalNow() und LocalTimeZone. Auch das lässt sich in FakeTimeProvider setzen, was bei allem hilft, was mit Sommerzeit zu tun hat.

Der Haken

TimeProvider gibt es erst ab .NET 8. Für ältere Ziele existiert das Paket Microsoft.Bcl.TimeProvider, das die Typen nachreicht.

Und es hilft nur dort, wo man ihn auch durchreicht. Ein DateTime.Now tief in einer Hilfsklasse bleibt genauso untestbar wie vorher. Der Umbau eines bestehenden Projekts ist deshalb weniger eine technische als eine Fleißaufgabe: Jede Stelle finden, den Provider hineinreichen, Konstruktoren anpassen. Bei neuem Code kostet es dagegen nichts, es von Anfang an richtig zu machen.