Integration Testing 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

I also want to test the method that sends the weekly update email. But because the real complexity of this method is centered around generating the PDF, instead of a unit test, let's write an integration test.

In MailerTest, add a second method: testIntegrationSendAuthorWeeklyReportMessage().

... lines 1 - 14
class MailerTest extends TestCase
{
... lines 17 - 42
public function testIntegrationSendAuthorWeeklyReportMessage()
{
... lines 45 - 58
}
}

Let's start the same way as the first method: copy all of its code except for the asserts, paste them down here and change the method to sendAuthorWeeklyReportMessage().

... lines 1 - 14
class MailerTest extends TestCase
{
... lines 17 - 42
public function testIntegrationSendAuthorWeeklyReportMessage()
{
$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);
$email = $mailer->sendWelcomeMessage($user);
}
}

This needs a User object... but it also needs an array of articles. Let's create one: $article = new Article(). These articles are passed to the template where we print their title. So let's at least populate that property: $article->setTitle():

Black Holes: Ultimate Party Pooper

... lines 1 - 4
use App\Entity\Article;
... lines 6 - 14
class MailerTest extends TestCase
{
... lines 17 - 42
public function testIntegrationSendAuthorWeeklyReportMessage()
{
... lines 45 - 52
$user = new User();
$user->setFirstName('Victor');
$user->setEmail('victor@symfonycasts.com');
$article = new Article();
$article->setTitle('Black Holes: Ultimate Party Pooper');
... lines 58 - 60
}
}

Use this for the 2nd argument of sendAuthorWeeklyReportMessage(): an array with just this inside.

... lines 1 - 14
class MailerTest extends TestCase
{
... lines 17 - 42
public function testIntegrationSendAuthorWeeklyReportMessage()
{
... lines 45 - 52
$user = new User();
$user->setFirstName('Victor');
$user->setEmail('victor@symfonycasts.com');
$article = new Article();
$article->setTitle('Black Holes: Ultimate Party Pooper');
$mailer = new Mailer($symfonyMailer, $twig, $pdf, $entrypointLookup);
$email = $mailer->sendAuthorWeeklyReportMessage($user, [$article]);
}
}

Unit Versus Integration Test

It's time to think strategically about our mocks. Right now, every dependency is mocked, which means it's a pure unit test. If we kept doing this, we could probably make sure that whatever render() returns is passed to the PDF function... and even assert that whatever that returns is passed to the attach() method. It's not bad, but because the logic in this method isn't terribly complex, its usefulness is limited.

What really scares me is the PDF generation: does my Twig template render correctly? Does the PDF generation process work... and do I really get back PDF content? To test this, instead of mocking $twig and $pdf, we could use the real objects. That would make this an integration test. These are often more useful than unit tests... but are also much slower to run, and it will mean that I really do need to have wkhtmltopdf installed on this machine, otherwise my tests will fail. Tradeoffs!

So here's the plan: use the real $twig and $pdf objects but keep mocking $symfonyMailer and $entrypointLookup... because I don't really want to send emails... and the $entrypointLookup doesn't matter unless I want to test that it does reset things correctly between rendering 2 PDFs.

Become an Integration Test!

To make this test able to use real objects, we need to change extends from TestCase to KernelTestCase.

... lines 1 - 9
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
... lines 11 - 15
class MailerTest extends KernelTestCase
{
... lines 18 - 62
}

That class extends the normal TestCase but gives us the ability to boot Symfony's service container in the background. Specifically, it gives us the ability, down in the method, to say: self::bootKernel().

... lines 1 - 9
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
... lines 11 - 15
class MailerTest extends KernelTestCase
{
... lines 18 - 43
public function testIntegrationSendAuthorWeeklyReportMessage()
{
self::bootKernel();
$symfonyMailer = $this->createMock(MailerInterface::class);
... lines 48 - 61
}
}

That will give us the ability to fetch real service objects and use them.

Fetching out Services

So we'll leave $symfonyMailer mocked, leave the $entrypointLookup mocked, but for the Pdf, get the real Pdf service. How? In the test environment, we can fetch things out of the container using the same type-hints as normal. So, $pdf = self::$container - bootKernel() set that property - ->get() passing this Pdf::class. Do the same for Twig: self::$container->get(Environment::class).

