Clock-Abstraktion für zeitabhängigen Code testbar machen
AI generated
@test
assert
PHPUnit · Clock-Abstraktion · PHP
Clock-Abstraktion
Zeitabhängigen Code sauber testbar machen

Direkte Aufrufe von new DateTime() oder time() sind der klassische Grund, warum zeitabhängige Logik schwer zu testen ist. Eine eigene Clock-Abstraktion löst das Problem an der Wurzel und erlaubt es, feste Zeitpunkte in PHPUnit-Tests zu setzen.

14 Min. Lesezeit Clock Interface Symfony Clock Component Zeitabhängige Tests

1. Warum new DateTime() im Code ein Testproblem ist

Wenn eine Klasse an irgendeiner Stelle direkt new DateTime() oder time() aufruft, um den aktuellen Zeitpunkt zu ermitteln, koppelt sie sich untrennbar an die reale Systemzeit. Ein Test, der prüfen will, ob ein Rabattcode am letzten Gültigkeitstag noch funktioniert, am Tag danach aber nicht mehr, kann diesen Zeitpunkt nicht kontrollieren und muss entweder auf den echten Kalender warten oder unschöne Tricks wie das systemweite Umstellen der Serverzeit anwenden.

Dieses Problem wird in Magento-Projekten besonders sichtbar bei Rabattaktionen, Sonderpreisen mit Gültigkeitszeitraum oder Cronjob-Logik, die sich an Wochentagen oder Tageszeiten orientiert. Ohne Abstraktion bleiben solche Tests entweder unvollständig, weil die kritischen Randfälle rund um Gültigkeitsgrenzen gar nicht geprüft werden, oder sie werden fragil, weil sie vom tatsächlichen Ausführungszeitpunkt des Tests abhängen.

2. Ein eigenes Clock-Interface entwerfen

Die Lösung folgt einem vertrauten Muster: Statt die Systemzeit direkt abzufragen, injiziert man eine Abstraktion, die genau das kapselt. Ein minimales Clock-Interface braucht in der Regel nur eine einzige Methode, die den aktuellen Zeitpunkt als DateTimeImmutable zurückgibt. Diese Einfachheit ist bewusst gewählt, weil ein zu mächtiges Interface mit vielen Methoden die Testbarkeit wieder erschwert.

Wichtig ist, konsequent DateTimeImmutable statt des veränderlichen DateTime zu verwenden. Ein veränderliches Datum, das versehentlich an mehreren Stellen im Code weitergereicht und irgendwo mutiert wird, führt zu denselben schwer nachvollziehbaren Fehlern wie geteilter, mutierbarer Zustand in jedem anderen Kontext. Die Immutable-Variante erzwingt, dass jede Änderung ein neues Objekt erzeugt und macht den Datenfluss dadurch nachvollziehbar. Ein weiterer Grund für ein möglichst schmales Interface ist, dass es sich dadurch leicht auch für andere Zwecke wiederverwenden lässt, etwa um in einem Reporting-Modul konsistent denselben Zeitpunkt zu verwenden wie in der eigentlichen Geschäftslogik.


<?php

declare(strict_types=1);

namespace App\Clock;

use DateTimeImmutable;

/**
 * Abstraktion fuer den aktuellen Zeitpunkt, ersetzt direkte new DateTime()-Aufrufe.
 */
interface ClockInterface
{
    public function now(): DateTimeImmutable;
}

3. Die produktive Implementierung

Für den Produktivbetrieb braucht es eine simple Implementierung, die tatsächlich die Systemzeit liefert. Diese Klasse ist bewusst trivial gehalten, ihr einziger Zweck ist es, als Standard-Implementierung über die Dependency-Injection-Konfiguration verdrahtet zu werden, während in Tests eine andere Implementierung eingesetzt wird.

In einem Magento-Kontext registriert man diese Implementierung als Standard-Preference in der di.xml, sodass jede Klasse, die ClockInterface per Constructor Property Promotion anfordert, automatisch die Systemzeit-Implementierung erhält, ohne dass irgendwo im Anwendungscode noch ein direkter new DateTime()-Aufruf übrig bleibt.


<?php

declare(strict_types=1);

namespace App\Clock;

use DateTimeImmutable;

/**
 * Liefert die tatsaechliche Systemzeit, Standard-Implementierung fuer den Produktivbetrieb.
 */
