Functional Testing with 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

When we originally added our Mailtrap config... I was a bit lazy. I put the value into .env. But because that file is committed... we really shouldn't put any sensitive values into it. Well, you could argue that Mailtrap credentials aren't that sensitive, but let's fix this properly. Copy the MAILER_DSN and open .env.local.

If you don't have a .env.local file yet, just create it. I already have one so that I can customize my local database config. The values in this file override the ones in .env. And because this file is ignored by .gitignore, these values won't be committed.

Back in .env, let's set MAILER_DSN back to the original value, which was smtp://localhost.

46 lines .env
... lines 1 - 37
###> symfony/mailer ###
MAILER_DSN=smtp://localhost
###
... lines 41 - 46

And yes, this does mean that when a developer clones the project, unless they customize MAILER_DSN in their own .env.local file, they'll get an error if they try to register... or do anything that sends an email. We'll talk more about that in a few minutes.

Creating a Functional Test

Back to my real goal: writing a functional test for the registration page. Because a successful registration causes an email to be sent... I'm curious how that will work. Will an email actually be sent to Mailtrap? Do we want that?

To create the test, be lazy and run:

php bin/console make:functional-test

And... we immediately get an error: we're missing some packages. I'll copy the composer require browser-kit part. Panther isn't technically needed to write functional tests... and this error message is fixed in a newer version of this bundle. But, Panther is an awesome way to write functional tests that rely on JavaScript.

Anyways, run

composer require browser-kit --dev

... and we'll wait for that to install. Once it finishes, I'll clear the screen and try make:functional-test again:

php bin/console make:functional-test

Access granted! I want to test SecurityController - specifically the SecurityController::register() method. I'll follow the same convention we used for the unit test: call the class SecurityControllerTest.

Done! This creates a simple functional test class directly inside of tests/.

... lines 1 - 2
namespace App\Tests;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class SecurityControllerTest extends WebTestCase
{
public function testSomething()
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('h1', 'Hello World');
}
}

We don't have to, but to make this match the src/Controller directory structure, create a new Controller/ folder inside of tests/... and move the test file there. Don't forget to add \Controller to the end of its namespace.

... lines 1 - 2
namespace App\Tests\Controller;
... lines 4 - 6
class SecurityControllerTest extends WebTestCase
{
... lines 9 - 16
}

And, again, to stay somewhat conventional, let's rename the method to testRegister().

... lines 1 - 6
class SecurityControllerTest extends WebTestCase
{
public function testRegister()
{
... lines 11 - 24
}
}

Writing the Registration Functional Test

We won't go too deep into the details of how to write functional tests, but it's a pretty simple idea. First, we create a $client object - which is almost like a "browser": it helps us make requests to our app. In this case, we want to make a GET request to /register to load the form.

... lines 1 - 6
class SecurityControllerTest extends WebTestCase
{
public function testRegister()
{
$client = static::createClient();
$crawler = $client->request('GET', '/register');
... lines 13 - 24
}
}

The assertResponseIsSuccessful() method is a helper assertion from Symfony that will make sure the response wasn't an error or a redirect.

... lines 1 - 6
class SecurityControllerTest extends WebTestCase
{
public function testRegister()
{
$client = static::createClient();
$crawler = $client->request('GET', '/register');
$this->assertResponseIsSuccessful();
... lines 15 - 24
}
}

Now... I'll remove the assertSelectorTextContains()... and paste in the rest of the test.

... lines 1 - 6
class SecurityControllerTest extends WebTestCase
{
public function testRegister()
{
$client = static::createClient();
$crawler = $client->request('GET', '/register');
$this->assertResponseIsSuccessful();
$button = $crawler->selectButton('Register');
$form = $button->form();
$form['user_registration_form[firstName]']->setValue('Ryan');
$form['user_registration_form[email]']->setValue(sprintf('foo%s@example.com', rand()));
$form['user_registration_form[plainPassword]']->setValue('space_rocks');
$form['user_registration_form[agreeTerms]']->tick();
$client->submit($form);
$this->assertResponseRedirects();
}
}

Let's see: this goes to /register, finds the Register button by its text, and then fills out all the form fields. These funny-looking values are literally the name attributes of each element if you looked at the source HTML. After submitting the form, we assert that the response is a redirect... which is an easy way to assert that the form submit was successful. If there's a validation error, it re-renders without redirecting.

We've used the registration form on this site... about 100 times. So we know it works... and so this test should pass. Whenever you say that something "should" work in programming... do you ever get the sinking feeling that you're about to eat your words? Ah, I'm sure nothing bad will happen in this case. Let's try it!

At your terminal, run just this test with:

php bin/phpunit tests/Controller/SecurityControllerTest.php

Deprecation notices of course... and... woh! It failed! And dumped some giant HTML... which is impossible to read... unless you go all the way to the top. Ah!

Failed asserting that the Response is redirected: 500 internal server error.

And down in the HTML:

Connection could not be established with host tcp://localhost:25

The test Environment Doesn't Read .env.local

Huh. That's coming from sending the email... but why is it trying to connect to localhost? Our config in .env.local is set up to talk to Mailtrap.

Well... there's a little gotcha about the .env system. I mean... it's a feature! When you're in the test environment, the .env.local file is not loaded. In every other situation - like the prod or the dev environments - it is loaded. But in test, it's not. It's madness!

Well, it definitely is surprising the first time you see this, but there is a good reason for it. In theory, your committed .env.test file should contain all the configuration needed for the test environment to work... on any machine. And so, you actually don't want your local values from .env.local to override the stuff in .env.test - that might break how the tests are supposed to behave.

The point is, since the .env.local file is not being loaded in our tests, it's using the .env settings for MAILER_DSN... which is connecting to localhost.

How can we fix this? The simplest answer is to copy the MAILER_DSN from .env.local into .env.test. This isn't a great solution because .env.test is committed... and so we would once again be committing our Mailtrap credentials to the repository. You can get around this by creating a .env.test.local file - that's a file that's loaded in the test environment but not committed - but let's just do this for now and see if we can get things working. Later, we'll talk about a better option.

Ok, go tests go!

php bin/phpunit tests/Controller/SecurityControllerTest.php

This time... it passes! Spin back over and inside Mailtrap... there it is! The test actually sent an email! Wait... is that what we want? Let's improve this next by preventing emails from our test from actually being delivered. Then, we'll talk about how we can add assertions to guarantee that the right email was sent.

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
    }
}