Cronjobs in Magento isoliert mit PHPUnit testen
AI generated
@test
assert
PHPUnit · Magento · Cron
Cronjobs in Magento isoliert testen
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.

14 Min. Lesezeit Cron crontab.xml Scheduler Mocking

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

11. FAQ: Cronjobs testen: Das Wichtigste auf einen Blick

1Muss ich bin/magento cron:run in Tests ausfuehren?
Nein, die Job-Klasse kann direkt instanziiert und ihre execute-Methode aufgerufen werden. Das ist schneller und unabhaengig davon, ob der Job gerade faellig ist.
2Wie teste ich Logik, die von der aktuellen Systemzeit abhaengt?
Ueber ein injizierbares Uhr-Interface, das die aktuelle Zeit liefert. Im Test wird ein Mock mit einer festen Zeit verwendet, sodass Grenzwertfaelle wie Ablaufdaten reproduzierbar geprueft werden koennen.
3Wie pruefe ich, ob die crontab.xml korrekt konfiguriert ist?
Mit einem einfachen Test, der die XML-Datei einliest und per XPath prueft, ob der erwartete Cron-Ausdruck und die erwartete Klassen- und Methodenreferenz fuer den Job vorhanden sind.
4Was passiert, wenn ein Cronjob eine Exception wirft?
Ohne eigene Fehlerbehandlung kann das nachfolgende Jobs derselben Gruppe blockieren. Deshalb sollte die Job-Klasse Exceptions selbst abfangen, loggen und execute() sauber zurueckkehren lassen.
5Wie teste ich Locking-Mechanismen gegen ueberlappende Cron-Laeufe?
Der LockManager wird gemockt und liefert im Test vor, dass ein Lock bereits gehalten wird. Geprueft wird dann, dass die eigentliche Verarbeitung uebersprungen wird.
6Braucht jeder Cronjob einen eigenen Service fuer die Geschaeftslogik?
Fuer trivial kleine Jobs ist das nicht zwingend, aber sobald echte Entscheidungslogik enthalten ist, verbessert die Trennung die Testbarkeit erheblich und macht die Logik wiederverwendbar.
7Wie gehe ich mit Batch-Verarbeitung in Cronjobs testtechnisch um?
Man prueft mit einer bestimmten vorgegebenen Datensatzmenge, ob die Batch-Groesse korrekt eingehalten wird, etwa indem man die Anzahl der Aufrufe an den verarbeitenden Service zaehlt.
8Lohnt sich ein Integrationstest fuer Cronjobs ueberhaupt noch?
Ja, aber in reduziertem Umfang: ein seltener laufender manueller Smoke-Test, der bin/magento cron:run tatsaechlich ausfuehrt, faengt Probleme ab, die reine Unit-Tests nicht abdecken koennen, etwa DI-Konfigurationsfehler.
9Wie stelle ich sicher, dass der Cron-Ausdruck tatsaechlich zur gewuenschten Frequenz passt?
Der Ausdruck selbst laesst sich am besten durch einen Konfigurationstest gegen die erwartete Zeichenkette pruefen, waehrend die Interpretation des Crontab-Formats dem Magento-Scheduler ueberlassen bleibt und nicht neu getestet werden muss.
10Was ist der haeufigste Fehler bei ungetesteten Cronjobs?
Direkte Zugriffe auf die Systemzeit oder den ObjectManager innerhalb der Job-Klasse, die eine saubere Isolation im Test verhindern und dazu fuehren, dass Fehler erst produktiv nachts auffallen.