Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Testing Messenger

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $10.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Let's spice up our LockDownHelper a bit more, shall we?! When we create a lockdown, instead of sending the email directly, we're going to dispatch a message to Messenger and have it send the email. Start by installing Messenger:

composer require symfony/messenger

Lovely! In .env, this added a MESSENGER_TRANSPORT_DSN which, by default, uses the Doctrine transport type. Though, it won't matter which transport type you use - Doctrine, Redis, whatever. As you'll see, in the test environment, we'll override this completely.

42 lines .env
... lines 1 - 39
... lines 41 - 42

Setting up the Test Environment Transport

To make testing easier, let's also require another package from, you guessed it, Zenstruck!

composer require zenstruck/messenger-test --dev

Cool! This messenger-test library adds a special Messenger transport called test. We'll still use Doctrine by default, but now open up config/packages/messenger.yaml. Uncomment the async transport, which uses MESSENGER_TRANSPORT_DSN. Below, under when@test, we override the async transport and set it to the in-memory type. Oh, and I need to get rid of one extra space. Perfect!

... lines 3 - 5
... line 7
... lines 9 - 15
... lines 20 - 21
async: 'in-memory://'

The in-memory comes from Symfony and it is nice for testing. When it's used, messages are not really sent to a transport, but are stored - in memory - on an object during the test... which you can then use to assert that the message is there.

I like that! But the messenger-test packages gives us something even better. Change this to test://. We'll see what that does in a moment.

... lines 1 - 15
... lines 20 - 21
async: 'test://'

Testing that Messages were Dispatched

Before we dispatch the message inside our code, head into the test. Here, we want to assert that we sent a message to Messenger. And - surprise, surprise - we're going to use another trait. It's called InteractsWithMessenger. Down here, right before we call the method, say $this->transport()->queue()->assertEmpty().

... lines 1 - 12
use Zenstruck\Messenger\Test\InteractsWithMessenger;
... line 14
class LockDownHelperTest extends KernelTestCase
... lines 17 - 18
use InteractsWithMessenger;
... lines 20 - 37
public function testDinoEscapedPersistsLockDown()
... lines 40 - 41
... lines 43 - 49
... lines 51 - 55

Similar to the mailer library, there are a lot of different things about messages that we can check. We're asserting that the queue starts empty, which isn't really necessary - but it's a nice way for us to start. At the end, also assertCount() that 1 message was sent.

... lines 1 - 37
public function testDinoEscapedPersistsLockDown()
... lines 40 - 48
... lines 51 - 57

Let's try this! Keep running all of the tests from LockDownHelper:

symfony php vendor/bin/phpunit tests/Integration/Service/LockDownHelperTest.php

And... it fails with the exact message we wanted!

Expected 1 messages, but 0 messages found.

Creating & Dispatching the Message

Sweet! Generate a Messenger message with:

./bin/console make:message

Call it LockDownStartedNotification and put this into the async transport. Done! This created a message class, a handler class, and also updated messenger.yaml so that this class is sent to the async transport.

... lines 3 - 11
App\Message\LockDownLiftedNotification: async
... lines 14 - 24

Next, waltz into LockDownHelper to dispatch that. On top, add a private MessageBusInterface $messageBus. Then, at the bottom, say $this->messageBus->dispatch(new LockDownStartedNotification()).

