Symfony Messenger Bus in Tests mocken: Async Handler ohne Warteschlange testen
AI generated
SF
{ }
Symfony · Messenger · Testing · PHP 8.4
Symfony Messenger Bus in Tests mocken
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.

18 Min. Lesezeit InMemoryTransport · TraceableMessageBus · Handler-Tests Symfony 7 · PHPUnit 11

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.

11. FAQ: Symfony Messenger Bus in Tests mocken

1Was ist InMemoryTransport?
Ein Test-Transport, der Nachrichten im Speicher sammelt statt an RabbitMQ oder Redis zu senden.
2Fuehrt es den Handler auch aus?
Nicht automatisch. Nur der Envelope wird gespeichert, Handler-Ausführung muss separat geprüft werden.
3Wie teste ich Handler-Logik isoliert?
Handler direkt instanziieren, Abhängigkeiten mocken, __invoke() aufrufen. Kein Bus, kein Kernel nötig.
4Was macht TraceableMessageBus?
Protokolliert jeden dispatch()-Aufruf, abrufbar über getDispatchedMessages().
5Wie pruefe ich Envelope-Stamps?
Mit InMemoryTransport::getSent(), das die vollständigen Envelopes inklusive Stamps zurückliefert.
6Kann ich Retry-Verhalten testen?
Ja, mit reject() auf InMemoryTransport lassen sich fehlgeschlagene Zustellversuche simulieren.
7Brauche ich echte Warteschlangen in CI?
Nein, InMemoryTransport ersetzt RabbitMQ oder Redis vollständig in der Testumgebung.
8Mehrere Handler pro Event pruefen?
Mit TraceableMessageBus und synchroner Ausführung lassen sich alle registrierten Handler verifizieren.
9Haeufigster Fehler beim Testen?
Annehmen, dass der Handler lief, nur weil dispatch() erfolgreich war.
10Alles in einem Test pruefen?
Nein, getrennte fokussierte Tests für Routing, Handler-Logik und Retry-Verhalten sind besser diagnostizierbar.