humbledev

Testing an email workflow from end to end with Cypress

06.03.20193 Min Read — In Testing

Sending emails is a critical part of web applications. If often marks major checkpoints of the user journey such as signing up or completing a purchase. As such, I always felt that thoroughly testing the complete flow from the sign-up button to the email verification link was a good idea, although I was unsure where to draw the border between end to end testing and 3rd party testing (am I testing my 3rd party email provider here? 🤔 Is it out of the scope of my app? 🤔).

Throughout my (humble 😀) experience, testing such a "complete" flow that includes behavior that happens outside the browser and outside your server can be quite complex. Let’s list the key points we want to have when testing an email based workflow:

  • I want to test a user action on my UI to send an email.
  • I want to be able to check that that email has been received.
  • I want to check the contents of that email (does it contain a link?).
  • I want to check that that link is valid and does something.
  • Ideally, I want to able to send as many emails as I want during testing without having to sell my house to pay a subscription.
  • Ideally, I want to do all of this using Cypress because it is so good and my preferred way of e2e test web apps.

The specs

Let’s build a small app with these requirements.
Our app will consist of one page with an email field, a password field, and a sign-up button.
When I click on the sign-up button, an email containing a verification link should be sent to the email address entered during the previous step.
When I click on the verification link, I want to be sent to a page confirming that my email has been validated.

The web app

Our web app is very basic. I chose to make it as simple as possible by not using any front-end lib such as React. It consists of 3 very basic HTML pages and a small express server. You can see the full code here.

Sign-up page
Our beautiful sign-up form
Email sent page
The email sent page we get after submitting the form
Email verified page
Our holy grail

Emails are sent to Mailhog, an email server you can run on your machine very easily using Docker. It also exposes a convenient API to fetch emails as well as a UI. A SaaS equivalent to Mailhog would be Mailtrap. Email sending rate is limited on a free plan, so your tests can fail if you send emails too fast. This is what I used before Mailhog, then I missed having full control over my emails.

const transporter = nodemailer.createTransport({
  host: 'localhost',
  // Mailhog SMTP port
  port: 1025,
  tls: {
    ciphers: 'SSLv3',
  },
});

app.post('/', (req, res) => {
  transporter.sendMail({
    from: 'Humbledev <contact@humble.dev>',
    to: req.body.email,
    subject: 'Email verification',
    // Very advanced verification link
    html: '<a href="http://localhost:4003/verification.html">Click me!</a>',
  });
  res.redirect('/sent.html');
});

In production code, you can simply change the nodemailer part based on whether process.env.NODE_ENV is equal to production or not, or use a 3rd party API like SendGrid (this is what I use for my production web apps, it’s pretty cool).

We also expose a /last-email endpoint for Cypress to consume. To simplify testing, we assume that one email address has either received 0 or 1 email. It’s a pretty strong assumption, but we back it up by using unique randomly generated emails. This makes it easier to reason about than using static email addresses. We could also delete all emails before each Cypress run. If we didn’t do that, there would be the possibility that we’d fetch an email from a previous test run. This endpoint will be polled by Cypress to fetch the email contents (i.e. the verification link).

// This endpoint is for testing purposes only!
app.get('/last-email', async (req, res) => {
  // Query mailhog API
  const { items } = await rp({
    method: 'GET',
    url: 'http://localhost:8025/api/v2/search',
    headers: {
      'content-type': 'application/json',
    },
    qs: {
      kind: 'to',
      query: decodeURIComponent(req.query.email),
    },
    json: true,
  });

  const lastEmail = items[0];

  if (lastEmail) {
    res.send(lastEmail.Content.Body);
  } else {
    res.send(null);
  }
});

Testing with Cypress

If you aren’t familiar with Cypress yet, writing Cypress tests is really easy if you already played with Mocha or Jest. The test is pretty straightforward. We fill the signup form and check that the new page appears. Then we fetch the email sent to the email address we entered and extract its verification link. If visiting the link displays a success message, we’re all good!

// Lib to generate random stuff like email addresses and passwords
const faker = require('faker');

describe('signing up', () => {
  const randomEmail = faker.internet.email();

  it('should display the email sent page after filling and submit the sign up form', () => {
    cy.visit('/');
    cy.get('input[name="email"]').type(randomEmail);
    cy.get('input[name="password"]').type(faker.internet.password());
    cy.get('form[name="signup"]').submit();

    cy.contains('Email sent!');
  });

  it('should send an email containing a verification link', () => {
    cy.getLastEmail(randomEmail).then(email => {
      const link = email.match(/href="([^"]*)/)[1];
      cy.visit(link);
      cy.contains('Your email address has been verified!');
    });
  });
});

cy.getLastEmail is a custom Cypress command. They’re like functions that can be reused easily throughout your Cypress tests. Here is the code for this one:

Cypress.Commands.add('getLastEmail', email => {
  function requestEmail() {
    return cy
      .request({
        method: 'GET',
        url: 'http://localhost:4003/last-email',
        headers: {
          'content-type': 'application/json',
        },
        qs: {
          email,
        },
        json: true,
      })
      .then(({ body }) => {
        if (body) {
          return body;
        }

        // If body is null, it means that no email was fetched for this address.
        // We call requestEmail recursively until an email is fetched.
        // We also wait for 300ms between each call to avoid spamming our server with requests
        cy.wait(300);

        return requestEmail();
      });
  }

  return requestEmail();
});

We define a requestEmail function that tries to fetch an email by querying the last-email endpoint we defined earlier, then tries again after a small delay if it failed to do so. This polling approach is a bit better than doing something like cy.wait(5000) after we click on the Sign Up button. It may still send a few wasted requests, but it’s not a big deal. Maybe something based on WebSocket could work, but that may be overkill.

Take a peek at the repo!

If you have any question, leave a comment below or ping @windkomo on twitter 😄