Test emails with Symfony

Test emails with Symfony

Symfony 4 brought us the awesome Mailer Component. Something that you may not know about it, is how to test if an email has been sent. This is something not well known, but actually easy!

Let's see together how to do it with PHPUnit, but also with Behat - since it's about functional testing more than unit testing, doing it with Behat makes more sense to me.

Here is the controller that we are going to test in the following sections:

/**
 * @Route(path="/sendmail", name="sendmail")
 */
public function index(MailerInterface $mailer): Response
{
    $email = (new Email())
        ->from('[email protected]')
        ->to('[email protected]')
        ->subject('I use Symfony mailer woohoooooo')
        ->text('This is the content of the email')
        ->html('<p>Simple HTML will be ok for this example, but you can obviously use twig for it.</p>');

    $mailer->send($email);

    return $this->json(['message' => 'Simple response that we dont really care here']);
}

Testing Symfony emails with PHPUnit

Even if it's not documented; testing emails is really super easy. But before getting into the actual test, you may be interested about how to not send emails for real. And here again, it is simple: you just have to change the value of the environment variable MAILER_DSN, and here is how:

# file .env.test
MAILER_DSN=null://null

And here is the code that allows us to test it:

public function testSomething()
{
    $client = static::createClient();
    $client->request('GET', '/sendmail');
    $this->assertEmailCount(1);
}

This test only verifies that an email has been sent, but you have many possibilities of assertions thanks to the MailerAssertionsTrait trait.

Testing with Behat tests (using the kernel)

⚠ This approach assumes that you are testing by using the kernel directly, you can do it very easily by using the Symfony extension. It means that it cannot work with selenium, in this case you may be interested by the last section of this article.

In the previous section I told you about the MailerAssertionsTrait, and you can use it on your behat context under 2 conditions:

  1. You must extend PHPUnit\Framework\Assert from PHPUnit
  2. You must have a static variable with your container

I personally think those 2 conditions are extremely restrictive so I decided to use another approach: I copied/pasted the code of this trait, and modified it. The most important method to redefine is getMessageMailerEvents, and here my version of it (beware, it is static in the original trait):

private function getMessageMailerEvents(): MessageEvents
{
    if (!$this->container()->has('mailer.logger_message_listener')) {
    	Assert::fail('A client must have Mailer enabled to make email assertions. Did you forget to require symfony/mailer?');
    }

    return $this->container()->get('mailer.logger_message_listener')->getEvents();
}

This approach is very helpful if you want to add your own methods. Here is one I use in my test (you may like it or not, here it is), this method returns a link contained in the HTML version of your mail:

public function getLinkInEmail(string $containing = '', int $index = 0, string $transport = null): string
{
    $message = $this->getMailerMessage($index, $transport);

    if (null === $message) {
        Assert::fail('There is no email');
    }
    if (RawMessage::class === \get_class($message)) {
        throw new \LogicException('Unable to test a message HTML body on a RawMessage.');
    }

    preg_match_all('/"(https?:\/\/[a-zA-Z0-9\-.]+\.[a-zA-Z]{2,3}(\/\S*)?)"/', $message->getHtmlBody(), $urls);

    foreach ($urls[1] as $url) {
        if (StringTools::contains($url, $containing)) {
            return $url;
        }
    }

    Assert::fail('No URL found for the pattern '.$containing);
}

Now by using your trait inside your behat contexts, you can easily test emails.

Testing emails with Behat/Mink and Selenium (or external approach)

Here you can't use the tooling that Symfony provides, so you will need to be a little bit more creative. The following example is something that works and that you can customize to fit your needs (or for better code quality, this is super-raw here).

I suggest you to use a redis instance. For this test to work I used a redis docker with no special configuration: it just works.

First step: a custom Symfony Mailer transport

Defining a custom transport for Symfony Mailer is really simple: you need a factory that instantiate a transport, and this factory should be tagged as transport factory in the dependency injection, it's basically 3 files.

In the transport we will just store inside redis that we sent a mail.

<?php

namespace App\Test;

use Symfony\Component\Mailer\Exception\UnsupportedSchemeException;
use Symfony\Component\Mailer\Transport\AbstractTransportFactory;
use Symfony\Component\Mailer\Transport\Dsn;
use Symfony\Component\Mailer\Transport\TransportInterface;

class BehatMailerFactory extends AbstractTransportFactory
{
    public function create(Dsn $dsn): TransportInterface
    {
        if (!\in_array($dsn->getScheme(), $this->getSupportedSchemes(), true)) {
            throw new UnsupportedSchemeException($dsn, 'behat', $this->getSupportedSchemes());
        }

        return new FakeRedisMailerTransport();
    }

    protected function getSupportedSchemes(): array
    {
    	// This is important for the Symfony to find the related
        // transport for the DSN we provide
        return ['behat'];
    }
}
<?php

namespace App\Test;

use Predis\Client;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\TransportInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\RawMessage;

class FakeRedisMailerTransport implements TransportInterface
{
    /**
     * @param Email $message
     */
    public function send(RawMessage $message, Envelope $envelope = null): ?SentMessage
    {
        // Instantiating predis here is not awesome, in real usage
        // I suggest you to use the redis bundle
        $client = new Client();
        
        // If you get the original value, you can just increment it
        $client->set('email_count', 1);

        // Oh yes I know. This part really sucks.
        // I just wanted to have the simplest example
        // But you have all you want inside the $message object
        // which is an Email instance, not just a RawMessage.
        return new SentMessage(
            $message,
            new Envelope(
                new Address('[email protected]'),
                [new Address('[email protected]')]
            )
        );
    }

    public function __toString(): string
    {
        return 'behat';
    }
}
# services.yaml
services:
    App\Test\BehatMailerFactory:
        tags:
            - {name: 'mailer.transport_factory'}

And we can now use the following DSN for our tests: behat://null

Second step: the behat context

Let's start this part with the actual feature we want to test:

# my-feature.feature
Feature:
  As a user
  blah blah blah (you should say a lot here)

  @email
  Scenario: It sends email
    Given I send an email
    Then an email has been sent

So we need to have 2 step, here is an example of how you can do it:

/**
 * @Given I send an email
 */
public function iSendAnEmail()
{
    $client = \Symfony\Component\HttpClient\HttpClient::create();
    $response = $client->request('GET', 'http://localhost:8001/sendmail');

    Assert::eq($response->getStatusCode(), 200);
}

/**
 * @Then an email has been sent
 */
public function anEmailHasBeenSent()
{
    $client = new Predis\Client();
    $value = $client->get('email_count');
    Assert::greaterThan($value, 0);
}

And here we are, you have a test that passes on the CI!

Bonus part: cleaning redis

After the test is executed, if the mail is no more sent, the test will pass: that's because the first test added data to redis and didn't clean it.

You may have notice that I used the tag @email in the feature file, nothing innocent here. It's because I use a behat hook to clean my email data in redis. Here is how to do it for our current example:

/**
 * @AfterScenario email
 */
public function cleanEmail()
{
    $client = new Predis\Client();
    $client->set('email_count', 0);
}

This concludes this little tutorial. I hope you liked it.

Show Comments