Test sending emails using Symfony + Behat + Selenium2

Written by Pim on Thursday November 24, 2016 - Comment - Permalink
Category: php - Tags: php, symfony2, testing, behat, mink, selenium

Testing your Symfony application with Behat and Selenium2 is a good approach to check if your application is performing as expected. When using this combination in your continuous integration pipeline you likely also want to know, not only if the application is working correctly from a user perspective, but also if background processes are correctly executed. How about testing that an email is sent to the user if the user requests a password reset? This is possible by using the Symfony Profiler!

Behat

In short, Behat is a PHP framework for auto testing your business expectations. If you're not familiar with Behat, read the introduction on their website because I won't discuss the tool substantively in this blog post.

Selenium

Selenium is a tool to automate browser actions. In this case, we use Selenium, together with Behat and Mink, to automate our browser tests!

Basic configuration

Before your can start testing the Symfony application with Behat and Selenium, you need some configuration. At least you should have a behat.yml file in the root directory of your project.

We also need these two extensions:

  • Symfony2Extension to access the Symfony kernel so we can use the container and all the services we have defined in the application
  • MinkExtension to enable the use of browsers in our tests

Require these extensions with composer if you don't have them already available in your project yet:

"behat/symfony2-extension": "^2.0",
"behat/mink": "^1.6",
"behat/mink-extension": "^2.0",
"behat/mink-browserkit-driver": "^1.3"

Because we want to use Selenium, we also have to require the selenium2 driver for behat and mink with composer:

"behat/mink-selenium2-driver": "^1.3"

The content of your behat.yml could be something like this:

default:
    autoload:
        '': '%paths.base%/tests/behat/features/bootstrap'

    extensions:
        Behat\Symfony2Extension:
            kernel:
                env: behat
        Behat\MinkExtension:
            base_url: http://my-app.dev/app_behat.php/
            sessions:
                symfony2:
                    symfony2: ~
                selenium2: # fast, CLI, opens up a browser
                    selenium2:
                        browser: firefox
                        wd_host: selenium-hub:4444/wd/hub

    suites:
        browser:
            paths: ['%paths.base%/tests/behat/features/browser']
            mink_session: selenium2
            contexts:
              - ApplicationContext
              - ProfilerContext
              - MinkContext

As you can see I have changed the autoload configuration so that Behat knows where it can find my bootstrap files. This is optional, but because I have all kind of tests in one directory it is mandatory in my use case.

The extensions are the most important. In most use cases you can just use the Symfony2Extensions as defined in the example, but if you would like more information, read the documentation. The MinkExtension is more specific for each project. The base_url is configured so that you won't need to write down the full URL in your tests (example: "homepage" instead of "http://my-app.dev/app_behat.php/homepage"). Define in the sessions section which sessions you got and which driver should be used for each session. In my use case, I have the Symfony2 driver and the Selenium2 driver configured, but more are available.

The selenium2 session uses the selenium2 driver and which uses the firefox browser. Because Selenium2 communicates through the Selenium Hub you have to define the wd_host. How those applications are configured? I use Docker Compose and this is my docker-selenium.yml file which is an extension of my docker-compose.yml file because I only want to boot up these containers when I want to run the Selenium tests:

version: '2'
services:
  selenium-hub:
    image: selenium/hub
    networks:
      - app_net
  
  selenium-firefox:
    image: selenium/node-firefox
    depends_on:
      - selenium-hub
    environment:
      - HUB_PORT_4444_TCP_ADDR=selenium-hub
    networks:
      - app_net

networks:
  app_net:
    driver: bridge

I named my suite "browser" and defined a custom path to the features. The mink session I use is "selenium2" and at last I define some contexts.

Test the sending of emails

The feature and scenario I will use as an example:

Feature: Reset password
  In order to login after I lost my password
  As a registered user
  I have to reset my password

  @database @screenshot
  Scenario: Request a reset link
    Given I am on "resetting/request"
    And there is a user "john" with role "ROLE_USER"
    When I fill in "username" with "john"
    And I press "Reset password"
    Then I post to "resetting/send-email" and should receive an e-mail with subject "Your password reset link"
    And I should see "An email has been sent to ...@my-app.local. It contains a link you must click to reset your password."