final class SystemClock implements ClockInterface
{
    public function now(): DateTimeImmutable
    {
        return new DateTimeImmutable();
    }
}

4. Eine feste Test-Clock für PHPUnit

Für Tests braucht es eine zweite Implementierung, die immer denselben, fest konfigurierten Zeitpunkt zurückgibt. Diese Klasse nimmt den gewünschten Zeitpunkt im Konstruktor entgegen und ignoriert die tatsächliche Systemzeit vollständig. Damit lässt sich in jedem Test exakt der Zeitpunkt setzen, der für den jeweiligen Testfall relevant ist, etwa genau eine Sekunde vor Ablauf einer Rabattaktion.

Praktisch ist außerdem eine Erweiterung, die es erlaubt, die interne Zeit während eines Tests gezielt vorzuspulen, etwa um zu prüfen, ob ein Cronjob nach Ablauf einer bestimmten Wartezeit korrekt reagiert. Eine solche advance()-Methode macht Zeitverlauf-Tests möglich, ohne echte Wartezeiten im Testlauf zu erzeugen.


<?php

declare(strict_types=1);

namespace Tests\Support\Clock;

use App\Clock\ClockInterface;
use DateTimeImmutable;
use DateInterval;

/**
 * Feste, manuell steuerbare Uhr fuer PHPUnit-Tests.
 */
final class FrozenClock implements ClockInterface
{
    public function __construct(private DateTimeImmutable $current)
    {
    }

    public function now(): DateTimeImmutable
    {
        return $this->current;
    }

    public function advance(DateInterval $interval): void
    {
        $this->current = $this->current->add($interval);
    }
}

5. Praxisbeispiel: Rabattaktion an der Gültigkeitsgrenze testen

Mit der FrozenClock lässt sich der eingangs beschriebene Fall, ob ein Rabattcode am letzten Gültigkeitstag noch funktioniert und am Tag danach nicht mehr, exakt und ohne echte Wartezeit prüfen. Man erzeugt in setUp() oder direkt im Test eine FrozenClock mit dem gewünschten Zeitpunkt und injiziert sie in die zu testende Klasse.

Der entscheidende Vorteil gegenüber dem Warten auf den echten Kalender ist, dass der Test in Sekundenbruchteilen läuft und trotzdem exakt die kritischen Randfälle rund um die Gültigkeitsgrenze abdeckt, einschließlich des Moments unmittelbar vor und unmittelbar nach dem Ablaufzeitpunkt, der in der Praxis die häufigste Quelle von Off-by-one-Fehlern bei Zeitvergleichen ist. Dieselbe Technik lässt sich außerdem auf Cronjob-Logik übertragen, etwa um zu prüfen, dass ein täglicher Preis-Reset exakt um Mitternacht und nicht erst eine Minute später greift.


<?php

declare(strict_types=1);

namespace Tests\Unit\Discount;

use App\Discount\DiscountCodeValidator;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use Tests\Support\Clock\FrozenClock;

final class DiscountCodeValidatorTest extends TestCase
{
    public function testCodeIsValidOnLastDay(): void
    {
        $clock = new FrozenClock(new DateTimeImmutable('2026-08-31 23:59:59'));
        $validator = new DiscountCodeValidator($clock);

        self::assertTrue($validator->isValid('SUMMER26', validUntil: '2026-08-31'));
    }

    public function testCodeIsInvalidOneSecondAfterExpiry(): void
    {
        $clock = new FrozenClock(new DateTimeImmutable('2026-09-01 00:00:00'));
        $validator = new DiscountCodeValidator($clock);

        self::assertFalse($validator->isValid('SUMMER26', validUntil: '2026-08-31'));
    }
}

6. Die Symfony Clock Component als fertige Lösung

Wer die Abstraktion nicht selbst pflegen möchte, kann auf die Symfony Clock Component zurückgreifen, die seit Symfony 6.2 als eigenständiges, framework-unabhängiges Paket verfügbar ist und sich problemlos in reine PHP- oder Magento-Projekte einbinden lässt. Sie bietet ein ClockInterface mit den Implementierungen NativeClock für den Produktivbetrieb und MockClock für Tests.

Ein Vorteil der Symfony-Lösung ist die zusätzliche ClockAwareTrait, die es erlaubt, eine globale Test-Clock über ClockSensitiveTrait in der Testklasse selbst zu setzen, ohne die Clock manuell durch jede beteiligte Klasse durchzureichen. Für größere Projekte mit vielen zeitabhängigen Services kann das den Verdrahtungsaufwand deutlich reduzieren, allerdings auf Kosten etwas weniger expliziter Abhängigkeiten.


