Testing HTML Emails using Cypress

May 11, 2021

By Gleb Bahmutov

In this blog post, we will use a local SMTP server to receive emails sent by the app to the user. We will test the HTML emails to make sure they look and work correctly.

Note: you can find the source code shown in this blog post at cypress-email-example and watch a video explaining the testing process here.

Sending emails

If you want to send an email from a Node program, I would suggest using the  nodemailer module. Here is an example script to send an email via a local SMTP server

// send.js
const nodemailer = require('nodemailer')

// async..await is not allowed in global scope, must use a wrapper
async function main() {
  // create reusable transporter object using the default SMTP transport
  // the settings could come from .env file or environment variables
  const transporter = nodemailer.createTransport({
    host: 'localhost',
    port: 7777,
    secure: false, // true for 465, false for other ports
  })

  // send an email
  const info = await transporter.sendMail({
    from: '"Fred Blogger" <[email protected]>',
    to: '[email protected]', // list of receivers
    subject: 'Hello ✔', // Subject line
    text: 'Hello world?', // plain text body
    html: '<b>Hello world?</b>', // html body
  })

  console.log('Message sent: %s', info.messageId)
}

main().catch(console.error)

In the production system the host and the port would be your organization's production SMTP server.  But when testing locally, let's assume the email server is running at localhost:7777. We can make the email-sending code reusable:

// emailer.js
const nodemailer = require('nodemailer')

// create reusable transporter object using the default SMTP transport
// the settings could come from .env file or environment variables
const host = process.env.SMTP_HOST || 'localhost'
const port = Number(process.env.SMTP_PORT || 7777)

const transporter = nodemailer.createTransport({
  host,
  port,
  secure: port === 456,
})

module.exports = transporter

Any part of our application can thus require the above module and use it to send an email:

const transporter = require('./emailer')
await transporter.sendMail({
  from: '"Fred Blogger" <[email protected]>',
  to: '[email protected]', // list of receivers
  subject: 'Hello ✔', // Subject line
  text: 'Hello world?', // plain text body
  html: '<b>Hello world?</b>', // html body
})

Great, now let's receive an email.

Receiving emails

During testing, we want to use a local STMP server that would give us access to the received emails. A simple implementation can be found in the smtp-tester NPM module. Let's create the server and print every incoming message:

// mail-server.js
const ms = require('smtp-tester')
const port = 7777
const mailServer = ms.init(port)
console.log('mail server at port %d', port)

// process all emails
mailServer.bind((addr, id, email) => {
  console.log('--- email ---')
  console.log(addr, id, email)
})

Start the server and then execute the send.js script

$ node ./send.js 
Message sent: <[email protected]>

The mail server prints the detailed email information

$ node ./demo.js 
mail server at port 7777
--- email ---
null 1 {
  sender: '[email protected]',
  receivers: { '[email protected]': true },
  data: 'Content-Type: multipart/alternative;\r\n' +
    ' boundary="--_NmP-7a4c358b72f79393-Part_1"\r\n' +
    'From: Fred Blogger <[email protected]>\r\n' +
    'To: [email protected]\r\n' +
    'Subject: =?UTF-8?Q?Hello_=E2=9C=94?=\r\n' +
    'Message-ID: <[email protected]>\r\n' +
    'Date: Wed, 05 May 2021 14:30:35 +0000\r\n' +
    'MIME-Version: 1.0\r\n' +
    '\r\n' +
    '----_NmP-7a4c358b72f79393-Part_1\r\n' +
    'Content-Type: text/plain; charset=utf-8\r\n' +
    'Content-Transfer-Encoding: 7bit\r\n' +
    '\r\n' +
    'Hello world?\r\n' +
    '----_NmP-7a4c358b72f79393-Part_1\r\n' +
    'Content-Type: text/html; charset=utf-8\r\n' +
    'Content-Transfer-Encoding: 7bit\r\n' +
    '\r\n' +
    '<b>Hello world?</b>\r\n' +
    '----_NmP-7a4c358b72f79393-Part_1--',
  headers: {
    'content-type': { value: 'multipart/alternative', params: [Object] },
    from: 'Fred Blogger <[email protected]>',
    to: '[email protected]',
    subject: 'Hello ✔',
    'message-id': '<[email protected]>',
    date: 2021-05-05T14:30:35.000Z,
    'mime-version': '1.0'
  },
  body: 'Hello world?',
  html: '<b>Hello world?</b>',
  attachments: []
}