... lines 1 - 9
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
... lines 11 - 15
class MailerTest extends KernelTestCase
{
... lines 18 - 43
public function testIntegrationSendAuthorWeeklyReportMessage()
{
self::bootKernel();
... lines 47 - 49
$pdf = self::$container->get(Pdf::class);
$twig = self::$container->get(Environment::class);
... lines 52 - 61
}
}

I love that! Again, the downside is that you really do need to have wkhtmltopdf installed correctly anywhere you run your tests. That's the cost of doing this.

Before we try it, at the bottom, we don't have any asserts yet. Let's add at least one: $this->assertCount() that 1 is the count of $email->getAttachments().

... lines 1 - 15
class MailerTest extends KernelTestCase
{
... lines 18 - 43
public function testIntegrationSendAuthorWeeklyReportMessage()
{
... lines 46 - 60
$email = $mailer->sendAuthorWeeklyReportMessage($user, [$article]);
$this->assertCount(1, $email->getAttachments());
}
}

We could go further and look closer at the attachment... maybe make sure that it looks like it's in a PDF format... but this is a good start.

Now let's try this. Find your terminal and run our normal:

php bin/phpunit

It is slower this time... and then.. ah! What just happened? Two things. First, because this booted up a lot more code, we're seeing a ton of deprecation warnings. These are annoying... but we can ignore them.

Caching Driver in the test Environment

The second thing is that... the test failed! But... weird - not how I expected: something about APCu is not enabled. Huh? Why is it suddenly trying to use APCu?

The cause of this is specific to our app... but it's an interesting situation. Open up config/packages/cache.yaml.

framework:
cache:
... lines 3 - 14
app: '%cache_adapter%'
... lines 16 - 21

See this app key? This is where you can tell Symfony where it should store things that need to be added to cache at runtime - like the filesystem, redis or APCu. In an earlier tutorial, we set this to a parameter that we invented: %cache_adapter%.

This allows us to do something cool. Open config/services.yaml.

... lines 1 - 5
parameters:
cache_adapter: cache.adapter.apcu
... lines 8 - 53

Here, we set cache_adapter to cache.adapter.apcu: we told Symfony to store cache in APCu. And... apparently, I don't have that extension installed on my local machine.

Ok... fine... but then... how the heck is the website working? Shouldn't we be getting this error everywhere? Yep... except that we override this value in services_dev.yaml - a file that is only loaded in the dev environment. Here we tell it to use cache.adapter.filesystem.

parameters:
cache_adapter: 'cache.adapter.filesystem'

This is great! It means that we don't need any special extension for the cache system while developing... but on production, we use the superior APCu.

The problem now is that, when we run our tests, those are run in the test environment... and since the test environment doesn't load services_dev.yaml, it's using the default APCu adapter! By the way, there is a services_test.yaml file... but it has nothing in it. In fact, you can delete this: it's for a feature that's not needed anymore.

So, honestly... I should have set this all up better. And now, I will. Change the default cache adapter to cache.adapter.filesystem.

... lines 1 - 5
parameters:
cache_adapter: cache.adapter.filesystem
... lines 8 - 53

Then, only in the prod environment, let's change this to apcu. To do that, rename services_dev.yaml to services_prod.yaml... and change the parameter inside to cache.adapter.apcu.

parameters:
cache_adapter: 'cache.adapter.apcu'

Now the test environment should use the filesystem. Let's try it!

php bin/phpunit

And... if you ignore the deprecations... it worked! It actually generated the PDF inside the test! To totally prove it, real quick, in the test, var_dump($email->getAttachments())... and run the test again:

php bin/phpunit

Yea! It's so ugly. The attachment is some DataPart object and you can see the crazy PDF content inside. Go take off that dump.

Ok, the last type of test is a functional test. And this is where things get more interesting... especially in relation to Mailer. If we want to make a functional test for the registration form... do we expect our test to send a real email? Or should we disable email delivery somehow while testing? And, in both cases, is it possible to submit the registration form in a functional test and then assert that an email was in fact sent? Ooo. This is good stuff!

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
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "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.9", // v1.9.10
        "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
    }
}