Capturing emails in Acceptance tests with Specflow

Pretty much every web application out there nowadays needs to send emails, whether it be for registration, password reset or some other bespoke functionality. When you’re working in your own dev environment and building out features, tools like smtp4dev work great for capturing emails sent to localhost; to use, simply add the following to web.config

  <system.net>
    <mailSettings>
      <smtp deliveryMethod="Network">
        <network host="localhost" port="25" defaultCredentials="true" />
      </smtp>
    </mailSettings>
  </system.net>

 

start smtp4dev.exe

Screenshot 2014-10-20 22.28.02

and you can start capturing emails sent whilst developing. You can use web config transforms or slow cheetah to replace the mail settings for different build configurations too. But what about acceptance tests; how do you handle emails sent during acceptance test runs?

I’m Listening

It turns out that smtp4dev was written in C# and uses the Rnwood.SmtpServer component under the covers to allow one to receive and process emails, and does this by setting up a TcpListener that understands the SMTP protocol on the configured host and port. Using the component, we can spin the SMTP server up when we need to capture emails and then shut it down when it’s no longer required. In conjunction with Specflow, we can use it to capture emails sent during acceptance tests and verify assertions about the behaviour of the system.

To get started, we need a hook to start the server up at the start of the acceptance test run and a hook to stop the server at the end. Fortunately, Specflow already provides such hooks for us in the form of the BeforeTestRunAttribute and the AfterTestRunAttribute, respectively. When a message is received by the server, we’ll want to read it into a format against which we can easily make assertions and store it in the context of the currently running scenario for when we wish to make those assertions. Using MIMER, we can read the messages received that adhere to the RFC2045 format and then add them to the current ScenarioContext:

using System.Collections.Generic;
using System.Net.Mail;
using MIMER.RFC2045;
using Rnwood.SmtpServer;
using TechTalk.SpecFlow;

[Binding]
public class RecevingEmailTests
{
    private static DefaultServer _server;

    [AfterTestRun]
    public static void AfterTestRun()
    {
        if (_server != null && _server.IsRunning)
        {
            _server.Stop();
            _server = null;
        }
    }

    [BeforeTestRun]
    public static void BeforeTestRun()
    {
        if (_server == null)
        {
            _server = new DefaultServer();

            _server.MessageReceived += (sender, messageEventArgs) =>
            {
                var messages = ScenarioContext.Current.ContainsKey<List<MailMessage>>()
                    ? ScenarioContext.Current.Get<List<MailMessage>>()
                    : new List<MailMessage>();

                using (var stream = messageEventArgs.Message.GetData())
                {
                    var dataStream = stream;
                    var reader = new MailReader();
                    var message = reader.ReadMimeMessage(ref dataStream, new BasicEndOfMessageStrategy());
                    var mailMessage = message.ToMailMessage();

                    messages.Add(mailMessage);
                }

                ScenarioContext.Current.Set(messages);
            };
        }

        if (!_server.IsRunning)
        {
            _server.Start();
        }
    }
}

public static class ScenarioContextExtensions
{
    public static bool ContainsKey<T>(this ScenarioContext scenarioContext)
    {
        return scenarioContext.ContainsKey(typeof(T).FullName);
    }
}

Now that we have email messages being put into the current ScenarioContext, we can start asserting that certain emails are received when actions are taken within the application.

Did you get it?

In order to demonstrate usage, I’m going to write some behavioural specifications using Specflow and send an email in one of the scenario steps. In your real acceptance tests, the scenario step would take an action that would cause the application to send an email but for the purposes of brevity, sending the email from within the step will be good enough. Here is what our feature file looks like

Feature: Receiving Emails
	In order to contact prospective clients
	As a System Administrator
	I need to be able to send emails from the system

Background:
	When I send an email with the following properties
	| Property | Value                      |
	| To       | hello@example.com          |
	| From     | demo@Example.com           |
	| Subject  | Test Email                 |
	| Body     | This is just an email test |
	
Scenario: Receive an email with the given subject
	Then an email with subject Test Email should be sent

