Full Testing of HTML Emails using SendGrid and Ethereal Accounts

May 24, 2021

•

By Gleb Bahmutov

Imagine you are sending beautiful HTML emails using your own SMTP server or even a 3rd party service like SendGrid. What if the emails are formatted incorrectly? What if the template string is incorrect and the user sees Dear <<undefined>>? Can you test the emails to make sure they really work?

In this blog post I will describe the full cycle email testing. We will send actual emails using 3rd party service SendGrid and we will fetch that email from a temporary email account hosted by Ethereal.email service to test it using Cypress Test Runner.

You can find the full source code for this blog post at cypress-ethereal-email-example.

The application

Our application is a Next.js web app where the user can register for a service. To cut down on bots, the registration sends the user an email with the confirmation code to be entered by the user to complete the registration process.

The user enters their email to register

The email is sent by the application using the SendGrid SMTP server. The email includes the plaintext and rich HTML versions.

The HTML email

If we click on the "Confirm registration" button, the browser opens at the app's URL, in the simplest case at localhost:3000/confirm and if the user enters the right code the registration is complete.

The emailed code has been confirmed

Let's look how we can test the confirmation email flow.

Ethereal emails

The Ethereal email service at https://ethereal.email/ provides temporary email accounts that can only receive emails (but never reply or send the emails of their own). The received emails are purged every couple of hours, so we do not have to worry about them. We can create a temporary email account from Node code:

// cypress/plugins/email-account.js
const nodemailer = require('nodemailer')
const makeEmailAccount = async () => {
  const testAccount = await nodemailer.createTestAccount()
  // use testAccount.user and testAccount.pass 
  // to log in into the email inbox
  return {
    email: testAccount.user,
    
    /**
     * Utility method for getting the last email
     * for the Ethereal email account created above.
     */
    async getLastEmail() {
      // connect to the IMAP inbox for the test account
      // and get the last email
    }
  }
}
module.exports = makeEmailAccount

Let's use the created temporary email account during the E2E test. We can create this account when the Cypress plugin file loads.

// cypress/plugins/index.js
const makeEmailAccount = require('./email-account')
module.exports = async (on, config) => {
  const emailAccount = await makeEmailAccount()

  on('task', {
    getUserEmail() {
      return emailAccount.email
    },

    getLastEmail() {
      return emailAccount.getLastEmail()
    },
  })

  // important: return the changed config
  return config
}

We can make a temporary Ethereal email account at the start of the Cypress test, then fill the web application form. Eventually, the app contacts SendGrid, and the email gets delivered.

The confirmation email flow

Tip: once the temporary Ethereal email account has been created, you can log into the web view using the credentials to see any received emails.

Viewing the received emails at Ethereal website

The E2E test

Our test starts by fetching the just-created email from the plugin process.

// cypress/integration/spec.js
describe('Email confirmation', () => {
  let userEmail

  before(() => {
    // get and check the test email only once before the tests
    cy.task('getUserEmail').then((email) => {
      expect(email).to.be.a('string')
      userEmail = email
    })
  })

  it('sends confirmation code', () => {
    ...
  })
})     

Our test needs to enter the user email (and any name and other form values) into the registration form.

it('sends confirmation code', () => {
  const userName = 'Joe Bravo'

  cy.visit('/')
  cy.get('#name').type(userName)
  cy.get('#email').type(userEmail)
  cy.get('#company_size').select('3')
  cy.get('button[type=submit]').click()

  cy.log('**shows message to check emails**')
  cy.get('[data-cy=sent-email-to]')
    .should('be.visible')
    .and('have.text', userEmail)
})

After the test clicks the Submit button, the backend springs into the action. The app contacts the SendGrid SMTP server, that sends the email. The email travels from server to server until it reaches the smtp.ethereal.email and falls into the specific inbox. It might take a few seconds or longer - we really cannot control this timing. For now, let's hardcode the wait period.

// ANTI-PATTERN wait for N seconds
// then get the email and hope it has arrived
cy.wait(10000)

cy.task('getLastEmail')
  .its('html')
  .then((html) => {
    cy.document({ log: false }).invoke({ log: false }, 'write', html)
  })
cy.log('**email has the user name**')
cy.contains('[data-cy=user-name]', userName).should('be.visible')

We use a 3rd party NPM package to fetch the last email from Ethereal inbox, see the repository for details. The HTML text can be loaded directly into the browser using cy.document().invoke('write', html) command.

After we load the HTML email into the browser we can use the standard Cypress commands to extract the confirmation code and click the "Confirm registration" button.

cy.get('[data-cy=confirmation-code]')
  .should('be.visible')
  .invoke('text')
  .then((code) => {
    cy.log(`**confirm code ${code} works**`)
    expect(code, 'confirmation code')
      .to.be.a('string')
      .and.have.length.gt(5)

    cy.contains('Confirm registration').click()
    ...
  })
The test finds and clicks the "Confirm registration" button

Clicking on the button brings the Test Runner back to the application at the localhost:3000/confirm URL. The test can enter the code and confirm it is valid.

cy.contains('Confirm registration').click()

cy.get('#confirmation_code', { timeout: 10000 }).type(code)
cy.get('button[type=submit]').click()
// first positive assertion, then negative
// https://glebbahmutov.com/blog/negative-assertions/
cy.get('[data-cy=confirmed-code]').should('be.visible')
cy.get('[data-cy=incorrect-code]').should('not.exist')

The test should succeed.

The end of the test fetches the email and confirms the code

The E2E tested the confirmation email as shown in the diagram below

Fetching the delivered email and checking the code flow

Retry email task

We have hard-coded a delay in our test to make sure the email arrives before checking it using cy.task(...). Without a delay the test can be flakey - it might fail if there is even the smallest email slowdown.

Let's speed the test up and at the same time make it robust against the normal delays. We can retry fetching the email until it is found. If the email arrives quickly, the test will move on without a hardcoded wait. If the test arrives slower than usual, there is no problem - the test will still pass.

We can use the cypress-recurse plugin to retry Cypress commands and even chains of commands until a predicate passes. We can replace cy.wait(10000) with the following call:

// retry fetching the email
recurse(
  () => cy.task('getLastEmail'), // Cypress commands to retry
  Cypress._.isObject, // keep retrying until the task returns an object
  {
    timeout: 60000, // retry up to 1 minute
    delay: 5000, // wait 5 seconds between attempts
  },
)
  .its('html')
  .then((html) => {
    cy.document({ log: false }).invoke({ log: false }, 'write', html)
  })

The recurse tries fetching the email every 5 seconds up to 1 minute. As you can see in the recording below, the test can be quite fast, yet still be robust against unforeseen delays.

The finished test

A fast and flake-free test is a happy test, in my opinion.