<?php

declare(strict_types=1);

namespace Tests\Unit\Discount;

use App\Discount\DiscountCodeValidator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Clock\ClockSensitiveTrait;
use Symfony\Component\Clock\MockClock;

final class DiscountCodeValidatorSymfonyClockTest extends TestCase
{
    use ClockSensitiveTrait;

    public function testCodeIsInvalidAfterExpiry(): void
    {
        $clock = self::mockTime(new MockClock('2026-09-01 00:00:00'));
        $validator = new DiscountCodeValidator($clock);

        self::assertFalse($validator->isValid('SUMMER26', validUntil: '2026-08-31'));
    }
}

7. Bestehenden Code auf die Clock-Abstraktion umstellen

Der Umstieg in einem gewachsenen Projekt gelingt am besten schrittweise: Man sucht zunächst gezielt nach direkten Aufrufen von new DateTime(), new DateTimeImmutable() und time() in fachlicher Logik, klammert dabei aber technische Infrastruktur wie Logging-Zeitstempel bewusst aus, da nicht jeder Zeitaufruf im Projekt testrelevant ist.

Für jede gefundene Stelle prüft man, ob die Klasse bereits über Constructor Property Promotion Abhängigkeiten injiziert bekommt. In diesem Fall reicht es, ClockInterface als weitere Abhängigkeit zu ergänzen und den direkten Zeitaufruf durch $this->clock->now() zu ersetzen. Für ältere Klassen ohne Dependency Injection lohnt sich die Umstellung meist im selben Zug wie eine ohnehin geplante Modernisierung.

8. Typische Stolperfallen im Umgang mit der Clock-Abstraktion

Ein häufiger Fehler ist, die FrozenClock zwar im Testkonstruktor zu setzen, sie aber zwischen mehreren Testmethoden derselben Klasse versehentlich wiederzuverwenden, etwa als Property, das nur einmal in einer statischen Setup-Methode initialisiert wird. Dadurch schleicht sich ein gemeinsamer, mutierbarer Zustand zwischen eigentlich unabhängigen Tests ein, und die Reihenfolge der Testausführung kann plötzlich Einfluss auf das Ergebnis nehmen. Die FrozenClock sollte deshalb konsequent in jeder einzelnen Testmethode oder in setUp() frisch erzeugt werden.

Eine zweite Stolperfalle betrifft Zeitzonen: Wird die FrozenClock ohne explizite Zeitzone erzeugt, übernimmt sie die Standard-Zeitzone der PHP-Konfiguration, die sich zwischen lokaler Entwicklungsumgebung, CI-Runner und Produktivserver unterscheiden kann. Für Tests, die Zeitzonen-Grenzfälle wie den Wechsel von Sommer- auf Winterzeit prüfen sollen, muss die Zeitzone deshalb immer explizit im DateTimeImmutable-Konstruktor angegeben werden, statt sich auf eine implizite Umgebungseinstellung zu verlassen.

9. Fazit: Eine kleine Abstraktion mit großer Wirkung

Die Clock-Abstraktion gehört zu den Mustern, die auf den ersten Blick trivial wirken, in der Praxis aber einen der hartnäckigsten Testbarkeits-Probleme in Magento- und anderen PHP-Projekten lösen. Statt Tests künstlich zu verlangsamen oder Randfälle rund um Gültigkeitsgrenzen unvollständig zu lassen, macht eine einzige Interface-Abstraktion jeden Zeitpunkt kontrollierbar.

Ob man dabei eine eigene, schlanke Implementierung schreibt oder auf die Symfony Clock Component zurückgreift, hängt vor allem davon ab, wie viele zeitabhängige Services im Projekt existieren und ob die zusätzlichen Komfortfunktionen wie globales Zeit-Mocking den Preis der zusätzlichen Abhängigkeit wert sind.

Ansatz Produktiv-Implementierung Test-Implementierung Besonderheit
Eigenes ClockInterface SystemClock mit new DateTimeImmutable() FrozenClock mit festem Zeitpunkt Volle Kontrolle, keine Zusatzabhängigkeit
Symfony Clock Component NativeClock MockClock Fertige Lösung, zusätzliches Composer-Paket
ClockSensitiveTrait Reale Systemzeit Global gemockte Zeit im Test Kein manuelles Durchreichen der Clock nötig
Direkter time()-Aufruf Aktueller Unix-Timestamp Nicht kontrollierbar Sollte in fachlicher Logik vermieden werden