The received email contains both the plain text and the "rich" HTML bodies we have sent.

The application

Imagine you have an application where the user enters their email and the application emails the confirmation code to the user.

The registration page

The user must enter the sent code into the "confirm" page.

The confirmation page expects the emailed code

In our case, the application receives the registration form inputs from the front-end as a JSON object. The backend function uses the email utility we wrote above to send the actual (hardcoded for now) confirmation code.

// pages/api/register.js
const emailer = require('../../emailer')

export default async (req, res) => {
  if (req.method === 'POST') {
    const { name, email, companySize } = req.body
    // return to the caller right away
    res.status(200).json({ name, email })

    // and then send an email
    const info = await emailer.sendMail({
      from: '"Registration system" <[email protected]>',
      to: email,
      subject: 'Confirmation code 1️⃣2️⃣3️⃣',
      text: 'Your confirmation code is 654agc',
      html: 'Your confirmation code is 654agc',
    })
    console.log('sent a confirmation email to %s', email)

    return
  }

  return res.status(404)

The confirmation page can call the backend with the user-submitted code, or in my demo it simply verifies the input against the expected string "654agc".

The confirmation page shows success message if the code is valid

Confirmation page test

As the first step, let's confirm the above page works - it should show an error message for an invalid code, and a success message for the valid one. Our Cypress test can be this:

// cypress/integration/confirm-spec.js
/// <reference types="cypress" />

describe('Confirmation page', () => {
  it('rejects invalid code', () => {
    cy.visit('/confirm')
    cy.get('#confirmation_code').type('wrongcode')
    cy.get('button[type=submit]').click()
    cy.get('[data-cy=incorrect-code]').should('be.visible')
    cy.get('[data-cy=confirmed-code]').should('not.exist')

    cy.log('**enter the right code**')
    cy.get('#confirmation_code').clear().type('654agc')
    cy.get('button[type=submit]').click()
    cy.get('[data-cy=incorrect-code]').should('not.exist')
    cy.get('[data-cy=confirmed-code]').should('be.visible')
  })
})

The test passes

The Cypress test for the confirmation page

The STMP server inside Cypress

During testing we want to receive the email the application is sending. Thus we need access to the SMTP server receiving the emails - Cypress can spawn such server using the smtp-tester module right from its plugin file! The plugin file runs in Node, thus it can bind to the local socket, listen for the incoming SMTP messages - yet be accessible from the test via cy.task command.

// cypress/plugins/index.js
/// <reference types="cypress" />
const ms = require('smtp-tester')

/**
 * @type {Cypress.PluginConfig}
 */
module.exports = (on, config) => {
  // starts the SMTP server at localhost:7777
  const port = 7777
  const mailServer = ms.init(port)
  console.log('mail server at port %d', port)

  // process all emails
  mailServer.bind((addr, id, email) => {
    console.log('--- email ---')
    console.log(addr, id, email)
  })
}

The SMTP server will start when we run Cypress. Now we can write a test to fill the registration page form and submit it - we should see the email arrive.

// cypress/integration/spec.js
// enables intelligent code completion for Cypress commands
// https://on.cypress.io/intelligent-code-completion
/// <reference types="cypress" />

describe('Email confirmation', () => {
  it('sends an email', () => {
    cy.visit('/')
    cy.get('#name').type('Joe Bravo')
    cy.get('#email').type('[email protected]')
    cy.get('#company_size').select('3')

    cy.intercept('POST', '/api/register').as('register')
    cy.get('button[type=submit]').click()

    cy.log('**redirects to /confirm**')
    cy.location('pathname').should('equal', '/confirm')

    cy.log('**register API call**')
    cy.wait('@register').its('request.body').should('deep.equal', {
      name: 'Joe Bravo',
      email: '[email protected]',
      companySize: '3',
    })
    // once we have waited for the ajax call once,
    // we can immediately get it again using cy.get(<alias>)
    cy.get('@register').its('response.body').should('deep.equal', {
      // the response from the API should only
      // include name and email
      name: 'Joe Bravo',
      email: '[email protected]',
    })
  })
})

The above test:

  • visits the registration page
  • fills the registration form: name, email, company size
  • clicks the submit button and checks the application ends up at the /confirm page
  • spies on the network call using cy.intercept command to make sure the application does send the registration information to the backend
The registration test shows the SMTP server receives the sent email from the app

Use the text email in the test

You can see from the terminal messages that the SMTP server running inside the Cypress plugin process receives the registration email with the confirmation code. Let's get this email text and use it to continue our test - we need to enter this code on the confirmation page. We can store the last email's text for each user in an object inside the plugin file:

// cypress/plugins/index.js
module.exports = (on, config) => {
  // starts the SMTP server at localhost:7777
  const port = 7777
  const mailServer = ms.init(port)
  console.log('mail server at port %d', port)

  // [receiver email]: email text
  let lastEmail = {}

  // process all emails
  mailServer.bind((addr, id, email) => {
    console.log('--- email to %s ---', email.headers.to)
    console.log(email.body)
    console.log('--- end ---')
    // store the email by the receiver email
    lastEmail[email.headers.to] = email.html || email.body
  })
}

This works, but we also need to pass the last email to the test when needed. Let's add a task:

// cypress/plugins/index.js
module.exports = (on, config) => {
  ...
  // [receiver email]: email text
  let lastEmail = {}
    
  on('task', {
    getLastEmail(email) {
      // cy.task cannot return undefined
      // thus we return null as a fallback
      return lastEmail[email] || null
    },
  })
}

We can also add a task to delete all emails, since we want to start from a clean slate before each test:

// cypress/plugins/index.js
module.exports = (on, config) => {
  ...
  on('task', {
    resetEmails(email) {
      console.log('reset all emails')
      if (email) {
        delete lastEmail[email]
      } else {
        lastEmail = {}
      }
      return null
    },
    
    getLastEmail(email) {
      // cy.task cannot return undefined
      // thus we return null as a fallback
      return lastEmail[email] || null
    },
  })
}

Let's add the following commands to our test to grab the email the server should receive:

// cypress/integration/spec.js
...

// once we have waited for the ajax call once,
// we can immediately get it again using cy.get(<alias>)
cy.get('@register').its('response.body').should('deep.equal', {
  // the response from the API should only
  // include name and email
  name: 'Joe Bravo',
  email: '[email protected]',
})

// by now the SMTP server has probably received the email
cy.task('getLastEmail', '[email protected]').then((email) => {
  cy.log(email)
})

We log the received email test in the Command Log to observe

The received email text

Tip: we assumed the server has received the expected email by the time we called cy.task('getLastEmail', '[email protected]'). Of course, due to the async nature of the application flow, it is not guaranteed. If the above test is flaky due to the delay in the email arrival, use cypress-recurse to retry the task command until the email arrives (or the test times out).

// call the task every second for up to 20 seconds
// until it returns a string result
recurse(
  () => cy.task('getLastEmail', '[email protected]'),
  Cypress._.isString,
  {
    log: false,
    delay: 1000,
    timeout: 20000,
  },
).then(email => ...)

Entering the confirmation code

Let's parse the received email text and enter the code into the confirmation page.

// cypress/integration/spec.js
...
// by now the SMTP server has probably received the email
cy.task('getLastEmail', '[email protected]')
  .then(cy.wrap)
  // Tip: modern browsers supports named groups
  .invoke('match', /code is (?<code>\w+)/)
  // the confirmation code
  .its('groups.code')
  .should('be.a', 'string')
  .then((code) => {
    cy.get('#confirmation_code').type(code)
    cy.get('button[type=submit]').click()
    cy.get('[data-cy=incorrect-code]').should('not.exist')
    cy.get('[data-cy=confirmed-code]').should('be.visible')
  })

By wrapping the value yielded from the cy.task using the cy.wrap, we print the value to the Command Log without using an extra cy.log command. The entire test shows that our application is emailing the confirmation code that works.

The code from the email works

Sending HTML emails

In the previous test we have confirmed the registration flow using the plain text email. But we can send and test a rich HTML email too. Let's create a stylish HTML email and confirm that the users with HTML email clients can see and use the confirmation code.

Writing bulletproof cross-platform HTML emails is hard, thus I used Maizzle to produce a confirmation email that should work on most email clients. The built HTML file emails/confirmation-code.html looks nice, in my humble opinion:

Confirmation HTML email

The "Confirm registration" link points at localhost:3000/confirm URL. Our registration API function can send both the plain and the rich HTML versions.

// pages/api/register.js
const confirmationEmailPath = join(
  process.cwd(), // should be the root folder of the project
  'emails',
  'confirmation-code.html',
)
const confirmationEmailHTML = readFileSync(confirmationEmailPath, 'utf-8')

...

const info = await emailer.sendMail({
  from: '"Registration system" <[email protected]>',
  to: email,
  subject: 'Confirmation code 1️⃣2️⃣3️⃣',
  text: 'Your confirmation code is 654agc',
  html: confirmationEmailHTML,
})

Now that we are sending both the plain and the rich HTML emails, let's test the HTML version.

Testing HTML emails

On the SMTP side, we should store both the plain and the HTML text

// cypress/plugins/index.js
let lastEmail = {}

// process all emails
mailServer.bind((addr, id, email) => {
  console.log('--- email to %s ---', email.headers.to)
  console.log(email.body)
  console.log('--- end ---')
  // store the email by the receiver email
  lastEmail[email.headers.to] = {
    body: email.body,
    html: email.html,
  }
})

We can update the plain email test to grab just the body property from the object now returned by the getLastEmail task

// cypress/integration/spec.js
// by now the SMTP server has probably received the email
cy.task('getLastEmail', '[email protected]')
  .its('body') // check the plain email text
  .then(cy.wrap)
  // Tip: modern browsers supports named groups
  .invoke('match', /code is (?<code>\w+)/)

To test the HTML email, I will clone the full test we wrote to test the plain email version. We can always refactor the common test code later. The test is almost identical right now:

// cypress/integration/spec.js
it('sends an HTML email', () => {
  cy.visit('/')
  cy.get('#name').type('Joe Bravo')
  cy.get('#email').type('[email protected]')
  cy.get('#company_size').select('3')

  cy.intercept('POST', '/api/register').as('register')
  cy.get('button[type=submit]').click()

  cy.log('**redirects to /confirm**')
  cy.location('pathname').should('equal', '/confirm')

  cy.log('**register API call**')
  cy.wait('@register').its('request.body').should('deep.equal', {
    name: 'Joe Bravo',
    email: '[email protected]',
    companySize: '3',
  })
  // once we have waited for the ajax call once,
  // we can immediately get it again using cy.get(<alias>)
  cy.get('@register').its('response.body').should('deep.equal', {
    // the response from the API should only
    // include name and email
    name: 'Joe Bravo',
    email: '[email protected]',
  })

  // by now the SMTP server has probably received the email
  cy.task('getLastEmail', '[email protected]')
    .its('html') // check the HTML email text
    // what do we do now?
})

Now we have to check the HTML email. But rather than simply search its text, why don't we really test it? After all, the HTML email is meant to be displayed by a browser ... and Cypress is testing inside one. We can load the received HTML in place of the site!

// by now the SMTP server has probably received the email
cy.task('getLastEmail', '[email protected]')
  .its('html') // check the HTML email text
  // what do we do now?
  .then((html) => {
    cy.document().invoke('write', html)
  })

// by now the HTML email should be displayed in the app iframe
// let's confirm the confirmation code and the link
cy.contains('654agc')
  .should('be.visible')
  // I have added small wait to make sure the video shows the email
  // otherwise it passes way too quickly!
  .wait(2000)
cy.contains('Confirm registration').click()
cy.location('pathname').should('equal', '/confirm')
// we have already tested the confirmation page in other tests

The test confirms the code is displayed in the email, and the test really clicks on the "Confirm ..." link, and checks that the page /confirm loads.

The complete HTML test flow

My favorite part about the above test is the time-traveling debugging experience. I can see the received email, the confirmation code it contains, the link clicked - just by hovering over the test commands.

Hover over the test commands to see test steps

Tip: if you are recording the results to Cypress Dashboard you can use cy.screenshot commands at major test steps to record the images and make them easily shareable with the team.

cy.screenshot('1-registration')
cy.get('button[type=submit]').click()
...
cy.screenshot('2-the-email')
cy.contains('654agc')
  .should('be.visible')
...
cy.get('[data-cy=incorrect-code]').should('not.exist')
cy.get('[data-cy=confirmed-code]').should('be.visible')
cy.screenshot('3-confirmed')
The screenshots show the email and the app 

What to test next

We wrote tests for the plain and rich HTML emails. I would extend the HTML email tests to make sure the emails are accessible, work across multiple viewports, even bring the visual testing plugins to make sure the emails never have style problems.

This post showed how to use the low-level SMTP utilities to send and receive emails. If you are using 3rd party services to send emails, like Mailosaur, SendGrid, or others, then see if a Cypress plugin exists that lets the Test Runner access the received email.

As always, happy testing!