The "I post to "resetting/send-email" and should receive an e-mail with subject "Your password reset link"" is the step where I have to check if an email is sent by the backend system.

ProfilerContext

As you maybe already noticed in my behat.yml configuration, I have included the ProfilerContext file. In this file, I define how the step should be executed. This file contains the following code:

<?php

use Behat\Behat\Context\Context;
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
use Behat\Behat\Tester\Exception\PendingException;
use Symfony\Component\HttpKernel\Profiler\Profile;

/**
 * Defines symfony profiler features from the specific context.
 */
class ProfilerContext implements Context
{
    use \Behat\Symfony2Extension\Context\KernelDictionary;

    /**
     * @Then I am on :url and should receive an e-mail with subject :subject
     * @Then I post to :url and should receive an e-mail with subject :subject
     */
    public function iAmOnShouldReceiveAnEMailWithSubject($url, $subject)
    {
        $profile = $this->getLatestSymfonyProfileForUrl($url);
        /** @var \Symfony\Bundle\SwiftmailerBundle\DataCollector\MessageDataCollector $switftmailerCollector */
        $switftmailerCollector = $profile->getCollector('swiftmailer');

        // Check if at least one email is send
        \PHPUnit_Framework_Assert::assertGreaterThanOrEqual(1, $switftmailerCollector->getMessageCount());

        // Get messages from the collector
        /** @var Swift_Message[] $messages */
        $messages = $switftmailerCollector->getMessages();

        \PHPUnit_Framework_Assert::assertCount(
            1,
            array_filter($messages, function (Swift_Message $message) use ($subject) {
                if (strstr($message->getSubject(), $subject) !== false) {
                    return true;
                }
                return false;
            })
        );
    }

    /**
     * Get the latest symfony profile for the given token.
     *
     * @param string $url
     * @param string $method
     *
     * @return Profile
     */
    public function getLatestSymfonyProfileForUrl($url, $method = '')
    {
        $tokens = $this->getContainer()->get('profiler')->find('', $url, 1, $method, '', '');
        if (count($tokens) === 0) {
            throw new \RuntimeException('No profile found. Is the profiler data collector enabled?');
        }

        return $this->getSymfonyProfileByToken($tokens[0]['token']);
    }

    /**
     * Get the symfony profile for the given token.
     *
     * @param string $token
     *
     * @return Profile
     */
    public function getSymfonyProfileByToken($token)
    {
        $profile = $this->getContainer()->get('profiler')->loadProfile($token);

        if (!$profile instanceof Profile) {
            throw new \RuntimeException(sprintf('Profile with token \'%s\' not found.', $token));
        }

        return $profile;
    }
}

We have three methods:

  • iAmOnShouldReceiveAnEMailWithSubject is the method executing the step by getting the symfony profile by the given URL and retrieving the messages from the swiftmailer data collector. First I check if there is at least one email and if that is the case, I loop through all the emails to check if there is one email with the given subject.

  • getLatestSymfonyProfileForUrl tries to find the latest symfony profile for the given URL and uses the token to get the profile.

  • getSymfonyProfileByToken is the method getting the profile from the profiler service by the given token.

If you just want to get the latest profile you can also do:

    /**
     * Get the latest symfony profile.
     *
     * @return Profile
     */
    public function getLatestSymfonyProfile()
    {
        $tokens = $this->getContainer()->get('profiler')->find('', '', 1, '', '', '');
        if (count($tokens) === 0) {
            throw new \RuntimeException('No profile found. Is the profiler data collector enabled?');
        }

        return $this->getSymfonyProfileByToken($tokens[0]['token']);
    }

You can also request the current URL from the Mink session when you gather the MinkContext first:

    /** @var MinkContext */
    private $minkContext;

    /** @BeforeScenario */
    public function gatherContexts(BeforeScenarioScope $scope)
    {
        $environment = $scope->getEnvironment();

        /** @var MinkContext */
        $this->minkContext = $environment->getContext('MinkContext');
    }

    /**
     * @Then I should receive an e-mail with subject :subject
     */
    public function iShouldReceiveAnEMailWithSubject($subject)
    {
        $this->iAmOnShouldReceiveAnEMailWithSubject($this->minkContext->getSession()->getCurrentUrl(), $subject);
    }

Happy testing!