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('hello@example.com')
->to('you@example.com')
->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 in the last section of this article.
In the previous section, I told you about the MailerAssertionsTrait
, and you can use it in your behat context under 2 conditions:
- You must extend
PHPUnit\Framework\Assert
from PHPUnit - You must have a static variable containing your container
I 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 is 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.
Remember that you can alternatively add a method `static function getContainer()` returning the test container, extends from PHPUnit\Framework\Assert
and use the trait Symfony\Bundle\FrameworkBundle\Test\MailerAssertionsTrait
. It will just work the same way.
⚠️ If you follow redirects, this will not work anymore because the listener in charge of storing the emails is reset on each new request. I suggest you to follow redirections after checking for 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('foo@bar.com'),
[new Address('bar@foo.com')]
)
);
}
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.
Follow us on Twitter! https://twitter.com/swagdotind