Verarbeitungslogik von der Queue-Infrastruktur trennen und schnell absichern
Magento-Consumer laufen asynchron gegen RabbitMQ, doch die eigentliche Verarbeitungslogik dahinter laesst sich vollstaendig synchron und ohne laufenden Broker testen, wenn man Consumer-Klasse und Domaenenlogik sauber trennt.
Inhaltsverzeichnis
- 1. Warum Queue-Consumer besondere Testherausforderungen sind
- 2. Consumer als duenner Adapter, Verarbeitung als eigener Service
- 3. Den Processor vollstaendig synchron testen
- 4. Idempotenz und Wiederholungen gezielt testen
- 5. Die Publisher-Seite genauso entkoppelt testen
- 6. Wo Integrationstests trotzdem noch ihren Platz haben
- 7. Fehlerklassen und Retry-Strategien differenziert abbilden
- 8. Mehrere Nachrichtenvarianten mit Data Providern abdecken
- 9. Checkliste fuer testbare Consumer-Architekturen
- 10. Zusammenfassung
- 11. FAQ
1. Warum Queue-Consumer besondere Testherausforderungen sind
Message-Queue-Consumer in Magento werden ueber queue_consumer.xml deklariert und von der MessageQueue-Infrastruktur asynchron aufgerufen, sobald eine Nachricht im konfigurierten RabbitMQ-Topic eintrifft. Diese Kopplung an einen externen Broker macht Consumer auf den ersten Blick schwer testbar: Ein klassischer Integrationstest muesste eine laufende RabbitMQ-Instanz voraussetzen, eine Nachricht publizieren und dann asynchron pruefen, ob der Consumer sie korrekt verarbeitet hat. Das ist langsam, fragil und in einer CI-Pipeline ohne Broker-Container gar nicht praktikabel.
Der Schluessel liegt darin, den Consumer selbst nicht als monolithische Blackbox zu betrachten, sondern als duennen Adapter, der eine Nachricht entgegennimmt und an eine reine PHP-Verarbeitungsklasse weiterreicht. Die Queue-Anbindung, also das Deserialisieren der Nachricht, das Acknowledgen und das Fehler-Handling bei Exceptions, bleibt eine sehr duenne Schicht. Die eigentliche Geschaeftslogik, zum Beispiel das Aktualisieren eines Lagerbestands oder das Versenden einer Benachrichtigung, wandert in einen separaten Service, der voellig unabhaengig von RabbitMQ getestet werden kann.
2. Consumer als duenner Adapter, Verarbeitung als eigener Service
In der Praxis bedeutet das: Die Consumer-Klasse, die in queue_consumer.xml referenziert wird, bekommt im Konstruktor einen ProcessorInterface injiziert und delegiert im execute()-Aufruf ausschliesslich an diesen. Sie selbst enthaelt keine Business-Logik, sondern hoechstens das Mapping von rohen Nachrichtendaten auf ein DTO sowie das Weiterreichen von Exceptions an das Queue-Framework, damit Retry- und Dead-Letter-Mechanismen greifen. Diese strikte Trennung folgt dem gleichen Prinzip wie bei Controllern: duenne Eingangsschicht, fette Domaenenschicht dahinter.
Der eigentliche Processor kennt weder Magento\Framework\MessageQueue noch irgendeine RabbitMQ-Klasse. Er nimmt ein einfaches Datenobjekt oder primitive Werte entgegen und arbeitet mit injizierten Repositories und Services. Dadurch wird er zu einem ganz normalen PHP-Objekt, das sich exakt so testen laesst wie jeder andere Service auch: Abhaengigkeiten mocken, Methode aufrufen, Ergebnis und Seiteneffekte pruefen. Der komplette Test laeuft synchron in Millisekunden und benoetigt keinerlei Netzwerkverbindung.
<?php
declare(strict_types=1);
namespace Mironsoft\OrderSync\Model\Queue;
use Magento\Framework\MessageQueue\CallbackInvokerInterface;
use Mironsoft\OrderSync\Model\OrderSyncProcessor;
/**
* Duenner Adapter zwischen RabbitMQ und der eigentlichen Verarbeitungslogik.
*/
class OrderSyncConsumer
{
public function __construct(
private readonly OrderSyncProcessor $processor
) {
}
/**
* Wird von der MessageQueue-Infrastruktur pro eingehender Nachricht aufgerufen.
*
* @param string $orderIncrementId
* @return void
*/
public function process(string $orderIncrementId): void
{
$this->processor->execute($orderIncrementId);
}
}
3. Den Processor vollstaendig synchron testen
Sobald die Verarbeitungslogik in einer eigenen Klasse steckt, sieht der Test genauso aus wie bei jedem anderen Service: Repository-Mock fuer das Laden der Bestellung, Mock fuer den externen Client, der die Synchronisation durchfuehrt, und Assertions auf die erwarteten Methodenaufrufe. Es gibt keine Warteschlange, keine asynchrone Ausfuehrung und keinen Timing-bedingten Flakiness-Faktor, der klassische Queue-Integrationstests ploetzlich mal gruen und mal rot macht.
Wichtig ist, dass der Test auch den Fehlerfall abdeckt: Wirft der externe Client eine Exception, muss der Processor sie entweder sauber weiterreichen, damit der Consumer sie an das Queue-Framework durchreicht und ein Retry ausgeloest wird, oder er faengt sie bewusst ab und markiert die Bestellung als fehlgeschlagen. Beide Verhaltensweisen lassen sich in PHPUnit exakt spezifizieren, ohne dass jemals eine echte Nachricht durch RabbitMQ laufen muss.
<?php
declare(strict_types=1);
namespace Mironsoft\OrderSync\Test\Unit\Model;
use Mironsoft\OrderSync\Api\ExternalSyncClientInterface;
use Mironsoft\OrderSync\Model\OrderSyncProcessor;
use Magento\Sales\Api\Data\OrderInterface;
use Magento\Sales\Api\OrderRepositoryInterface;
use PHPUnit\Framework\TestCase;
class OrderSyncProcessorTest extends TestCase
{
public function testSuccessfulSyncCallsExternalClientWithOrderData(): void
{
$order = $this->createMock(OrderInterface::class);
$order->method('getIncrementId')->willReturn('100000123');
$repository = $this->createMock(OrderRepositoryInterface::class);
$repository->method('get')->willReturn($order);
$client = $this->createMock(ExternalSyncClientInterface::class);
$client->expects($this->once())
->method('push')
->with('100000123');
$processor = new OrderSyncProcessor($repository, $client);
$processor->execute('100000123');
}
public function testClientExceptionIsPropagatedForRetry(): void
{
$this->expectException(\RuntimeException::class);
$order = $this->createMock(OrderInterface::class);
$repository = $this->createMock(OrderRepositoryInterface::class);
$repository->method('get')->willReturn($order);
$client = $this->createMock(ExternalSyncClientInterface::class);
$client->method('push')->willThrowException(new \RuntimeException('API down'));
$processor = new OrderSyncProcessor($repository, $client);
$processor->execute('100000123');
}
}
4. Idempotenz und Wiederholungen gezielt testen
Ein zentrales Merkmal von Message-Queue-Verarbeitung ist, dass Nachrichten unter bestimmten Umstaenden mehrfach zugestellt werden koennen, etwa wenn der Consumer nach erfolgreicher Verarbeitung, aber vor dem Acknowledge abstuerzt. Deshalb muss die Verarbeitungslogik idempotent sein: Ein zweiter Aufruf mit derselben Nachricht darf keinen doppelten Effekt erzeugen, etwa keine doppelte E-Mail oder keinen doppelten Lagerabzug.
Dieses Verhalten laesst sich gezielt testen, indem man den Processor zweimal mit identischen Eingabedaten aufruft und prueft, dass der zweite Aufruf entweder fruehzeitig abbricht, weil ein Status bereits gesetzt ist, oder dass der externe Aufruf idempotent gestaltet ist. Ein Test, der bewusst einen bereits verarbeiteten Zustand simuliert, deckt Regressionsfaelle auf, die in der Praxis erst bei einem Netzwerk-Hickup live auffallen wuerden.
<?php
declare(strict_types=1);
public function testSecondCallWithAlreadySyncedOrderIsSkipped(): void
{
$order = $this->createMock(OrderInterface::class);
$order->method('getData')->with('sync_status')->willReturn('synced');
$repository = $this->createMock(OrderRepositoryInterface::class);
$repository->method('get')->willReturn($order);
$client = $this->createMock(ExternalSyncClientInterface::class);
$client->expects($this->never())->method('push');
$processor = new OrderSyncProcessor($repository, $client);
$processor->execute('100000123');
}
5. Die Publisher-Seite genauso entkoppelt testen
Auf der Sender-Seite verhaelt es sich spiegelbildlich: Ein Observer oder Plugin, der eine Nachricht in die Queue schreibt, sollte niemals direkt gegen den echten PublisherInterface von Magento aufgerufen werden, sondern gegen das Interface mocken. Der Test prueft dann nur, dass mit den korrekten Nachrichtendaten und dem korrekten Topic-Namen publiziert wurde, nicht, was danach mit der Nachricht passiert.
Diese Trennung erlaubt es, Publisher-Tests und Consumer-Tests komplett unabhaengig voneinander zu pflegen. Aendert sich das Nachrichtenformat, schlagen beide Tests unabhaengig fehl und zeigen exakt, an welcher Stelle die Vertragsverletzung liegt, statt dass ein einziger End-to-End-Test undurchsichtig rot wird und stundenlange Fehlersuche ausloest.
<?php
declare(strict_types=1);
public function testOrderPlacedEventPublishesToOrderSyncTopic(): void
{
$publisher = $this->createMock(PublisherInterface::class);
$publisher->expects($this->once())
->method('publish')
->with('mironsoft.ordersync.topic', '100000123');
$observer = new OrderPlacedPublisher($publisher);
$observer->execute($this->createMock(Observer::class));
}
6. Wo Integrationstests trotzdem noch ihren Platz haben
Unit-Tests decken die Verarbeitungslogik ab, ersetzen aber nicht jeden Test rund um die Queue-Konfiguration selbst. Ob queue_topology.xml, queue_consumer.xml und queue_publisher.xml korrekt zusammenspielen, also ob ein Topic tatsaechlich an die richtige Queue gebunden ist und der Consumer-Name mit der Konfiguration uebereinstimmt, ist eine reine Konfigurationsfrage und laesst sich nicht sinnvoll per Unit-Test pruefen.
Fuer diese Konfigurationsebene reicht in der Regel ein schlanker manueller oder halbautomatisierter Rauchtest mit einer echten RabbitMQ-Instanz in einer Staging-Umgebung, der einmal pro Release durchlaeuft. Die Menge an Testfaellen dort bleibt bewusst klein, waehrend die Verarbeitungslogik mit vielen kleinen, schnellen Unit-Tests engmaschig abgedeckt wird. Diese Balance verhindert, dass die Test-Suite durch instabile Queue-Integrationstests unzuverlaessig wird.
7. Fehlerklassen und Retry-Strategien differenziert abbilden
Nicht jeder Fehler in einem Consumer soll zu einem Retry fuehren. Ein voruebergehender Netzwerkfehler beim externen API-Aufruf rechtfertigt einen erneuten Versuch, waehrend eine ungueltige Bestell-ID, die dauerhaft nicht existiert, bei jedem weiteren Versuch erneut fehlschlagen wuerde und die Nachricht besser direkt in eine Dead-Letter-Queue verschoben wird. Diese Unterscheidung sollte im Code durch unterschiedliche Exception-Typen abgebildet werden, etwa eine TransientException gegenueber einer PermanentException.
In PHPUnit laesst sich fuer jede Fehlerklasse ein eigener Testfall schreiben, der genau pruft, welche Exception unter welchen Bedingungen geworfen wird. So wird sichergestellt, dass ein zukuenftiger Refactoring-Schritt nicht versehentlich eine permanente Fehlermeldung in eine wiederholbare umwandelt und dadurch Endlosschleifen von Retries in der Produktion erzeugt.
<?php
declare(strict_types=1);
public function testMissingOrderThrowsPermanentException(): void
{
$this->expectException(PermanentSyncException::class);
$repository = $this->createMock(OrderRepositoryInterface::class);
$repository->method('get')->willThrowException(new NoSuchEntityException());
$processor = new OrderSyncProcessor($repository, $this->createMock(ExternalSyncClientInterface::class));
$processor->execute('unknown-id');
}
8. Mehrere Nachrichtenvarianten mit Data Providern abdecken
Queue-Nachrichten kommen selten in nur einer Form daher: unterschiedliche Bestellstatus, fehlende optionale Felder oder unterschiedliche Store-Views fuehren zu leicht unterschiedlichem Verhalten im Processor. Statt fuer jede Variante eine eigene Testmethode zu schreiben, bietet sich ein PHPUnit-Data-Provider an, der die verschiedenen Eingangs-Payloads systematisch durchspielt.
Dieser Ansatz macht die Test-Suite gleichzeitig kompakter und vollstaendiger, weil neue Varianten einfach als zusaetzliche Zeile im Data Provider ergaenzt werden koennen, ohne die eigentliche Testlogik zu duplizieren. Bei einem Review faellt sofort auf, welche Nachrichtenvarianten bereits abgedeckt sind und welche noch fehlen.
<?php
declare(strict_types=1);
/**
* @dataProvider orderStatusProvider
*/
public function testProcessorHandlesDifferentOrderStatuses(string $status, bool $shouldSync): void
{
$order = $this->createMock(OrderInterface::class);
$order->method('getStatus')->willReturn($status);
$repository = $this->createMock(OrderRepositoryInterface::class);
$repository->method('get')->willReturn($order);
$client = $this->createMock(ExternalSyncClientInterface::class);
$client->expects($shouldSync ? $this->once() : $this->never())->method('push');
(new OrderSyncProcessor($repository, $client))->execute('100000123');
}
public static function orderStatusProvider(): array
{
return [
'processing order syncs' => ['processing', true],
'canceled order is skipped' => ['canceled', false],
'pending order is skipped' => ['pending', false],
];
}
9. Checkliste fuer testbare Consumer-Architekturen
Wer neue Queue-Consumer in Magento plant, sollte von Anfang an auf die Trennung von Adapter und Verarbeitungslogik achten. Der Consumer selbst bleibt so schlank, dass er kaum eigene Tests benoetigt, waehrend die eigentliche Logik als normaler, gut testbarer Service entsteht. Diese Architekturentscheidung zahlt sich vor allem dann aus, wenn spaeter weitere Consumer fuer aehnliche Anwendungsfaelle entstehen und die Testinfrastruktur wiederverwendet werden kann.
In der folgenden Tabelle sind die zentralen Testebenen fuer asynchronen Code in Magento gegenuebergestellt, damit klar wird, welche Ebene welchen Zweck erfuellt und mit welchem Aufwand sie verbunden ist.
| Testebene | Was geprueft wird | Benoetigt RabbitMQ | Typische Ausfuehrungszeit |
|---|---|---|---|
| Processor-Unit-Test | Verarbeitungslogik, Fehlerfaelle, Idempotenz | Nein | Millisekunden |
| Publisher-Unit-Test | Korrektes Topic und korrekte Payload beim Senden | Nein | Millisekunden |
| Queue-Konfigurationstest | Topology, Bindings, Consumer-Namen | Ja (Staging) | Sekunden bis Minuten |
| End-to-End-Rauchtest | Gesamter Fluss von Publish bis Verarbeitung | Ja | 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
Queue-Consumer testen: Das Wichtigste auf einen Blick
Trennung
Consumer als duenner Adapter, Verarbeitungslogik als eigener Service ohne Queue-Abhaengigkeit
Geschwindigkeit
Unit-Tests fuer Processor laufen synchron in Millisekunden ohne laufenden Broker
Idempotenz
Wiederholte Nachrichten muessen gezielt getestet werden, um doppelte Effekte zu verhindern
Fehlerklassen
Transiente und permanente Fehler klar unterscheiden, um Retry-Verhalten korrekt zu testen