Unit Testing our Emails

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 $12.00

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

Login Subscribe

Other than code organization, one of the benefits of putting logic into a service is that we can unit test it. Ok, to be fully honest, this chapter doesn't have a lot to do with Mailer. Unit tests pretty much look the same no matter what you're testing. But unit testing is a great practice... and I hate when code does weird things... especially code that sends emails.

make:unit-test

Let's use MakerBundle to bootstrap a test for us. At your terminal, run:

php bin/console make:unit-test

Answer MailerTest. This generates a super simple unit test file: tests/MailerTest.php.

... lines 1 - 2
namespace App\Tests;
use PHPUnit\Framework\TestCase;
class MailerTest extends TestCase
{
public function testSomething()
{
$this->assertTrue(true);
}
}

The idea is that this will test the Mailer class, which lives in the Service/ directory. Inside tests/, create a new Service/ directory to match that and move MailerTest inside. You typically want your test directory structure to match your src/ structure. Inside the file, don't forget to add \Service to the namespace to match the new location.

... lines 1 - 2
namespace App\Tests\Service;
... lines 4 - 6
class MailerTest extends TestCase
{
... lines 9 - 12
}

Running the Tests

Ok! Our test asserts that true is true! I'm not so easily convinced... we better run PHPUnit to be sure. At your terminal, run it with:

php bin/phpunit

This script is a small wrapper around PHPUnit... and it will install PHPUnit the first time you run it. Then... it passes!

Oh! But it did print out a deprecation notice. One of the superpowers of this wrapper around PHPUnit - called the phpunit-bridge - is that it prints out warnings about any deprecated code that the code in your tests hit. This is a great tool when you're getting ready to upgrade your app to the next major Symfony version. But more on that in a future tutorial. We'll just ignore these.

Go Deeper!

If PHPUnit is new for you - or you just want to go deeper - check out our dedicated PHPUnit Tutorial.

Writing the Unit Test

Let's get to work! So... what are we going to test? Well, we probably want to test that the mail was actually sent... and maybe we'll assert a few things about the Email object itself. Unit tests always start the same way: by instantiating the class you want to test.

Back in MailerTest, rename the method to testSendWelcomeMessage().

... lines 1 - 12
class MailerTest extends TestCase
{
public function testSendWelcomeMessage()
{
... lines 17 - 30
}
}

Then add $mailer = new Mailer(). For this to work, we need to pass the 4 dependencies: objects of the types MailerInterface, Twig, Pdf and EntrypointLookupInterface. In a unit test, instead of using real objects that really do send emails... or render Twig templates, we use mocks.

For the first, say $symfonyMailer = this->createMock()... and because the first argument needs to be an instance of MailerInterface, that's what we'll mock: MailerInterface::class.

... lines 1 - 8
use Symfony\Component\Mailer\MailerInterface;
... lines 10 - 12
class MailerTest extends TestCase
{
public function testSendWelcomeMessage()
{
$symfonyMailer = $this->createMock(MailerInterface::class);
... lines 18 - 30
}
}

To make sure we don't forget to actually send the email, we can add an assertion to this mock: we can tell PHPUnit that the send method must be called exactly one time. Do that with $symfonyMailer->expects($this->once()) that the ->method('send') is called.

... lines 1 - 8
use Symfony\Component\Mailer\MailerInterface;
... lines 10 - 12
class MailerTest extends TestCase
{
public function testSendWelcomeMessage()
{
$symfonyMailer = $this->createMock(MailerInterface::class);
$symfonyMailer->expects($this->once())
->method('send');
... lines 20 - 30
}
}

Let's create the 3 other mocks: $pdf = this->createMock(Pdf::class)... and the other two are for Environment and EntrypointLookupInterface: $twig = $this->createMock(Environment::class) and $entrypointLookup = $this->createMock(EntrypointLookupInterface::class).

