Testing Mail with Laravel Dusk and Mailtrap.io

posted by 6 months ago and updated 6 months ago

Laravel Dusk is an amazing way to do acceptance testing in order to make sure your application is working. To be honest acceptance testing is our favourite testing because it proves your application is working.

Although Dusk is amazing and powerful it might appear that it has certain limitations. Dusk uses ChromeDrive to create an application test. The minute we do that we are not part of the application but we are using the application under a different environment.

As @JVMartin said at git

This kind of testing no longer works because the application itself is now completely decoupled from the testbed.

@georaldc said at git

I don't think it's possible at the moment to modify the framework's environment because your test files and dusk run separately

Still not getting it?

Simple Answer

Let us simplify this a little bit more. When you visit your application from your browser is there anyway to see if an Event or an Email has fired? The answer is no! We can see a success message showing in our application. We can check our mailbox to see if we received an email. That is part of end to end testing. We performed an action and we expect to see a message and get an email.

But testing emails worked before

In the past using integration testing through phpunit we could create acceptance tests by mocking Mail. We are still able to do that in Http Testing by using Mail::fake() and then we can use Mail::assertSent, Mail::assertNotSent, Mail::assertQueued() or Mail::assertNotQueued. As we described it earlier we can't do that with Dusk.

Mailtrap to the rescue

So what can we do instead? We can use Mailtrap. Before we started using Laravel Dusk we used to use Behat. Along with Behat we used a package called Behat-Laravel-Extension by Jeffrey Way at Laracasts The Behat-Laravel-Extension does use mailtrap. We thought that to pull a package just for the purposes of using the Mailtrap.php trait was a bit too much so instead we modified the trait.

Set up

The following two links explain in further detail how to set up mailtrap with Laravel.

  • https://github.com/laracasts/Behat-Laravel-Extension#service-mailtrap
  • https://laravel.com/docs/5.6/mail#mail-and-local-development

Set up .env.dusk.local with the following variables. You can get all the details by logging in to your Mailtrap account.

# Mail Driver
MAIL_DRIVER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=MAILTRAP_USERNAME
MAIL_PASSWORD=MAILTRAP_PASSWORD
MAIL_ADDRESS=admin@yourcompany.com
MAIL_NAME=Phaine

# Mail Trap
MAILTRAP_SECRET=YOUR_MAILTRAP_API_KEY
MAILTRAP_API=YOUR_API_KEY
MAILTRAP_INBOX=YOUR_MAILTRAP_INBOX_NUMBER

We have modified Mailtrap.php to suite more to our purposes. The main thing that needs updating is the referencing to the use import statements. We also added a few more methods.

  • searchInboxMessagesUrl($query) - Search Messages Url endpoint
  • findMessage($query) - Find and fetch a Message By Query.
  • messageExists($query) - Check if a message exists based on a string query.

We are not aware whether the search API given by Mailtrap allows you to search for contents in the body or just the subject line. We have been successful with searching for the Subject.

tests\traits\Mailtrap.php

<?php

/**
 * Source comes from Package: laracasts/Behat-Laravel-Extension
 * Git: https://github.com/laracasts/Behat-Laravel-Extension/
 * File: https://github.com/laracasts/Behat-Laravel-Extension/blob/master/src/Context/Services/MailTrap.php
 *
 * Copyright (c) 2012 Jeffrey Way <jeffrey@laracasts.com>
 * Permission is hereby granted, free of charge, to any person
 * obtaining a copy of this software and associated documentation
 * files (the "Software"), to deal in the Software without
 * restriction, including without limitation the rights to use,
 * copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following
 * conditions:
 *
 * The above copyright notice and this permission notice shall be
 * included in all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
 * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
 * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
 * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
 * OTHER DEALINGS IN THE SOFTWARE.
 */

namespace Tests\Traits;

use GuzzleHttp\Client;
use Illuminate\Support\Facades\Config;
use \Exception;

trait MailTrap
{

    /**
     * The MailTrap configuration.
     *
     * @var integer
     */
    protected $mailTrapInboxId;

    /**
     * The MailTrap API Key.
     *
     * @var string
     */
    protected $mailTrapApiKey;

    /**
     * The Guzzle client.
     *
     * @var Client
     */
    protected $client;

    /**
     * Get the configuration for MailTrap.
     *
     * @param integer|null $inboxId
     * @throws Exception
     */
    protected function applyMailTrapConfiguration($inboxId = null)
    {
        if (null === $config = Config::get('services.mailtrap')) {
            throw new Exception(
                'Set "secret" and "default_inbox" keys for "mailtrap" in "config/services.php."'
            );
        }

        $this->mailTrapInboxId = $inboxId ?: $config['default_inbox'];
        $this->mailTrapApiKey = $config['secret'];
    }