Scenario: Receive an email containing content
	Then an email containing the text an email test should be sent

Scenario: Receive an email sent to given address
	Then an email addressed to hello@example.com should be sent

The feature file defines three scenarios where the application sends emails and each scenario verifies some property of the sent email; The Background section just defines common steps that run before the body of each scenario. The step definitions for the above are

using System.Linq;
using System.Net.Mail;
using TechTalk.SpecFlow;
using TechTalk.SpecFlow.Assist;

[Binding]
public class ReceivingEmailsSteps
{
    [When(@"I send an email with the following properties")]
    public void WhenISendAnEmailWithTheFollowingProperties(Table table)
    {
        // In reality, your application logic would be sending the email
        using (var message = table.CreateInstance(() => new MailMessage()))
        {
            message.To.Add(new MailAddress(table.Rows.Single(r => r["Property"] == "To")["Value"]));
            message.From = new MailAddress(table.Rows.Single(r => r["Property"] == "From")["Value"]);
            message.IsBodyHtml = true;

            using (var client = new SmtpClient())
            {
                client.Send(message);
            }
        }
    }

}

and

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Mail;
using System.Threading;
using NUnit.Framework;
using TechTalk.SpecFlow;

[Binding]
public class EmailSteps
{
    private const int Retries = 10;
    private static readonly MailAddressComparer AddressComparer = new MailAddressComparer();

    [Then("an email addressed to (.*) should be received")]
    public void ReceiveAnEmailAddressedTo(string emailAddress)
    {
        ReceiveAnEmailMatchingPredicate(
            m => m.To.Contains(new MailAddress(emailAddress), AddressComparer),
            string.Format("addressed to {0}", emailAddress));
    }

    [Then("an email containing the text (.*) should be received")]
    public void ReceiveAnEmailContaining(string content)
    {
        ReceiveAnEmailMatchingPredicate(
            m => m.Body.IndexOf(content, StringComparison.InvariantCultureIgnoreCase) >= 0,
            string.Format("where body contains {0}", content));
    }

    [Then("an email with subject (.*) should be received")]
    public void ReceiveAnEmailWithSubject(string subject)
    {
        ReceiveAnEmailMatchingPredicate(
            m => string.Equals(m.Subject, subject, StringComparison.InvariantCultureIgnoreCase),
            string.Format("where subject equals {0}", subject));
    }

    private void ReceiveAnEmailMatchingPredicate(Func<MailMessage, bool> predicate, string description)
    {
        var attempt = 0;

        while (true)
        {
            try
            {
                try
                {
                    var messages = ScenarioContext.Current.Get<List<MailMessage>>().ToList();
                    var message = messages.SingleOrDefault(predicate);

                    if (message == null)
                    {
                        throw new EmailNotReceivedException(string.Format("no email received {0}", description));
                    }

                    break;
                }
                catch
                {
                    if (attempt < Retries)
                    {
                        attempt++;
                        Thread.Sleep(TimeSpan.FromSeconds(1));
                        continue;
                    }

                    throw;
                }
            }
            catch (Exception e)
            {
                var keyNotFoundException = e as KeyNotFoundException;

                if (keyNotFoundException != null)
                {
                    Assert.Fail("No emails found in ScenarioContext.Current");
                }
                else
                {
                    Assert.Fail(e.Message);
                }
            }
        }
    }

    private class MailAddressComparer : IEqualityComparer<MailAddress>
    {
        public bool Equals(MailAddress first, MailAddress second)
        {
            return first.Address.Equals(second.Address, StringComparison.InvariantCultureIgnoreCase);
        }

        public int GetHashCode(MailAddress mailAddress)
        {
            return mailAddress.GetHashCode();
        }
    }
}

public class EmailNotReceivedException : Exception
{
    public EmailNotReceivedException(string message)
        : base(message)
    {
    }
}

Running the scenarios in the Resharper test runner yields the following successful tests

image

When using Specflow in conjunction with Selenium, it is pretty easy to get up and running with Behavioural Driven Development (BDD).

Comments

comments powered by Disqus