... lines 1 - 6
use Knp\Snappy\Pdf;
... line 8
use Symfony\Component\Mailer\MailerInterface;
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
use Twig\Environment;
... line 12
class MailerTest extends TestCase
{
public function testSendWelcomeMessage()
{
$symfonyMailer = $this->createMock(MailerInterface::class);
$symfonyMailer->expects($this->once())
->method('send');
$pdf = $this->createMock(Pdf::class);
$twig = $this->createMock(Environment::class);
$entrypointLookup = $this->createMock(EntrypointLookupInterface::class);
... lines 24 - 30
}
}

These three objects aren't even used in this method... so we don't need to add any assertions to them or configure any behavior. Finish the new Mailer() line by passing $symfonyMailer, $twig, $pdf and $entrypointLookup. Then, call the method: $mailer->sendWelcomeMessage(). Oh, to do this, we need a User object.

... lines 1 - 5
use App\Service\Mailer;
use Knp\Snappy\Pdf;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
use Twig\Environment;
class MailerTest extends TestCase
{
public function testSendWelcomeMessage()
{
$symfonyMailer = $this->createMock(MailerInterface::class);
$symfonyMailer->expects($this->once())
->method('send');
$pdf = $this->createMock(Pdf::class);
$twig = $this->createMock(Environment::class);
$entrypointLookup = $this->createMock(EntrypointLookupInterface::class);
... lines 24 - 28
$mailer = new Mailer($symfonyMailer, $twig, $pdf, $entrypointLookup);
$mailer->sendWelcomeMessage($user);
}
}

Should we mock the User object? We could, but as a general rule, I like to mock services but manually instantiate simple "data" objects, like Doctrine entities. The reason is that these classes don't have dependencies and it's usually dead-simple to put whatever data you need on them. Basically, it's easier to create the real object, than create a mock.

Start with $user = new User(). And... let's see... the only information that we use from User is the email and first name. For $user->setFirstName(), let's pass the name of my brave co-author for this tutorial: Victor! And for $user->setEmail(), him again victor@symfonycasts.com. Give this $user variable to the sendWelcomeMessage() method.

... lines 1 - 4
use App\Entity\User;
use App\Service\Mailer;
use Knp\Snappy\Pdf;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
use Twig\Environment;
class MailerTest extends TestCase
{
public function testSendWelcomeMessage()
{
$symfonyMailer = $this->createMock(MailerInterface::class);
$symfonyMailer->expects($this->once())
->method('send');
$pdf = $this->createMock(Pdf::class);
$twig = $this->createMock(Environment::class);
$entrypointLookup = $this->createMock(EntrypointLookupInterface::class);
$user = new User();
$user->setFirstName('Victor');
$user->setEmail('victor@symfonycasts.com');
$mailer = new Mailer($symfonyMailer, $twig, $pdf, $entrypointLookup);
$mailer->sendWelcomeMessage($user);
}
}

By the way, if you're enjoying this tutorial, you can thank Victor personally by emailing him photos of your cat or by sending tuna directly to his cat Ponka.

And... done! We're not asserting anything down here... but we do have one built-in assert above: our test will fail unless the send() method is called exactly once.

Let's try this! Fly over to your terminal, I'll clear my screen, then run:

php bin/phpunit

It passes! The power!

Asserting Info on the Email

The tricky thing is that the majority of this method is about creating the Email... and we're not testing what that object looks like at all. And... maybe we don't need to? I tend to unit test logic that scares me and manually test other things - like the wording inside an email. But let's at least assert a few basic things.

How? An easy way is to return the email from each method: return $email and then advertise that this method returns a TemplatedEmail. I'll do the same for the other method: return $email and add the TemplatedEmail return type.

... lines 1 - 12
class Mailer
{
... lines 15 - 27
public function sendWelcomeMessage(User $user): TemplatedEmail
{
... lines 30 - 41
return $email;
}
... line 44
public function sendAuthorWeeklyReportMessage(User $author, array $articles): TemplatedEmail
{
... lines 47 - 65
return $email;
}
}

