asynchrone Handler ohne echte Warteschlange prüfen
Ein Test, der prüft, ob eine Nachricht auf einem Bus gelandet ist, sagt nichts darüber aus, ob der zugehörige Handler das Richtige tut. Mit InMemoryTransport und TraceableMessageBus lassen sich Symfony Messenger Handler synchron, deterministisch und ohne RabbitMQ oder Redis in der Pipeline testen.
Inhaltsverzeichnis
- 1. Warum der Messenger Bus in Tests eine eigene Strategie braucht
- 2. InMemoryTransport: Nachrichten abfangen statt versenden
- 3. Handler isoliert als Unit testen
- 4. TraceableMessageBus: Dispatch-Aufrufe inspizieren
- 5. Middleware in Tests: Envelope-Stamps prüfen
- 6. Retry- und Failure-Verhalten funktional testen
- 7. Event-getriebene Seiteneffekte über den Bus verifizieren
- 8. Typische Fehler beim Testen von Messenger Handlern
- 9. Teststrategien für den Messenger Bus im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum der Messenger Bus in Tests eine eigene Strategie braucht
Der Symfony Messenger Bus entkoppelt das Auslösen einer Aktion vom eigentlichen Ausführen. Genau diese Entkopplung, die in Produktion Robustheit bringt, macht das Testen komplizierter, weil ein dispatch()-Aufruf in einer echten Anwendung keine sofortige, synchrone Rückmeldung liefert. Ein naiver Test, der nur prüft, dass dispatch() ohne Exception durchläuft, verifiziert weder, dass der richtige Handler aufgerufen wird, noch dass dieser Handler die erwartete Wirkung erzielt.
Ohne eine bewusste Teststrategie für den Symfony Messenger Bus passiert häufig eines von zwei Dingen: Entweder Tests werden gegen eine echte Warteschlange wie RabbitMQ ausgeführt, was CI-Pipelines verlangsamt und externe Infrastruktur voraussetzt, oder Handler werden isoliert getestet, ohne dass jemals geprüft wird, ob die Nachricht überhaupt korrekt auf dem Bus ankommt. Beide Extreme lassen Lücken offen.
Dieser Artikel zeigt, wie InMemoryTransport Nachrichten synchron abfängt, wie TraceableMessageBus Dispatch-Aufrufe für Assertions zugänglich macht, wie man Handler isoliert testet und wie Retry- sowie Failure-Verhalten des Messenger Bus funktional verifiziert wird.
2. InMemoryTransport: Nachrichten abfangen statt versenden
Der InMemoryTransport ist ein von Symfony mitgelieferter Test-Transport, der Nachrichten in einem PHP-Array im Speicher sammelt, statt sie an RabbitMQ, Redis oder eine Datenbank-Queue zu senden. In der Testkonfiguration wird der reale Transport für ein Routing durch InMemoryTransport ersetzt, wodurch dispatch()-Aufrufe synchron zurückkehren und die Nachricht sofort inspizierbar im Transport liegt, ohne dass ein Consumer-Prozess sie erst abholen muss.
Der entscheidende Vorteil: Ein Test kann nach dem dispatch()-Aufruf direkt $transport->getSent() abfragen und prüfen, welche Nachrichten mit welchen Envelope-Stamps tatsächlich geroutet wurden. Das verifiziert das Routing selbst, also ob ein Message-Objekt tatsächlich beim erwarteten Transport landet, ohne den zugehörigen Handler auszuführen. Für Handler-Logik selbst ist ein separater Test nötig, den Abschnitt drei behandelt.
# config/packages/test/messenger.yaml
framework:
messenger:
transports:
async: 'in-memory://'
failed: 'in-memory://'
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Messenger;
use App\Message\SendOrderConfirmationMessage;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;
final class OrderConfirmationRoutingTest extends KernelTestCase
{
public function testMessageIsRoutedToAsyncTransport(): void
{
self::bootKernel();
$container = static::getContainer();
$messageBus = $container->get('messenger.default_bus');
$messageBus->dispatch(new SendOrderConfirmationMessage(orderId: 42));
/** @var InMemoryTransport $transport */
$transport = $container->get('messenger.transport.async');
$sentEnvelopes = $transport->getSent();
self::assertCount(1, $sentEnvelopes);
self::assertInstanceOf(SendOrderConfirmationMessage::class, $sentEnvelopes[0]->getMessage());
}
}
3. Handler isoliert als Unit testen
Ein Messenger-Handler ist im Kern eine gewöhnliche PHP-Klasse mit dem #[AsMessageHandler]-Attribut und einer __invoke()-Methode. Das bedeutet, dass er sich wie jede andere Klasse mit konstruktor-injizierten Abhängigkeiten als reiner Unit-Test testen lässt, komplett ohne Bus, ohne Transport und ohne Symfony-Kernel. Man instanziiert den Handler direkt, übergibt Mock-Objekte für seine Abhängigkeiten und ruft __invoke() mit einer konkreten Nachricht auf.
Diese Trennung ist der wichtigste Hebel für schnelle, wartbare Tests rund um den Messenger Bus: Handler-Logik wird als isolierter Unit-Test geprüft, Bus-Routing wird separat über InMemoryTransport verifiziert. Wer beides in einem einzigen Test vermischt, etwa indem ein voller Kernel gebootet wird, nur um eine einfache Handler-Bedingung zu prüfen, erzeugt unnötig langsame Tests für eine Frage, die ein reiner Unit-Test in Millisekunden beantwortet.
<?php
declare(strict_types=1);
namespace App\Tests\Unit\MessageHandler;
use App\Entity\Order;
use App\Mailer\OrderConfirmationMailer;
use App\Message\SendOrderConfirmationMessage;
use App\MessageHandler\SendOrderConfirmationMessageHandler;
use App\Repository\OrderRepository;
use PHPUnit\Framework\TestCase;
final class SendOrderConfirmationMessageHandlerTest extends TestCase
{
public function testHandlerSendsConfirmationEmail(): void
{
$order = $this->createMock(Order::class);
$order->method('getId')->willReturn(42);
$repository = $this->createMock(OrderRepository::class);
$repository->expects(self::once())
->method('find')
->with(42)
->willReturn($order);
$mailer = $this->createMock(OrderConfirmationMailer::class);
$mailer->expects(self::once())
->method('sendConfirmation')
->with($order);
$handler = new SendOrderConfirmationMessageHandler($repository, $mailer);
$handler(new SendOrderConfirmationMessage(orderId: 42));
}
}
4. TraceableMessageBus: Dispatch-Aufrufe inspizieren
Der TraceableMessageBus deckoriert einen echten MessageBus und protokolliert jeden dispatch()-Aufruf inklusive der übergebenen Stamps und einer eventuellen Exception. Symfony aktiviert diesen dekorierten Bus automatisch, sobald der Symfony Profiler verfügbar ist, was in der Testumgebung meist der Fall ist. Über getDispatchedMessages() lässt sich prüfen, welche Nachrichten in welcher Reihenfolge auf dem Messenger Bus gelandet sind, ohne den Transport-Layer selbst zu inspizieren.
Das ist besonders wertvoll für funktionale Tests, die einen HTTP-Endpunkt aufrufen und anschließend prüfen wollen, ob als Seiteneffekt eine bestimmte Nachricht dispatcht wurde, etwa ein Domain-Event nach einer Bestellung. Statt den kompletten asynchronen Verarbeitungspfad auszuführen, reicht die Assertion, dass die richtige Nachricht mit den richtigen Daten den Messenger Bus erreicht hat.
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Controller;
use App\Message\OrderPlacedEvent;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\Messenger\TraceableMessageBus;
final class OrderControllerTest extends WebTestCase
{
public function testPlacingOrderDispatchesDomainEvent(): void
{
$client = static::createClient();
$client->request('POST', '/api/orders', ['sku' => 'ABC-123', 'qty' => 2]);
self::assertResponseIsSuccessful();
/** @var TraceableMessageBus $bus */
$bus = static::getContainer()->get('debug.event.bus');
$dispatched = $bus->getDispatchedMessages();
self::assertNotEmpty(array_filter(
$dispatched,
static fn (array $entry): bool => $entry['message'] instanceof OrderPlacedEvent
));
}
}
5. Middleware in Tests: Envelope-Stamps prüfen
Symfony Messenger transportiert neben dem eigentlichen Nachrichtenobjekt auch Stamps, etwa DelayStamp für verzögerte Zustellung, RedeliveryStamp für fehlgeschlagene Zustellversuche oder eigene, projektspezifische Stamps für Tracing und Korrelation. Ein Test des Messenger Bus, der nur das Nachrichtenobjekt prüft, aber die Stamps ignoriert, übersieht Fehler in eigener Middleware, die genau diese Stamps setzt oder auswertet.
Mit InMemoryTransport::getSent() lässt sich sowohl das Nachrichtenobjekt als auch die vollständige Stamp-Liste eines Envelopes inspizieren. Das erlaubt gezielte Assertions wie: Wurde ein DelayStamp mit exakt fünf Minuten Verzögerung gesetzt, wenn eine Nachricht wegen Rate-Limiting verschoben werden musste? Solche Prüfungen fangen Middleware-Fehler ab, die sonst erst in Produktion durch unerwartetes Timing auffallen.
6. Retry- und Failure-Verhalten funktional testen
Das Retry-Verhalten des Messenger Bus lässt sich mit InMemoryTransport ebenfalls testen, weil der Transport eine reject()-Methode bereitstellt, die einen fehlgeschlagenen Consume-Versuch simuliert. Kombiniert mit der konfigurierten Retry-Strategie in messenger.yaml lässt sich prüfen, ob eine Nachricht nach einem simulierten Fehler tatsächlich im failed-Transport landet, sobald die maximale Anzahl an Versuchen erreicht ist.
Diese Tests sind funktionale Tests, keine reinen Unit-Tests, weil sie den echten Consumer-Mechanismus von Symfony Messenger durchlaufen, allerdings mit InMemoryTransport statt einer echten Warteschlange. Sie sind besonders wertvoll für kritische Prozesse wie Zahlungsabwicklung, bei denen ein stillschweigend verlorenes Retry-Verhalten teure fachliche Konsequenzen hätte.
7. Event-getriebene Seiteneffekte über den Bus verifizieren
In Symfony Projekten mit Event-Bus-Architektur löst eine Domain-Aktion oft mehrere Handler auf demselben Messenger Bus aus, etwa einen Handler für Benachrichtigungen und einen zweiten für Statistik-Updates. Ein funktionaler Test kann mit TraceableMessageBus prüfen, dass tatsächlich alle erwarteten Handler für ein bestimmtes Event registriert und ausgeführt wurden, nicht nur der erste.
Ein bewährtes Muster ist, in der Testumgebung alle Handler eines Events synchron laufen zu lassen, mit InMemoryTransport als Transport, sodass ein einziger Testaufruf sowohl das Dispatchen des Events als auch die Ausführung aller registrierten Handler abdeckt. Das prüft die vollständige Kette vom auslösenden Event bis zum letzten Seiteneffekt, ohne dabei auf eine echte Warteschlangen-Infrastruktur angewiesen zu sein.
8. Typische Fehler beim Testen von Messenger Handlern
Ein verbreiteter Fehler ist, in Tests des Messenger Bus zu vergessen, dass InMemoryTransport Nachrichten sammelt, aber standardmäßig keine Handler automatisch ausführt. Ohne einen expliziten MessengerTransportListener oder Konsum-Aufruf bleibt eine Nachricht im Transport liegen, während der Test bereits vorbei ist. Wer prüfen will, dass ein Handler tatsächlich lief, muss entweder den Handler separat aufrufen oder die Nachricht explizit konsumieren.
<?php
// WRONG: assumes the handler ran just because dispatch() succeeded
$bus->dispatch(new SendOrderConfirmationMessage(orderId: 42));
// Handler was never invoked — InMemoryTransport only stores the envelope.
// RIGHT: explicitly consume queued messages in the test, or test
// the handler as an isolated unit (see section 3).
$transport = $container->get('messenger.transport.async');
self::assertCount(1, $transport->getSent()); // verifies routing only
Ein zweiter Fehler ist das Vermischen von Verantwortlichkeiten: Ein einzelner Test prüft gleichzeitig Routing, Handler-Logik und Retry-Verhalten, wird dadurch schwer lesbar und bei einem Fehlschlag schwer zu diagnostizieren. Getrennte, fokussierte Tests für jede dieser drei Ebenen des Messenger Bus sind fast immer die bessere Wahl, auch wenn das mehr Testklassen bedeutet.
9. Teststrategien für den Messenger Bus im Vergleich
Je nach Testziel eignet sich eine andere Strategie für den Symfony Messenger Bus am besten. Die folgende Tabelle ordnet die vorgestellten Ansätze nach Anwendungsfall.
| Testziel | Ungeeigneter Ansatz | Empfohlene Strategie | Begründung |
|---|---|---|---|
| Handler-Geschäftslogik | Voller Kernel-Boot | Isolierter Unit-Test des Handlers | Kein Bus nötig, Millisekunden statt Sekunden |
| Routing-Korrektheit | Echte Warteschlange | InMemoryTransport::getSent() | Synchron, keine externe Infrastruktur |
| Seiteneffekte nach HTTP-Aufruf | Manuelles Consumer-Polling | TraceableMessageBus in WebTestCase | Direkter Zugriff auf dispatchte Nachrichten |
| Retry- und Failure-Pfad | Nur Happy-Path testen | reject() auf InMemoryTransport simulieren | Deckt kritische Fehlerpfade ab |
| Mehrere Handler pro Event | Nur ersten Handler testen | TraceableMessageBus, alle Handler synchron | Vollständige Kette statt Teilprüfung |
Die klare Trennung zwischen Handler-Unit-Tests und Bus-Integrationstests bleibt der wichtigste Grundsatz für Tests rund um den Symfony Messenger Bus. Beide Ebenen zusammen ergeben eine vollständige, aber trotzdem schnelle Testabdeckung.
Mironsoft
Symfony Messenger, Event-Architektur und CI-Testing
Asynchrone Prozesse testbar und zuverlässig machen?
Wir strukturieren Messenger-Handler für isolierte Unit-Tests, richten InMemoryTransport-basierte Integrationstests ein und decken Retry- sowie Failure-Pfade ab, bevor sie in Produktion zum Problem werden.
Handler-Refactoring
Geschäftslogik von Bus und Transport entkoppeln für schnelle Unit-Tests
Test-Infrastruktur
InMemoryTransport-Konfiguration und TraceableMessageBus-Assertions
Fehlerpfad-Absicherung
Retry-Strategien und Failure-Transport gezielt testen
10. Zusammenfassung
Der Symfony Messenger Bus braucht eine zweischichtige Teststrategie: Handler-Logik gehört in isolierte Unit-Tests ohne Bus und Kernel, während Routing, Middleware und Retry-Verhalten mit InMemoryTransport und TraceableMessageBus funktional geprüft werden. Diese Trennung verhindert sowohl langsame Tests, die für jede Kleinigkeit einen vollen Kernel booten, als auch blinde Flecken, in denen niemand prüft, ob eine Nachricht überhaupt korrekt geroutet wird.
Der InMemoryTransport ersetzt RabbitMQ oder Redis in der Testumgebung vollständig und macht Nachrichten sofort inspizierbar, ohne Consumer-Prozesse zu starten. Der TraceableMessageBus ergänzt das um Sichtbarkeit auf Dispatch-Ebene, besonders wertvoll für funktionale Tests, die Seiteneffekte nach einem HTTP-Aufruf verifizieren wollen. Wer diese Werkzeuge konsequent einsetzt, bekommt eine Testsuite für den Messenger Bus, die schnell läuft und trotzdem die kritischen Pfade abdeckt.
Symfony Messenger Bus in Tests mocken: Das Wichtigste auf einen Blick
InMemoryTransport
Testkonfiguration mit in-memory:// statt RabbitMQ oder Redis, Nachrichten synchron abfangbar mit getSent().
Handler als Unit-Test
Handler-Klasse direkt instanziieren, Abhängigkeiten mocken, ohne Bus und Kernel testen.
TraceableMessageBus
getDispatchedMessages() für funktionale Tests, die Seiteneffekte nach HTTP-Aufrufen prüfen.
Retry-Pfade nicht vergessen
reject() auf InMemoryTransport simuliert Fehlversuche für Failure-Transport-Tests.