Nicht-deterministischen Code reproduzierbar testen
Tests, die von der aktuellen Uhrzeit, zufällig generierten UUIDs oder Pseudozufallswerten abhängen, sind flaky: Sie schlagen manchmal fehl, manchmal nicht, und die Ursache ist schwer zu reproduzieren. Clock-Interfaces, UUID-Factories und kontrollierter Zufall lösen dieses Problem grundlegend – ohne den Produktionscode zu komplizieren.
Inhaltsverzeichnis
- 1. Das Problem mit nicht-deterministischem Code in Tests
- 2. Zeit kontrollieren: Das Clock-Interface-Pattern
- 3. Frozen Clock: Zeit im Test einfrieren
- 4. UUIDs in Tests deterministisch machen
- 5. Pseudozufall kontrollieren: mt_rand und random_int
- 6. Flaky Tests identifizieren und beheben
- 7. Symfony Clock Component und PSR-20
- 8. Zeit und UUIDs in Magento-Tests kontrollieren
- 9. Ansätze im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Das Problem mit nicht-deterministischem Code in Tests
Ein Test ist deterministisch, wenn er bei gleicher Eingabe immer dasselbe Ergebnis produziert. Nicht-deterministische Tests – auch "flaky tests" genannt – schlagen manchmal fehl, manchmal nicht. Die häufigsten Quellen von Nicht-Determinismus in PHP-Tests sind: die aktuelle Zeit (new DateTime(), time(), Carbon::now()), zufällig generierte UUIDs oder IDs, Pseudozufallswerte (rand(), mt_rand(), random_int()) und Datenbankoperationen, die zeitstempelbasierte Felder setzen.
Das Problem mit flaky Tests ist nicht, dass sie gelegentlich fehlschlagen – das ist sogar der beste Fall, weil es ein Signal ist. Das echte Problem ist, dass Tests, die von nicht kontrollierten Zeitwerten oder Zufallswerten abhängen, manchmal grün sein können, obwohl eine Regression eingeführt wurde. Ein Test, der prüft, ob ein Ablaufdatum 30 Tage in der Zukunft liegt, wird je nach Tageszeit unterschiedliche Ergebnisse haben, wenn die Zeit nicht kontrolliert wird. Die Lösung liegt nicht im Test-Design, sondern im Design des Produktionscodes: Nicht-deterministischen Zustand über Interfaces kapseln, die im Test durch kontrollierte Implementierungen ersetzt werden können.
2. Zeit kontrollieren: Das Clock-Interface-Pattern
Das Clock-Interface-Pattern ist die etablierte Lösung für zeitabhängigen PHP-Code. Statt new DateTime() oder time() direkt aufzurufen, nimmt eine Klasse eine ClockInterface-Implementierung als Abhängigkeit und ruft darüber die aktuelle Zeit ab. Im Produktionscode wird eine echte Systemuhr injiziert. Im Test wird eine "Frozen Clock" injiziert, die immer denselben, vordefinierten Zeitstempel zurückgibt.
Seit PSR-20 (Clock Interface) gibt es einen PHP-Standard für dieses Pattern: Das Interface Psr\Clock\ClockInterface definiert eine einzige Methode now(): DateTimeImmutable. Symfony 6.2+ enthält eine vollständige Implementierung dieses Standards im symfony/clock-Paket, inklusive einer MockClock für Tests. Das Pattern ist einfach, mächtig und ohne Framework-Abhängigkeit implementierbar.
<?php
// ClockInterface and system clock implementation
declare(strict_types=1);
namespace Mironsoft\Common\Clock;
use Psr\Clock\ClockInterface;
/**
* System clock implementation — returns the real current time.
* Use this in production via dependency injection.
*/
final class SystemClock implements ClockInterface
{
/**
* Returns the current time as an immutable DateTimeImmutable.
*/
public function now(): \DateTimeImmutable
{
return new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
}
}
// Usage in a service that calculates expiry dates
final class SubscriptionService
{
public function __construct(
private readonly ClockInterface $clock,
private readonly SubscriptionRepositoryInterface $repository,
) {}
/**
* Creates a new subscription expiring 30 days from now.
*/
public function createMonthlySubscription(int $customerId): Subscription
{
$now = $this->clock->now();
$expiresAt = $now->modify('+30 days');
return $this->repository->save(
new Subscription(
customerId: $customerId,
startedAt: $now,
expiresAt: $expiresAt,
)
);
}
}
Das Entscheidende: new DateTime() kommt im gesamten Produktionscode nicht vor. Jeder zeitabhängige Code nutzt das injizierte ClockInterface. Das macht zeitabhängigen Code vollständig testbar, ohne Mocking der PHP-Standardfunktionen oder Manipulation globaler Zustände.
3. Frozen Clock: Zeit im Test einfrieren
Eine Frozen Clock ist eine Test-Implementierung des ClockInterface, die immer denselben vordefinierten Zeitstempel zurückgibt. Die einfachste Implementierung braucht nur wenige Zeilen PHP:
<?php
// Test/Unit/Clock/FrozenClock.php — deterministic clock for tests
declare(strict_types=1);
namespace Mironsoft\Common\Test\Unit\Clock;
use Psr\Clock\ClockInterface;
/**
* Frozen clock for testing — always returns the same predefined time.
* Inject this instead of SystemClock in unit tests.
*/
final class FrozenClock implements ClockInterface
{
private \DateTimeImmutable $frozenAt;
public function __construct(string $dateTime = '2026-01-15 12:00:00', string $timezone = 'UTC')
{
$this->frozenAt = new \DateTimeImmutable($dateTime, new \DateTimeZone($timezone));
}
/**
* Returns the frozen time — always the same, never changes.
*/
public function now(): \DateTimeImmutable
{
return $this->frozenAt;
}
/**
* Advances the frozen time by the given interval — useful for testing sequences.
*/
public function advance(\DateInterval $interval): void
{
$this->frozenAt = $this->frozenAt->add($interval);
}
}
// SubscriptionServiceTest.php — using FrozenClock
final class SubscriptionServiceTest extends TestCase
{
public function testSubscriptionExpiresAfter30Days(): void
{
$clock = new FrozenClock('2026-01-15 12:00:00');
$repoMock = $this->createMock(SubscriptionRepositoryInterface::class);
$repoMock->expects(self::once())
->method('save')
->with(self::callback(function (Subscription $sub) {
// Deterministic: always '2026-01-15' + 30 days = '2026-02-14'
self::assertEquals(
new \DateTimeImmutable('2026-02-14 12:00:00', new \DateTimeZone('UTC')),
$sub->expiresAt
);
return true;
}))
->willReturnArgument(0);
$service = new SubscriptionService($clock, $repoMock);
$service->createMonthlySubscription(42);
}
public function testExpiredSubscriptionIsDetectedCorrectly(): void
{
$clock = new FrozenClock('2026-03-01 00:00:00');
// Subscription that expired on Feb 14 — clock is now March 1
$subscription = new Subscription(
customerId: 42,
startedAt: new \DateTimeImmutable('2026-01-15'),
expiresAt: new \DateTimeImmutable('2026-02-14'),
);
$checker = new SubscriptionExpiryChecker($clock);
self::assertTrue($checker->isExpired($subscription));
}
}
4. UUIDs in Tests deterministisch machen
UUIDs (Universally Unique Identifiers) sind per Definition zufällig generierte Identifikatoren. In Tests, die auf UUIDs in Datenbank-IDs, Event-Payloads oder Audit-Logs angewiesen sind, ist das ein Problem: Jeder Test-Lauf erzeugt andere UUIDs, was das Testen von exakten Werten unmöglich macht. Die Lösung folgt demselben Pattern wie beim Clock-Interface: Eine UuidGeneratorInterface abstrahiert die UUID-Generierung; im Test wird eine deterministische Implementierung injiziert.
Die deterministische UUID-Implementierung für Tests gibt vorprogrammierte UUIDs aus einer internen Liste zurück. Die erste UUID für den ersten Aufruf, die zweite für den zweiten Aufruf und so weiter. Das ermöglicht es, in Tests exakt zu prüfen, welche UUID welchem Objekt zugewiesen wurde. Alternativ: Eine Sequenz-UUID (00000000-0000-0000-0000-000000000001, ...0002, usw.) macht Tests lesbar und reproduzierbar, ohne eine vollständige Liste vorprogrammierter UUIDs zu benötigen.
<?php
// UUID abstraction for testable code
declare(strict_types=1);
namespace Mironsoft\Common\Identity;
/**
* Interface for UUID generation — injectable and mockable in tests.
*/
interface UuidGeneratorInterface
{
/**
* Generates and returns a new UUID string.
*/
public function generate(): string;
}
/**
* Production implementation using ramsey/uuid.
*/
final class RamseyUuidGenerator implements UuidGeneratorInterface
{
public function generate(): string
{
return \Ramsey\Uuid\Uuid::uuid4()->toString();
}
}
/**
* Test implementation — returns sequential UUIDs for deterministic tests.
*/
final class SequentialUuidGenerator implements UuidGeneratorInterface
{
private int $counter = 0;
public function generate(): string
{
$this->counter++;
return sprintf('00000000-0000-0000-0000-%012d', $this->counter);
}
}
// Test using SequentialUuidGenerator
final class OrderCreationServiceTest extends TestCase
{
public function testCreatesOrderWithDeterministicId(): void
{
$uuidGen = new SequentialUuidGenerator();
$service = new OrderCreationService($uuidGen);
$order1 = $service->createOrder(['sku' => 'test-001', 'qty' => 1]);
$order2 = $service->createOrder(['sku' => 'test-002', 'qty' => 2]);
// Deterministic: first call always returns ...000000000001
self::assertSame('00000000-0000-0000-0000-000000000001', $order1->getId());
self::assertSame('00000000-0000-0000-0000-000000000002', $order2->getId());
}
}
5. Pseudozufall kontrollieren: mt_rand und random_int
Pseudozufallsfunktionen wie mt_rand() und random_int() sind schwieriger zu kontrollieren als Zeit und UUIDs, weil PHP keine eingebaute Mechanismus bietet, den Seed für random_int() zu setzen (das wäre auch ein Sicherheitsproblem). Die Lösung ist dieselbe wie bei UUIDs: Zufallswerte über ein Interface kapseln und im Test durch eine deterministische Implementierung ersetzen.
Für Tests, die das Verhalten des Systems bei bestimmten Zufallswerten prüfen müssen, ist die Stub-Methode am einfachsten: Eine RandomnessInterface-Implementierung gibt vorprogrammierte Werte aus einer Liste zurück. Für Tests, die nur prüfen wollen, dass das System mit beliebigen gültigen Zufallswerten korrekt funktioniert, reicht es, einen festen Seed über mt_srand() zu setzen und mt_rand() zu verwenden – das erzeugt deterministische Sequenzen, die zwischen Test-Läufen identisch sind.
6. Flaky Tests identifizieren und beheben
Flaky Tests zu identifizieren ist oft schwieriger als sie zu beheben. PHPUnit selbst bietet keine eingebaute Erkennung für zeitabhängige Tests. Die beste Strategie: Tests mehrfach wiederholen und auf unterschiedliche Ergebnisse prüfen. Das PHPUnit-Flag --repeat=10 führt jeden Test zehnmal aus. Wenn ein Test in einem von zehn Läufen fehlschlägt, ist er flaky. Alternativ: Tests kurz nach Mitternacht oder kurz vor einem Monatswechsel ausführen – das deckt viele zeitabhängige Bugs auf.
Bei identifizierten flaky Tests ist der Behebungsweg immer derselbe: Den Produktionscode refaktorieren, um den nicht-deterministischen Zustand über ein Interface zu kapseln, und dann im Test die deterministische Test-Implementierung injizieren. Es gibt keine sinnvolle Alternative: Versuche, flaky Tests durch sleep()-Aufrufe oder Toleranzbereiche zu stabilisieren, lösen das Problem nicht – sie verbergen es nur und verlängern die Test-Laufzeit.
7. Symfony Clock Component und PSR-20
Symfony 6.2 führte die symfony/clock-Component ein, die eine vollständige PSR-20-Implementierung enthält. Die NativeClock-Klasse ist die Produktionsimplementierung, die MockClock ist die Test-Implementierung mit zusätzlichen Hilfsmethoden für zeitbasierte Tests. Der Vorteil von symfony/clock: Die Library ist gut getestet, wird aktiv gepflegt und integriert sich nahtlos in Symfony-Projekte sowie in andere Frameworks wie Laravel, die PSR-Interfaces unterstützen.
PSR-20 standardisiert das Clock-Interface über alle PHP-Frameworks hinweg. Das bedeutet: Eine Bibliothek, die PSR-20 nutzt, kann in Symfony, Laravel, Magento und jedem anderen PSR-kompatiblen Framework genutzt werden, ohne dass unterschiedliche Clock-Interfaces für unterschiedliche Frameworks implementiert werden müssen. Für Magento-Projekte, die parallel Symfony-Komponenten nutzen, ist PSR-20 die sauberste Integrationsbasis.
8. Zeit und UUIDs in Magento-Tests kontrollieren
Magento 2 nutzt intern teilweise $this->dateTime->gmtDate() und ähnliche Helfer für zeitbezogene Operationen. Diese Klassen sind nicht PSR-20-kompatibel und schwer zu mocken. Für eigene Module ist die Empfehlung klar: Niemals Magento-interne Datums-Helfer direkt in eigenen Services verwenden. Stattdessen PSR-20 ClockInterface implementieren, das die Magento-Helfer kapselt oder direkt PHP-interne Funktionen nutzt.
Für Magento-Integrationstests, wo die Zeit aus der Datenbank kommt oder in Datenbankfeldern gespeichert wird: Das Magento-Fixture-System erlaubt keine direkte Zeitkontrolle. Die Lösung hier ist pragmatischer: Testdaten mit explizit gesetzten Zeitstempeln erzeugen (über das Repository), anstatt auf "jetzt" zu verlassen. Ein Ablaufdatum als 2020-01-01 zu setzen ist deterministisch und dokumentiert die Test-Absicht klar.
| Problem-Quelle | Falscher Ansatz | Richtiger Ansatz | Test-Double |
|---|---|---|---|
| Aktuelle Zeit | new DateTime() direkt |
ClockInterface injizieren | FrozenClock / MockClock |
| UUID-Generierung | Uuid::uuid4() direkt |
UuidGeneratorInterface | SequentialUuidGenerator |
| Zufallswerte | random_int() direkt |
RandomnessInterface | StubRandomness (vordefinierte Liste) |
| Token-Generierung | bin2hex(random_bytes()) direkt |
TokenGeneratorInterface | FixedTokenGenerator |
| Datum in Fixtures | Carbon::now() in Fixtures | Explizite Datumsangaben | FrozenClock in Integration Tests |
9. Ansätze im Vergleich
Es gibt mehrere Ansätze, um nicht-deterministischen Code in PHP-Tests zu kontrollieren. Die sauberste Methode ist das Interface-Pattern: Alle nicht-deterministischen Operationen werden über Interfaces abstrahiert, die im Test durch deterministische Implementierungen ersetzt werden. Dieser Ansatz erfordert anfängliches Refactoring des Produktionscodes, führt aber zu einer dauerhaft saubereren Architektur.
Eine schnellere, aber weniger saubere Alternative für bestehenden Code: Statische Methoden überschreiben mit runkit oder Namespace-Monkey-Patching. Das ist ein Hack, der nicht für neuen Code empfohlen wird, aber für Legacy-Code ohne Interface-Abstraktion als Überbrückungsstrategie dienen kann. Eine dritte Option: Assertions, die Toleranzbereiche akzeptieren, etwa assertEqualsWithDelta() für Zeitstempel. Das macht Tests robuster gegen Timing-Schwankungen, löst aber das eigentliche Problem nicht – die Tests bleiben nicht-deterministisch, nur weniger flaky.
10. Zusammenfassung
Deterministisches Testen von Zeit, UUIDs und Zufallswerten ist kein Test-Problem, sondern ein Architekturproblem. Die Lösung liegt nicht im Test-Design, sondern im Design des Produktionscodes: Nicht-deterministischer Zustand wird über Interfaces abstrahiert. Im Produktionscode werden echte Implementierungen injiziert. Im Test werden deterministische Test-Doubles injiziert. Das Ergebnis ist reproduzierbares, zuverlässiges und verständliches Testverhalten.
Das PSR-20 Clock Interface ist der aktuelle Standard für zeitbasierte Abstraktion in PHP. symfony/clock ist die ausgereifte Bibliothek, die diesen Standard implementiert. Für UUIDs ist das Interface-Pattern genauso sauber und leicht implementierbar. Flaky Tests, die von nicht-deterministischem Zustand abhängen, sind ein direkter Hinweis darauf, dass dieser Zustand noch nicht ausreichend abstrahiert ist – sie sind damit auch ein Architekturkritikum.
Zeit, UUIDs und Zufall in Tests kontrollieren — Das Wichtigste auf einen Blick
PSR-20 Clock Interface
Niemals new DateTime() direkt. Immer ClockInterface injizieren. FrozenClock für Tests. symfony/clock ist die ausgereifte Bibliothek dafür.
UUID-Abstraktion
UuidGeneratorInterface kapselt Uuid::uuid4(). SequentialUuidGenerator im Test gibt ...0001, ...0002, usw. zurück – deterministisch und lesbar.
Flaky Tests beheben
Flaky Tests sind Architekturkritik. Kein sleep(), keine Toleranzbereiche als Lösung. Produktionscode refaktorieren, um nicht-deterministischen Zustand zu kapseln.
Magento-Spezifika
Magento-interne Datums-Helfer nicht in eigenen Services. In Fixtures explizite Datumsangaben statt "jetzt". PSR-20 für alle neuen Module verwenden.
11. FAQ: Zeit, UUIDs und Zufall in PHPUnit-Tests kontrollieren
1Was sind flaky Tests und warum entstehen sie?
time(), new DateTime(), zufällige UUIDs oder random_int() ohne Interface-Abstraktion.2Was ist das Clock-Interface-Pattern?
new DateTime() direkt. Produktionscode: SystemClock. Test: FrozenClock mit vordefinierten Zeitstempel. PSR-20 standardisiert das Interface.3Was ist PSR-20?
now(): DateTimeImmutable. Framework-übergreifend kompatibel – Symfony, Laravel, Magento ohne Anpassung nutzbar.4UUID-Generierung deterministisch machen?
...0001, ...0002 zurück – reproduzierbar und in Assertions prüfbar.5Flaky Tests identifizieren?
--repeat=N führt Tests N-mal aus. Tests nach Mitternacht oder vor Monatswechsel decken zeitabhängige Bugs auf. CI-Trend-Tracking zeigt schwankende Tests über Zeit.6Kann sleep() flaky Tests stabilisieren?
7random_int() in Tests kontrollieren?
random_int(). Im Test: StubRandomness mit vorprogrammierten Werten. Oder: mt_srand(42) + mt_rand() für deterministische Sequenzen.8Was ist symfony/clock?
9Zeitstempel in Magento-Test-Fixtures deterministisch setzen?
'2020-01-01' statt Carbon::now(). Über Repository mit explizit gesetzten Zeitstempeln. FrozenClock über di.xml im Test-Kontext.