    /**
     * Fetch a MailTrap inbox.
     *
     * @param  integer|null $inboxId
     * @return mixed
     * @throws RuntimeException
     */
    protected function fetchInbox($inboxId = null)
    {
        if ( ! $this->alreadyConfigured()) {
            $this->applyMailTrapConfiguration($inboxId);
        }

        $body = $this->requestClient()
            ->get($this->getMailTrapMessagesUrl())
            ->getBody();

        return $this->parseJson($body);
    }

    /**
     *
     * Empty the MailTrap inbox.
     *
     * @AfterScenario @mail
     */
    public function emptyInbox()
    {
        $this->requestClient()->patch($this->getMailTrapCleanUrl());
    }

    /**
     * Get the MailTrap messages endpoint.
     *
     * @return string
     */
    protected function getMailTrapMessagesUrl()
    {
        return "/api/v1/inboxes/{$this->mailTrapInboxId}/messages";
    }

    /**
     * Get the MailTrap "empty inbox" endpoint.
     *
     * @return string
     */
    protected function getMailTrapCleanUrl()
    {
        return "/api/v1/inboxes/{$this->mailTrapInboxId}/clean";
    }

    /**
     * Determine if MailTrap config has been retrieved yet.
     *
     * @return boolean
     */
    protected function alreadyConfigured()
    {
        return $this->mailTrapApiKey;
    }

    /**
     * Request a new Guzzle client.
     *
     * @return Client
     */
    protected function requestClient()
    {
        if ( ! $this->client) {
            $this->client = new Client([
                'base_uri' => 'https://mailtrap.io',
                'headers'  => ['Api-Token' => $this->mailTrapApiKey],
            ]);
        }

        return $this->client;
    }

    /**
     * @param $body
     * @return array|mixed
     * @throws RuntimeException
     */
    protected function parseJson($body)
    {
        $data = json_decode((string) $body, true);

        if (JSON_ERROR_NONE !== json_last_error()) {
            throw new RuntimeException('Unable to parse response body into JSON: ' . json_last_error());
        }

        return $data === null ? array() : $data;
    }

    /**
     * Search Messages Url
     *
     * @param string $query Search query
     * @return string
     */
    protected function searchInboxMessagesUrl($query)
    {
        return "/api/v1/inboxes/{$this->mailTrapInboxId}/messages?search=" . $query;
    }

    /**
     * Find and fetch a Message By Query.
     *
     * @param  integer $query Query
     * @return mixed
     * @throws RuntimeException
     */
    protected function findMessage($query)
    {
        if ( ! $this->alreadyConfigured()) {
            $this->applyMailTrapConfiguration();
        }

        $body = $this->requestClient()
            ->get($this->searchInboxMessagesUrl($query))
            ->getBody();

        return $this->parseJson($body);
    }

    /**
     * Check if a message exists based on a string query.
     *
     * @param  string $query Query string
     * @return mixed
     * @throws RuntimeException
     */
    protected function messageExists($query)
    {
        $messages = $this->findMessage($query);

        return count($messages) > 0;
    }
}

How to use the Mailtrap trait

Now that we have defined our trait we need to consume it and use it in our test class.

<?php

namespace Tests\Browser;

use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Tests\Traits\MailTrap;
use \PHPUnit\Framework\Assert as PHPUnit;

class ContactControllerTest extends DuskTestCase
{
    use MailTrap;

    /** @test */
    public function it_should_send_an_email()
    {
        $this->browse(function (Browser $browser) {
            $browser->visit('/contact')
                ->type('from_email', 'test@email.com')
                ->type('body', 'I love bananas, testing with bananas is great and it is the only way to get things done.')
                ->press('Send Message...')
                ->assertPathIs('/contact')
                ->waitForText('Thank you for contacting us. We will respond to you as soon as we can.');
        });

        PHPUnit::assertTrue($this->messageExists('Thank you for contacting Phaine'));

        $this->emptyInbox();
    }
}

Some things to keep in mind. We are importing the Trait we created and PHPUnit type assertions

use Tests\Traits\MailTrap;
use \PHPUnit\Framework\Assert as PHPUnit;

We then use the trait

use MailTrap;

Finally we assert that the email was sent to our Mailtrap inbox

PHPUnit::assertTrue($this->messageExists('Thank you for contacting us'));

// Or
$lastEmail = $this->findMessage('Verify Your Account')[0];
PHPUnit::assertContains($user->name, $lastEmail['text_body']);

Conclusion

You might get some false negatives with queue and delays on the network. Up to now we have not had any such problems.

Some of you might not like the idea of having to rely on a live API and might think of it as an overkill or a waste of resource. Like we said earlier if Mailtrap is not an option for you don't use it. Don't test for Mail at Dusk instead write HTTP Tests that perform the test and with Dusk, check if the message has fired successfully.

Also remember not to test for the same thing twice. If you have an HTTP Test for you controllers checking whether an email was sent, do you need to use Dusk to check for that email as well?


Want to read more? Follow these links.