Zurück zu allen Beiträgen
·6 Min. Lesezeit

Zeitabhängigen Code in .NET testen: die Uhr mit TimeProvider unter Kontrolle bringen

Zeitabhängiger Code ist eine der häufigsten Ursachen für instabile Tests. Ist ein Token abgelaufen, ist eine Rabattaktion beendet, wann erfolgt der nächste Wiederholungsversuch, zu welchem Tag gehört ein Bericht: All das hängt vom Wissen um „jetzt“ ab. Liest der Code direkt DateTime.UtcNow, ist der Test an die Systemuhr gebunden. Das Ergebnis ist bekannt: Tests, die kurz vor Mitternacht fehlschlagen, Szenarien, die in der CI gelegentlich in ein Timeout laufen, und langsame Testsuiten voller Thread.Sleep.

In diesem Beitrag beschreibe ich, wie ich die Uhr mit der TimeProvider-Abstraktion in .NET zu einer Abhängigkeit mache, wie ich in Tests die Zeit mit einer falschen Uhr vorspule und auf welche Fallstricke man achten sollte.

Das Problem: Die Uhr ist eine versteckte Abhängigkeit

Betrachten wir diese einfache Klasse:

public sealed class CouponService
{
    public bool IsValid(Coupon coupon) =>
        coupon.ExpiresAt > DateTimeOffset.UtcNow;
}

Der Code sieht korrekt aus, ist aber schwer zu testen. Um einen abgelaufenen Gutschein zu testen, denkt man sich ein Datum in der Vergangenheit aus, für einen gültigen eines in der Zukunft. Der eigentlich wichtige Fall, das Verhalten „genau an der Grenze“, ist praktisch nicht testbar, weil man nicht kontrollieren kann, wie viel Zeit zwischen dem Start des Tests und dem Lesen von UtcNow vergeht.

Schlimmer noch: Diese Abhängigkeit ist in der Signatur nicht sichtbar. Wer sich den Konstruktor ansieht, erkennt nicht, dass die Klasse eine Uhr braucht. Wenn wir andere externe Abhängigkeiten wie eine Datenbank oder einen HTTP-Client ohnehin injizieren, ist es nur konsequent, die Uhr genauso zu behandeln.

TimeProvider als Abhängigkeit übernehmen

Früher hat jedes Team sein eigenes IClock- oder ISystemClock-Interface geschrieben. In aktuellen .NET-Versionen ist es die bessere Wahl, stattdessen die abstrakte Klasse TimeProvider des Frameworks zu verwenden, denn nicht nur „jetzt“, sondern auch Timer, Verzögerungen und die Messung verstrichener Zeit laufen über dieselbe Abstraktion.

public sealed class CouponService(TimeProvider time)
{
    public bool IsValid(Coupon coupon) =>
        coupon.ExpiresAt > time.GetUtcNow();
}

In der Anwendung registriert man die echte Uhr mit einer einzigen Zeile:

builder.Services.AddSingleton(TimeProvider.System);

TimeProvider.System ist zustandslos, daher ist die gemeinsame Nutzung als Singleton unproblematisch. Die Uhr ist jetzt eine explizite Abhängigkeit, die in der Signatur der Klasse sichtbar ist.

Die Uhr im Test durch eine falsche ersetzen

In Tests kann man FakeTimeProvider aus dem Paket Microsoft.Extensions.TimeProvider.Testing verwenden. Das ist eine Uhr, die sich erst bewegt, wenn man es ihr sagt:

[Fact]
public void Coupon_expires_exactly_at_expiry_time()
{
    var start = new DateTimeOffset(2026, 3, 1, 12, 0, 0, TimeSpan.Zero);
    var time = new FakeTimeProvider(start);
    var service = new CouponService(time);
    var coupon = new Coupon(ExpiresAt: start.AddMinutes(30));

    Assert.True(service.IsValid(coupon));

    time.Advance(TimeSpan.FromMinutes(30));

    Assert.False(service.IsValid(coupon));
}

Dieser Test ist deterministisch: Er liefert dasselbe Ergebnis, egal an welchem Tag, in welcher Zeitzone oder auf wie langsamer Hardware er läuft. Ich empfehle, die Startzeit immer explizit anzugeben. Wer den Test liest, sieht, zu welchem Zeitpunkt das Szenario spielt, und man kann bewusst besondere Daten wie ein Monatsende, ein Schaltjahr oder eine Sommerzeitumstellung wählen.

Verzögerungen und Timer

Der eigentliche Gewinn zeigt sich bei Code, der wartet. Betrachten wir eine einfache Wiederholungsschleife:

public sealed class RetryingSender(IMessageClient client, TimeProvider time)
{
    public async Task SendAsync(Message message, CancellationToken ct)
    {
        for (var attempt = 1; ; attempt++)
        {
            try
            {
                await client.SendAsync(message, ct);
                return;
            }
            catch (TransientException) when (attempt < 3)
            {
                var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt));
                await Task.Delay(delay, time, ct);
            }
        }
    }
}