You don't have to do this, but it'll make our unit test more useful and keep it simple. Now we can say $email = $mailer->sendWelcomeMessage() and we can check pretty much anything on that email.

I'll paste in some asserts:

... lines 1 - 13
class MailerTest extends TestCase
{
public function testSendWelcomeMessage()
{
... lines 18 - 29
$mailer = new Mailer($symfonyMailer, $twig, $pdf, $entrypointLookup);
$email = $mailer->sendWelcomeMessage($user);
$this->assertSame('Welcome to the Space Bar!', $email->getSubject());
$this->assertCount(1, $email->getTo());
/** @var NamedAddress[] $namedAddresses */
$namedAddresses = $email->getTo();
$this->assertInstanceOf(NamedAddress::class, $namedAddresses[0]);
$this->assertSame('Victor', $namedAddresses[0]->getName());
$this->assertSame('victor@symfonycasts.com', $namedAddresses[0]->getAddress());
}
}

Tip

In Symfony 4.4 and higher, use new Address() - it works the same way as the NamedAddress we use here.

These check the subject, that the email is sent to exactly one person and checks to make sure that the "to" has the right info.

Let's give this a try! Move over and run:

php bin/phpunit

All green! Next, let's do this same thing for the author weekly report email. Actually... the "email" part of this method is, once again, pretty simple. The complex part is the PDF-generation logic. Want to test to make sure the template actually renders correctly and the PDF is truly created? We can't do that with a pure unit test... but we can with an integration test. That's next.

Leave a comment!

This tutorial is built on Symfony 4.3, but will work well with Symfony 4.4 or 5.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "aws/aws-sdk-php": "^3.87", // 3.110.11
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.1
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-snappy-bundle": "^1.6", // v1.6.0
        "knplabs/knp-time-bundle": "^1.8", // v1.9.1
        "league/flysystem-aws-s3-v3": "^1.0", // 1.0.23
        "league/flysystem-cached-adapter": "^1.0", // 1.0.9
        "league/html-to-markdown": "^4.8", // 4.8.2
        "liip/imagine-bundle": "^2.1", // 2.1.0
        "nexylan/slack-bundle": "^2.1,<2.2.0", // v2.1.0
        "oneup/flysystem-bundle": "^3.0", // 3.1.0
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.4.1
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.3.4
        "symfony/console": "^4.0", // v4.3.4
        "symfony/flex": "^1.0", // v1.6.2
        "symfony/form": "^4.0", // v4.3.4
        "symfony/framework-bundle": "^4.0", // v4.3.4
        "symfony/mailer": "4.3.*", // v4.3.4
        "symfony/messenger": "4.3.*", // v4.3.4
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.3.4
        "symfony/sendgrid-mailer": "4.3.*", // v4.3.4
        "symfony/serializer-pack": "^1.0", // v1.0.2
        "symfony/twig-bundle": "^4.0", // v4.3.4
        "symfony/twig-pack": "^1.0", // v1.0.0
        "symfony/validator": "^4.0", // v4.3.4
        "symfony/web-server-bundle": "^4.0", // v4.3.4
        "symfony/webpack-encore-bundle": "^1.4", // v1.6.2
        "symfony/yaml": "^4.0", // v4.3.4
        "twig/cssinliner-extra": "^2.12", // v2.12.0
        "twig/extensions": "^1.5", // v1.5.4
        "twig/inky-extra": "^2.12" // v2.12.0
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.2.2
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/browser-kit": "4.3.*", // v4.3.5
        "symfony/debug-bundle": "^3.3|^4.0", // v4.3.4
        "symfony/dotenv": "^4.0", // v4.3.4
        "symfony/maker-bundle": "^1.0", // v1.13.0
        "symfony/monolog-bundle": "^3.0", // v3.4.0
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.3.4
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/var-dumper": "^3.3|^4.0" // v4.3.4
    }
}