Die Job-Klasse direkt aufrufen statt ueber bin/magento cron:run zu testen
Magento-Cronjobs muessen nicht ueber die vollstaendige Cron-Ausfuehrung getestet werden: Ruft man die Job-Klasse direkt auf und trennt Zeitsteuerung von Geschaeftslogik, laesst sich jeder Cronjob als gewoehnlicher PHPUnit-Test behandeln.
Inhaltsverzeichnis
- 1. Warum Cronjobs oft ungetestet bleiben
- 2. Die Job-Klasse als normale, aufrufbare PHP-Klasse gestalten
- 3. Den Cronjob per direktem Methodenaufruf testen
- 4. Zeitabhaengige Geschaeftslogik ueber ein injizierbares Uhr-Interface testen
- 5. Was auf der Scheduler-Ebene ueberhaupt noch getestet werden muss
- 6. Fehlerbehandlung im Job so gestalten, dass ein Fehler nicht den ganzen Scheduler blockiert
- 7. Wiederkehrende Cron-Patterns wie Batch-Verarbeitung und Locking testen
- 8. Store-abhaengige Konfiguration innerhalb des Jobs testen
- 9. Checkliste fuer testbare Cronjob-Architekturen
- 10. Zusammenfassung
- 11. FAQ
1. Warum Cronjobs oft ungetestet bleiben
Magento-Cronjobs werden in crontab.xml deklariert und von bin/magento cron:run ueber den internen Scheduler ausgefuehrt, der Eintraege in der Tabelle cron_schedule verwaltet, Zeitplaene per Crontab-Ausdruck ausliest und faellige Jobs anstoesst. Wer versucht, einen Cronjob End-to-End zu testen, muesste den Scheduler simulieren, Datenbankzeilen in cron_schedule manipulieren und pruefen, ob der Job zur richtigen Zeit ausgeloest wird. Dieser Aufwand fuehrt in der Praxis oft dazu, dass Cronjobs schlicht ungetestet bleiben und Fehler erst produktiv auffallen, meist nachts, wenn niemand hinschaut.
Dabei ist die Zeitsteuerung, also wann ein Job ausgefuehrt wird, fast immer voellig unabhaengig davon, was der Job inhaltlich tut. Ein Cronjob, der abgelaufene Warenkoerbe bereinigt, hat eine Ausfuehrungslogik, die man genauso gut per direktem Methodenaufruf pruefen kann, ganz ohne den Scheduler zu bemuehen. Die Erkenntnis, dass sich Zeitsteuerung und Geschaeftslogik trennen lassen, ist der zentrale Hebel fuer testbare Cronjobs.
2. Die Job-Klasse als normale, aufrufbare PHP-Klasse gestalten
Jede in crontab.xml referenzierte Cron-Klasse implementiert eine execute()-Methode ohne Parameter, die vom Scheduler ohne weiteren Kontext aufgerufen wird. Damit diese Methode testbar bleibt, sollte sie so schlank wie moeglich sein und alle benoetigten Abhaengigkeiten ueber den Konstruktor per Dependency Injection erhalten, statt sie sich selbst per ObjectManager zu beschaffen. Das macht die Klasse zu einem ganz gewoehnlichen Service, den man in PHPUnit ohne Magento-Bootstrap instanziieren kann.
Wichtig ist zudem, dass die execute()-Methode selbst keine komplexe Verzweigungslogik enthaelt, sondern lediglich orchestriert: Daten laden, an einen Service delegieren, Ergebnis loggen. Die eigentliche fachliche Entscheidung, etwa welche Warenkoerbe als abgelaufen gelten, wandert in eine eigene, leicht testbare Klasse. So wird der Cronjob selbst zu einem duennen Wrapper und die komplexere Logik dahinter bleibt unabhaengig von der Cron-Infrastruktur.
<?php
declare(strict_types=1);
namespace Mironsoft\CartCleanup\Cron;
use Mironsoft\CartCleanup\Model\ExpiredCartCleaner;
use Psr\Log\LoggerInterface;
/**
* Cronjob-Einstiegspunkt, delegiert vollstaendig an den Cleaner-Service.
*/
class CleanExpiredCarts
{
public function __construct(
private readonly ExpiredCartCleaner $cleaner,
private readonly LoggerInterface $logger
) {
}
/**
* Wird vom Magento-Scheduler gemaess crontab.xml aufgerufen.
*
* @return void
*/
public function execute(): void
{
$removed = $this->cleaner->removeExpiredCarts();
$this->logger->info(sprintf('Removed %d expired carts', $removed));
}
}
3. Den Cronjob per direktem Methodenaufruf testen
Statt bin/magento cron:run in einem Test-Prozess zu starten, was Scheduler, Datenbank und volle Bootstrap-Zeit erfordern wuerde, wird die Job-Klasse in PHPUnit einfach direkt instanziiert und execute() aufgerufen. Der Logger und der Cleaner-Service werden gemockt, sodass der Test nur pruefen muss, ob die Orchestrierung korrekt ist: Wird der Cleaner aufgerufen und wird das Ergebnis korrekt geloggt.
Dieser Test laeuft in Millisekunden und ist voellig unabhaengig davon, ob der Cronjob gerade faellig ist, ob die crontab.xml korrekt eingelesen wurde oder ob der Scheduler ueberhaupt laeuft. Genau diese Unabhaengigkeit ist der entscheidende Vorteil gegenueber einem Versuch, den echten Scheduler in einem Test zu simulieren.
<?php
declare(strict_types=1);
namespace Mironsoft\CartCleanup\Test\Unit\Cron;
use Mironsoft\CartCleanup\Cron\CleanExpiredCarts;
use Mironsoft\CartCleanup\Model\ExpiredCartCleaner;
use Psr\Log\LoggerInterface;
use PHPUnit\Framework\TestCase;
class CleanExpiredCartsTest extends TestCase
{
public function testExecuteCallsCleanerAndLogsResult(): void
{
$cleaner = $this->createMock(ExpiredCartCleaner::class);
$cleaner->expects($this->once())
->method('removeExpiredCarts')
->willReturn(7);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())
->method('info')
->with('Removed 7 expired carts');
$job = new CleanExpiredCarts($cleaner, $logger);
$job->execute();
}
}
4. Zeitabhaengige Geschaeftslogik ueber ein injizierbares Uhr-Interface testen
Ein haeufiges Problem bei zeitgesteuerten Jobs ist, dass die Geschaeftslogik selbst auf die aktuelle Systemzeit zugreift, etwa ueber new DateTime() oder time(). Das macht Tests nicht deterministisch, weil das Ergebnis vom Ausfuehrungszeitpunkt abhaengt. Die Loesung ist ein injizierbares Zeit-Interface, ueber das die aktuelle Zeit abgefragt wird, sodass der Test eine feste, kontrollierte Zeit vorgeben kann.
Mit einem solchen ClockInterface laesst sich exakt pruefen, ob beispielsweise ein Warenkorb, der vor genau 31 Tagen erstellt wurde, korrekt als abgelaufen erkannt wird, waehrend ein Warenkorb von vor 29 Tagen nicht geloescht wird. Ohne kontrollierbare Zeit waere ein solcher Grenzwerttest praktisch nicht reproduzierbar, weil er nur an einem einzigen echten Tag im Jahr zufaellig zuverlaessig gruen waere.
<?php
declare(strict_types=1);
/**
* @dataProvider cartAgeProvider
*/
public function testCartIsConsideredExpiredAfterThirtyDays(int $daysOld, bool $expectedExpired): void
{
$clock = $this->createMock(ClockInterface::class);
$clock->method('now')->willReturn(new \DateTimeImmutable('2026-08-07'));
$cart = $this->createMock(CartInterface::class);
$cart->method('getCreatedAt')->willReturn(
(new \DateTimeImmutable('2026-08-07'))->modify("-{$daysOld} days")->format('Y-m-d H:i:s')
);
$cleaner = new ExpiredCartCleaner($clock);
$this->assertSame($expectedExpired, $cleaner->isExpired($cart));
}
public static function cartAgeProvider(): array
{
return [
'29 days old is not expired' => [29, false],
'30 days old is not expired' => [30, false],
'31 days old is expired' => [31, true],
];
}
5. Was auf der Scheduler-Ebene ueberhaupt noch getestet werden muss
Nach der Trennung von Zeitsteuerung und Geschaeftslogik bleibt auf der Cron-Infrastruktur-Ebene nur noch wenig zu pruefen: dass crontab.xml syntaktisch korrekt ist, dass der Job-Name eindeutig ist und dass der Cron-Ausdruck, also die Crontab-Syntax fuer die Ausfuehrungsfrequenz, so formuliert ist, wie beabsichtigt. Diese Aspekte sind reine Konfiguration und lassen sich am besten durch einen schlanken statischen Check pruefen, nicht durch einen laufenden Scheduler-Test.
Ein einfacher Test kann etwa die crontab.xml einlesen und pruefen, ob der erwartete Cron-Ausdruck fuer einen bestimmten Job vorhanden ist. Das deckt Tippfehler in der Konfiguration ab, ohne dass jemals ein echter Scheduler-Lauf simuliert werden muss, und ergaenzt die Unit-Tests der Job-Logik um die fehlende Konfigurationsebene.
<?php
declare(strict_types=1);
public function testCrontabDefinesExpectedScheduleForCleanupJob(): void
{
$xml = simplexml_load_file(__DIR__ . '/../../../etc/crontab.xml');
$job = $xml->xpath("//job[@name='mironsoft_cartcleanup_clean_expired']")[0];
$this->assertSame('0 3 * * *', (string) $job->schedule);
$this->assertSame(
'Mironsoft\CartCleanup\Cron\CleanExpiredCarts::execute',
(string) $job->instance . '::' . (string) $job->method
);
}
6. Fehlerbehandlung im Job so gestalten, dass ein Fehler nicht den ganzen Scheduler blockiert
Ein haeufiger Produktionsfehler ist ein Cronjob, der bei einer unerwarteten Exception den gesamten Cron-Lauf zum Stillstand bringt, weil nachfolgende Jobs in derselben Gruppe nicht mehr ausgefuehrt werden. Deshalb sollte jede Job-Klasse Exceptions aus der eigentlichen Verarbeitung selbst abfangen, loggen und den Job als fehlgeschlagen markieren, statt die Exception ungebremst nach oben durchzureichen.
Dieses Verhalten laesst sich isoliert testen, indem der gemockte Service eine Exception wirft und geprueft wird, dass execute() trotzdem sauber zurueckkehrt, dabei aber den Fehler ueber den Logger protokolliert. Ein solcher Test verhindert, dass ein zukuenftiges Refactoring versehentlich das Exception-Handling entfernt und dadurch die Zuverlaessigkeit des gesamten Cron-Laufs gefaehrdet.
<?php
declare(strict_types=1);
public function testExecuteCatchesExceptionAndLogsErrorInsteadOfThrowing(): void
{
$cleaner = $this->createMock(ExpiredCartCleaner::class);
$cleaner->method('removeExpiredCarts')->willThrowException(new \RuntimeException('DB timeout'));
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())
->method('error')
->with($this->stringContains('DB timeout'));
$job = new CleanExpiredCarts($cleaner, $logger);
$job->execute();
$this->addToAssertionCount(1);
}
7. Wiederkehrende Cron-Patterns wie Batch-Verarbeitung und Locking testen
Viele Cronjobs verarbeiten grosse Datenmengen in Batches, um Speicherverbrauch und Laufzeit zu begrenzen, und nutzen zusaetzlich einen Locking-Mechanismus, um zu verhindern, dass zwei ueberlappende Ausfuehrungen gleichzeitig laufen, falls ein vorheriger Lauf laenger dauert als das Zeitintervall. Beide Aspekte lassen sich unabhaengig voneinander testen: Die Batch-Groesse wird geprueft, indem man dem Cleaner-Service eine bestimmte Anzahl an Datensaetzen vorgibt und die Anzahl der Batch-Aufrufe zaehlt.
Fuer das Locking wird ein LockManagerInterface injiziert und gemockt, sodass ein Test simulieren kann, dass der Lock bereits von einem anderen Lauf gehalten wird. Der erwartete Effekt ist, dass die Verarbeitung uebersprungen wird, ohne einen Fehler zu werfen. Dieses Verhalten in einem klassischen End-to-End-Test zu reproduzieren waere kaum praktikabel, weil dafuer tatsaechlich zwei parallele Prozesse gestartet werden muessten.
<?php
declare(strict_types=1);
public function testExecuteSkipsProcessingWhenLockIsAlreadyHeld(): void
{
$lockManager = $this->createMock(LockManagerInterface::class);
$lockManager->method('isLocked')->with('cartcleanup_lock')->willReturn(true);
$cleaner = $this->createMock(ExpiredCartCleaner::class);
$cleaner->expects($this->never())->method('removeExpiredCarts');
$job = new CleanExpiredCarts($cleaner, $this->createMock(LoggerInterface::class), $lockManager);
$job->execute();
}
8. Store-abhaengige Konfiguration innerhalb des Jobs testen
Viele Cronjobs sollen sich pro Website oder Store unterschiedlich verhalten, etwa weil eine Bereinigungsfunktion nur fuer bestimmte Stores aktiviert ist oder weil die Aufbewahrungsdauer als System-Konfigurationswert hinterlegt ist. Dafuer wird ScopeConfigInterface injiziert und im Test gemockt, sodass verschiedene Konfigurationswerte pro Store simuliert werden koennen, ohne eine echte Datenbank mit Store-Konfiguration aufzubauen.
Ein sauberer Test iteriert dabei ueber mehrere Store-IDs, liefert fuer jede einen anderen gemockten Konfigurationswert zurueck und prueft, dass der Job nur fuer die Stores aktiv wird, bei denen die Einstellung eingeschaltet ist. So lassen sich auch Randfaelle wie ein fehlender oder leerer Konfigurationswert gezielt abdecken, was in einem reinen End-to-End-Test kaum wirtschaftlich waere.
<?php
declare(strict_types=1);
/**
* @dataProvider storeConfigProvider
*/
public function testExecuteRespectsPerStoreEnabledFlag(bool $enabledForStore, bool $shouldRun): void
{
$scopeConfig = $this->createMock(ScopeConfigInterface::class);
$scopeConfig->method('isSetFlag')
->with('mironsoft_cartcleanup/general/enabled', ScopeInterface::SCOPE_STORE, 1)
->willReturn($enabledForStore);
$cleaner = $this->createMock(ExpiredCartCleaner::class);
$cleaner->expects($shouldRun ? $this->once() : $this->never())->method('removeExpiredCarts');
$job = new CleanExpiredCarts($cleaner, $this->createMock(LoggerInterface::class), $scopeConfig);
$job->execute();
}
public static function storeConfigProvider(): array
{
return [
'enabled store runs cleanup' => [true, true],
'disabled store skips cleanup' => [false, false],
];
}
9. Checkliste fuer testbare Cronjob-Architekturen
Wer neue Cronjobs in Magento plant, sollte die Job-Klasse von Anfang an als duennen Wrapper um einen richtigen Service konzipieren, die Systemzeit ueber ein injizierbares Interface beziehen und Fehler kontrolliert abfangen, statt sie durchzureichen. Damit wird jeder einzelne Baustein, von der Orchestrierung bis zur konkreten Geschaeftslogik, isoliert und ohne laufenden Scheduler testbar.
Die folgende Tabelle stellt die verschiedenen Testebenen rund um Cronjobs gegenueber, damit deutlich wird, welche Ebene welchen Zweck erfuellt und wie aufwendig sie in der Wartung ist.
| Testebene | Was geprueft wird | Benoetigt Scheduler | Typische Ausfuehrungszeit |
|---|---|---|---|
| Job-Unit-Test | Orchestrierung, Fehlerbehandlung, Logging | Nein | Millisekunden |
| Geschaeftslogik-Unit-Test | Zeitabhaengige Entscheidungen mit fixierter Uhr | Nein | Millisekunden |
| Crontab-Konfigurationstest | Cron-Ausdruck, Job-Name, Klassenreferenz | Nein | Millisekunden |
| Manueller Scheduler-Smoke-Test | Tatsaechliche Ausfuehrung ueber bin/magento cron:run | Ja | Sekunden bis Minuten |
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
Cronjobs testen: Das Wichtigste auf einen Blick
Trennung
Job-Klasse als duenner Wrapper, Geschaeftslogik als eigener, unabhaengiger Service
Direkter Aufruf
execute() wird in PHPUnit direkt aufgerufen statt ueber bin/magento cron:run
Kontrollierte Zeit
Injizierbares Uhr-Interface macht zeitabhaengige Logik deterministisch testbar
Robustheit
Fehler werden im Job selbst abgefangen, damit ein defekter Job nicht den gesamten Cron-Lauf blockiert