... lines 1 - 10
use Symfony\Component\Messenger\MessageBusInterface;
... lines 12 - 13
class LockDownHelper
public function __construct(
... lines 17 - 20
... lines 24 - 37
public function dinoEscaped(): void
... lines 40 - 44
$this->messageBus->dispatch(new LockDownLiftedNotification());

The handler for this class, if we look in src/MessageHandler/LockDownStartedNotification.php, isn't doing anything yet. But this should be enough to get our test to pass.

... lines 1 - 7
final class LockDownLiftedNotificationHandler
public function __invoke(LockDownLiftedNotification $message)
// do something with your message
symfony php vendor/bin/phpunit tests/Integration/Service/LockDownHelperTest.php

And... whoops! A gremlin sneaked into my code! I added the code inside endCurrentLockDown() instead of dinoEscaped(). And that's why we have tests people. When we try again... got it.

Let's move all the mailing logic out of this class. Copy the private method, delete where we call it, the MailerInterface... and even the old use statements.

Open the handler, paste the private method there and hit "OK" to re-add those use statements. Then say $this->sendEmailAlert().

... lines 1 - 9
final class LockDownLiftedNotificationHandler
public function __construct(private MailerInterface $mailer)
public function __invoke(LockDownLiftedNotification $message)
private function sendEmailAlert(): void
$email = (new Email())
->subject('PARK LOCKDOWN')

Cool! Everything should still work fine... except that the test fails:

Expected 1 emails to be sent, but 0 emails were sent.

Processing Messages in your Test

Hmm. If this were production, when we dispatch this message to the async transport, it would not send the email immediately. It will be sent to a queue and processed later. And, the test transport we're using works a lot like a true queue. It receives the message, but doesn't automatically handle it, which is cool. This means that, over in our test, we are dispatching this message... but the email is never sent because it's still waiting to be processed.

What you do here is up to you. Maybe you're cool just knowing that the message was sent.

Or you might want to be a bit more hands-on and say:

No way! I want full proof that when this message is handled, it sends an email.

We can do that by telling the test transport to process its messages. Copy those two mailer() lines and delete them. Down here, say $this->transport()->process().

... lines 1 - 14
class LockDownHelperTest extends KernelTestCase
... lines 17 - 37
public function testDinoEscapedPersistsLockDown()
... lines 40 - 47
$this->mailer()->assertEmailSentTo('staff@dinotopia.com', 'PARK LOCKDOWN');
... lines 53 - 57

That's it! That will execute the handler for any messages in its queue. Below that, the email should be sent.

Try it:

symfony php vendor/bin/phpunit tests/Integration/Service/LockDownHelperTest.php

And... it fails. Another bug! Why wasn't it sent? Because I was too quick with my handler: there is no $this->mailer property. I'm actually surprised that we didn't get a bigger error inside our test.

To fix this, add public function __construct(private MailerInterface $mailer). That looks better! And if we try that again... it passes.

And we can shorten things! Instead of assertCount(1) and ->process(), we can say processOrFail(). This method makes sure that there's at least one message to process, and then processes it.

... lines 1 - 37
public function testDinoEscapedPersistsLockDown()
... lines 40 - 46
... lines 48 - 49
... lines 51 - 57

Double-check the test:

symfony php vendor/bin/phpunit tests/Integration/Service/LockDownHelperTest.php

Got it!

We did team! Our Dinotopia application is dangerous and well-tested, thanks to unit and integration tests. In the next tutorial in this series, we'll turn to the final type of testing - functional testing - where you effectively control a browser, navigate to pages and check what's on them. It's fun and can also be used to check JavaScript behavior.

Alright friends, see ya next time.

Leave a comment!

Login or Register to join the conversation
kbond Avatar
kbond Avatar kbond | posted 3 days ago | HIGHLIGHTED

Regarding the lack of a larger exception when the test failed (because the mailer wasn't injected into the handler): by default, messenger-test swallows exceptions when processing. This behavior can be adjusted: https://github.com/zenstruck/messenger-test#processing-exceptions

1 Reply

Hey Kevin!

Thank you for this tip!


Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial uses PHPUnit 9 but works just fine for PHPUnit 10.

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=8.1.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "doctrine/doctrine-bundle": "^2.8", // 2.10.2
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.4
        "doctrine/orm": "^2.14", // 2.16.2
        "symfony/asset": "6.3.*", // v6.3.0
        "symfony/console": "6.3.*", // v6.3.4
        "symfony/dotenv": "6.3.*", // v6.3.0
        "symfony/flex": "^2", // v2.3.3
        "symfony/framework-bundle": "6.3.*", // v6.3.5
        "symfony/http-client": "6.3.*", // v6.3.5
        "symfony/mailer": "6.3.*", // v6.3.5
        "symfony/messenger": "6.3.*", // v6.3.5
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/runtime": "6.3.*", // v6.3.2
        "symfony/security-csrf": "6.3.*", // v6.3.2
        "symfony/twig-bundle": "6.3.*", // v6.3.0
        "symfony/yaml": "6.3.*" // v6.3.3
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.4
        "phpunit/phpunit": "^9.5", // 9.6.13
        "symfony/browser-kit": "6.3.*", // v6.3.2
        "symfony/css-selector": "6.3.*", // v6.3.2
        "symfony/debug-bundle": "6.3.*", // v6.3.2
        "symfony/maker-bundle": "^1.48", // v1.51.1
        "symfony/phpunit-bridge": "^6.2", // v6.3.2
        "symfony/stopwatch": "6.3.*", // v6.3.0
        "symfony/web-profiler-bundle": "6.3.*", // v6.3.2
        "zenstruck/foundry": "^1.35", // v1.35.0
        "zenstruck/mailer-test": "^1.3", // v1.3.0
        "zenstruck/messenger-test": "^1.7" // v1.7.3