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.
Inhaltsverzeichnis
- 1. Warum new DateTime() im Code ein Testproblem ist
- 2. Ein eigenes Clock-Interface entwerfen
- 3. Die produktive Implementierung
- 4. Eine feste Test-Clock für PHPUnit
- 5. Praxisbeispiel: Rabattaktion an der Gültigkeitsgrenze testen
- 6. Die Symfony Clock Component als fertige Lösung
- 7. Bestehenden Code auf die Clock-Abstraktion umstellen
- 8. Typische Stolperfallen im Umgang mit der Clock-Abstraktion
- 9. Fazit: Eine kleine Abstraktion mit großer Wirkung
- 10. Zusammenfassung
- 11. FAQ
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.