Dank der Überladung von Task.Delay, die einen TimeProvider entgegennimmt, ist das Warten an die übergebene Uhr gebunden. Dasselbe gilt für PeriodicTimer, Timeouts von CancellationTokenSource und WaitAsync; auch sie haben Konstruktoren und Überladungen, die einen TimeProvider akzeptieren. Spult man im Test die falsche Uhr vor, werden wartende Tasks abgeschlossen, ohne auf echte Zeit zu warten:

var sendTask = sender.SendAsync(message, CancellationToken.None);

time.Advance(TimeSpan.FromSeconds(2));
time.Advance(TimeSpan.FromSeconds(4));

await sendTask;
Assert.Equal(3, client.CallCount);

Hier gibt es eine Feinheit. Ein Aufruf von Advance löst nur Timer aus, die zu diesem Zeitpunkt bereits angelegt sind. Spult man die Uhr vor, bevor der Code seinen Timer eingerichtet hat, wird diese Wartezeit verpasst und der Test hängt. In asynchronen Abläufen ist diese Reihenfolge nicht immer garantiert. In der Praxis gehe ich einen von zwei Wegen: Ich mache beobachtbar, dass die getestete Komponente in die nächste Wartephase eingetreten ist (etwa indem der falsche Client seine Aufrufzahl über eine TaskCompletionSource meldet), oder ich spule die Uhr erst vor, wenn sicher ist, dass das Warten begonnen hat. Task.Delay(100) im Test zu verstreuen löst das Problem nicht, es verdeckt nur die Instabilität.

Verstrichene Zeit messen

Code, der Stopwatch für Performancemessungen oder Timeout-Berechnungen nutzt, hat dasselbe Problem. TimeProvider bietet dafür GetTimestamp und GetElapsedTime:

var started = time.GetTimestamp();
await ProcessAsync(ct);
var elapsed = time.GetElapsedTime(started);

if (elapsed > SlowThreshold)
    logger.LogWarning("Vorgang wurde langsam abgeschlossen: {Elapsed}", elapsed);

Mit einer falschen Uhr lässt sich so das Szenario „wenn der Vorgang die Schwelle überschreitet, wird eine Warnung geloggt“ prüfen, ohne Sekunden zu warten. Auch der Unterschied zwischen Wanduhrzeit und monotonem Zeitstempel bleibt erhalten: Für Dauermessungen sollte man GetTimestamp und nicht GetUtcNow verwenden, weil die Systemuhr durch Synchronisierung vor- oder zurückspringen kann.

Zeitzonen und der Begriff „heute“

Ein großer Teil der zeitbezogenen Fehler tritt nicht bei „jetzt“, sondern bei „heute“ auf. Läuft der Server in UTC, während sich der Nutzer in Istanbul befindet, gehört eine nachts um 01:00 Uhr erstellte Bestellung aus Sicht des Nutzers zu heute, aus Sicht des Servers zu gestern. Deshalb halte ich einige Regeln strikt ein:

  • Speichern und Vergleichen erfolgen immer in UTC; DateTimeOffset wird bevorzugt.
  • Die Umrechnung in ein lokales Datum erfolgt dort, wo die Geschäftsregel sie braucht, mit einer expliziten Zeitzone.
  • GetLocalNow oder DateTime.Now, die von der lokalen Zeitzone des Servers abhängen, werden in der Geschäftslogik nicht verwendet.
var istanbul = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul");
var localToday = DateOnly.FromDateTime(
    TimeZoneInfo.ConvertTime(time.GetUtcNow(), istanbul).DateTime);

Mit einer falschen Uhr wird es einfach, diesen Code mit Zeitpunkten auf beiden Seiten der Tagesgrenze zu testen: Man gibt zwei Startzeiten wie 20:59 und 21:00 UTC vor und prüft, dass das Datum korrekt wechselt.

Eine bestehende Codebasis umstellen

In einem großen Projekt muss man nicht alle DateTime.UtcNow-Aufrufe an einem Tag ersetzen. Ich gehe in dieser Reihenfolge vor:

  1. TimeProvider.System im DI-Container registrieren.
  2. Zuerst die zeitkritischen Geschäftsregeln umstellen: Ablauf, geplante Jobs, Wiederholungen, Abrechnung.
  3. Direktes Lesen der Uhr in neuem Code mit einer Analyzer-Regel oder einem Punkt in der Code-Review-Checkliste verhindern.
  4. In statischen Hilfsfunktionen die Uhr als Parameter übernehmen; wo es keine Klasse gibt, reicht es, TimeProvider zum Methodenargument zu machen.

Ein statisches Clock.Now zu halten und im Test auszutauschen, mag verlockend wirken, doch bei parallel laufenden Tests erzeugt gemeinsamer Zustand neue Instabilität. Eine injizierte Abhängigkeit trägt dieses Risiko nicht.

Abschluss

Die Uhr als Abhängigkeit zu betrachten, wirkt wie eine kleine Änderung, hat aber große Wirkung: Tests werden deterministisch, Szenarien mit Wartezeiten laufen in Millisekunden, und Grenzfälle werden tatsächlich testbar. TimeProvider ermöglicht das mit den eigenen Werkzeugen des Frameworks, ohne im Team eine neue Abstraktion zu erfinden. Wenn Sie das nächste Mal einen Test untersuchen, der „ab und zu“ fehlschlägt, schauen Sie als Erstes nach, woher der Code die Uhrzeit liest.