Mironsoft

Testautomatisierung, Magento-Qualitätssicherung und CI-Integration

Tests, die echte Fehler finden statt nur grün zu leuchten?

Wir prüfen bestehende PHPUnit-Suiten auf Implementierungsdetail-Tests, flaky Tests und fehlende Coverage an kritischen Stellen und bauen daraus eine Teststrategie, die bei jedem Magento-Update wirklich Sicherheit gibt.

Test-Audit

Bestehende Suiten auf Mocking-Antipatterns und blinde Flecken prüfen.

Teststrategie

Unit-, Integrations- und MFTF-Tests sinnvoll für Magento-Projekte kombinieren.

CI-Integration

Schnelle, zuverlässige Testläufe in GitLab CI oder GitHub Actions einrichten.

10. Zusammenfassung

Clock-Abstraktion: Das Wichtigste auf einen Blick

Kernproblem

Direkte new DateTime()- oder time()-Aufrufe koppeln Code untrennbar an die reale Systemzeit.

Lösung

Ein schlankes ClockInterface mit genau einer now()-Methode macht Zeitpunkte injizierbar.

Testnutzen

Eine FrozenClock oder MockClock erlaubt exaktes Testen von Gültigkeitsgrenzen ohne echte Wartezeit.

Fertige Option

Die Symfony Clock Component bietet NativeClock und MockClock ohne eigene Implementierung.

11. FAQ: Clock-Abstraktion: Das Wichtigste auf einen Blick

1Warum ist new DateTime() im fachlichen Code ein Testproblem?
Weil der Zeitpunkt nicht kontrollierbar ist. Tests für Gültigkeitsgrenzen oder zeitabhängige Regeln können den kritischen Moment nicht gezielt herbeiführen und müssen auf den echten Kalender warten.
2Was gehört in ein minimales Clock-Interface?
In der Regel nur eine einzige Methode now(), die den aktuellen Zeitpunkt als DateTimeImmutable zurückgibt. Ein zu mächtiges Interface erschwert die Testbarkeit wieder.
3Warum DateTimeImmutable statt DateTime verwenden?
Weil ein veränderliches DateTime-Objekt, das an mehreren Stellen weitergereicht wird, zu schwer nachvollziehbarem, mutierbarem Zustand führen kann. DateTimeImmutable erzwingt, dass jede Änderung ein neues Objekt erzeugt.
4Wie testet man einen Rabattcode an der Gültigkeitsgrenze?
Mit einer FrozenClock, die auf den letzten gültigen Zeitpunkt und separat auf den ersten ungültigen Zeitpunkt gesetzt wird, jeweils in einem eigenen Testfall.
5Was ist die Symfony Clock Component?
Ein seit Symfony 6.2 verfügbares, framework-unabhängiges Paket mit ClockInterface, NativeClock für den Produktivbetrieb und MockClock für Tests.
6Was macht die ClockSensitiveTrait?
Sie erlaubt es, eine globale Test-Clock direkt in der Testklasse zu setzen, ohne die Clock manuell durch jede beteiligte Klasse durchzureichen.
7Wie migriert man bestehenden Code schrittweise?
Man sucht gezielt nach direkten new DateTime()- und time()-Aufrufen in fachlicher Logik und ersetzt sie durch eine injizierte ClockInterface-Abhängigkeit, Schritt für Schritt statt in einem großen Rutsch.
8Sollte jeder time()-Aufruf im Projekt ersetzt werden?
Nicht unbedingt. Technische Infrastruktur wie Logging-Zeitstempel ist meist nicht testrelevant und muss nicht zwingend über die Clock-Abstraktion laufen.
9Wie lässt sich Zeitverlauf in einem Test simulieren?
Über eine advance()-Methode auf der FrozenClock, die die interne Zeit um ein DateInterval vorspult, ohne echte Wartezeit im Testlauf zu erzeugen.
10Lohnt sich eine eigene Clock-Implementierung gegenüber der Symfony Component?
Für kleine Projekte ohne Symfony-Abhängigkeiten oft ja, wegen der geringeren Komplexität. Für größere Projekte mit vielen zeitabhängigen Services überwiegen meist die Komfortfunktionen der Symfony Clock Component.