Testing Email Flow Using Cypress and Mailosaur

How to confirm the emails sent using SendGrid work correctly.

Recently I began looking again at testing emails sent as part of the user registration process. I wanted a quick way to set up end-to-end testing and decided to give cypress-mailosaur a try. In less than five minutes I had a working test!

🎁 You can find the source code for this blog post in the repo bahmutov/cypress-sendgrid-mailosaur-example.

The application

The user enters the sign-up information and submits a form. We can start writing the test immediately

cypress/e2e/confirmation.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// <reference types="cypress" />
// @ts-check

describe('Email flows', () => {
it('sends confirmation code', () => {
cy.visit('/')
cy.get('#name').type('Joe Bravo')
cy.get('#company_size').select('3')
cy.get('#email').type('[email protected]')
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', '[email protected]')
})
})

Tip: even in purely JavaScript tests, I check types to avoid silly mistakes. All you need is to use these special comments to get the IntelliSense in modern code editors. For more, read Convert Cypress Specs from JavaScript to TypeScript.

1
2
/// <reference types="cypress" />
// @ts-check

The application takes the user sign-up request and fires off a SendGrid API request. The code goes something like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// sending emails using the full SendGrid API
// for example if we want to use dynamic templates
// https://github.com/sendgrid/sendgrid-nodejs/tree/main/packages/client
const sgClient = require('@sendgrid/client')
sgClient.setApiKey(process.env.SENDGRID_API_KEY)

/**
* Sends an email by using SendGrid dynamic design template
* @see Docs https://sendgrid.api-docs.io/v3.0/mail-send/v3-mail-send
*/
async sendTemplateEmail({ from, template_id, dynamic_template_data, to }) {
const body = {
from: {
email: from || process.env.SENDGRID_FROM,
name: 'Confirmation system',
},
personalizations: [
{
to: [{ email: to }],
dynamic_template_data,
},
],
template_id,
}

const request = {
method: 'POST',
url: 'v3/mail/send',
body,
}
const [response] = await sgClient.request(request)

return response
}

// the user registration details
const { name, email } = req.body
// creates a short unique code
const confirmationCode = codes.createCode(email, name)
await sendTemplateEmail({
to: email,
// the ID of the dynamic template I have designed
template_id: 'd-abc...',
dynamic_template_data: {
code: confirmationCode,
username: name,
// the URL to include in the email for the user to click
confirm_url: 'http://localhost:3000/confirm',
},
})

We submit the information and the design ID to use as a JSON API call.

SendGrid email

A typical SendGrid email design looks like this:

SendGrid email design built from a library of components

We can use data variables we send in the dynamic_template_data object using {{ name }} syntax. We can even see the finished email if we fill the test data object in the editor. This is how the email will look like, and it even includes the button target URL:

SendGrid email preview

Once the API request arrives to SendGrid, it formats the HTML + Text email and sends it using SMTP protocol. All we need is a valid email to receive it.

Mailosaur

Mailosaur is an online email and 2FA token testing service. Once I signed up, it assigns me a random server hostname like abc123.mailosaur.net. Any email delivered to this server hostname will appear in my test inbox. For example, my tests could email [email protected] or test-user-1234.mailosaur.net, and the emails will be delivered to the same inbox.

Mailosaur server setup

Once I create a Mailosaur API key, I am good to go.

Note: I keep the server Id confidential, but anyone sending the emails will reveal it, since it is part of the email address. For sure keep the Mailosaur API key private to yourself.

In my test project I have installed the cypress-mailosaur plugin.

1
2
$ npm i -D cypress-mailosaur
+ [email protected]

All I need to do is to include the cypress-mailosaur plugin in my spec or support file loaded in the browser.

1
2
3
4
5
6
7
8
9
// https://github.com/mailosaur/cypress-mailosaur
// register email commands
import 'cypress-mailosaur'

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

Before we begin, we need to talk about SendGrid and Mailosaur API keys.

API keys

When running the application we need to provide the SendGrid API key and a "from" email address. We want to keep these settings confidential. When running Cypress tests, we want to provide the tests running in the browser with the Mailosaur API key and the server Id. The way I like keeping this information external to the source code is by injecting the values through the environment variables when needed. I use my own as-a utility. Here is the .as-a.ini file I have in the project's folder (and ignored by Git)

.as-a.ini
1
2
3
4
5
6
7
8
9
; http://github.com/bahmutov/as-a
[cypress-sendgrid-mailosaur-example]
; sending emails
SENDGRID_API_KEY=...
SENDGRID_FROM=...
; checking emails
; target email will be something like [email protected]
CYPRESS_MAILOSAUR_SERVER_ID=...
CYPRESS_MAILOSAUR_API_KEY=...

Assuming I am in the folder called "cypress-sendgrid-mailosaur-example", to run the application from one terminal I would execute as-a . npm start. Then from another terminal I would open Cypress with as-a . npx cypress open. The environment variables that start with CYPRESS_ are automatically parsed by Cypress and are available using Cypress.env() command.

Tip: even better, I use start-server-and-test to start the app and open Cypress using simple as-a . npm run local NPM script:

package.json
1
2
3
4
5
6
7
{
"scripts": {
"start": "next dev",
"cy:open": "cypress open",
"local": "start-test 3000 cy:open"
}
}

Use Mailosaur email

Let's use a real dynamic email with our test.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/// <reference types="cypress" />
// @ts-check

// https://github.com/mailosaur/cypress-mailosaur
// register email commands
import 'cypress-mailosaur'

describe('Email flows', () => {
it('sends confirmation code', () => {
const serverId = Cypress.env('MAILOSAUR_SERVER_ID')
const randomId = Cypress._.random(1e6)
const userEmail = `confirmation-${randomId}@${serverId}.mailosaur.net`
cy.log(`📧 **${userEmail}**`)

cy.visit('/')
cy.get('#name').type('Joe Bravo')
cy.get('#company_size').select('3')
cy.get('#email').type(userEmail)
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)
})
})

The test sends an email to a real inbox with some random characters to allow us to easily find it.

Using a random destination email

We can almost immediately find the delivered email in Mailosaur web application.

Mailosaur shows the delivered email

Fetching the email from Mailosaur

Let's grab the email from Mailosaur inbox. The CYPRESS_MAILOSAUR_API_KEY API key is read by the cypress-mailosaur plugin and we can simply call its custom command to give us the last email sent to that random email the test used.

1
2
3
4
5
6
7
cy.mailosaurGetMessage(serverId, {
sentTo: userEmail,
})
.its('html.body')
.then((html) => {
...
})

We are not interested in the raw text of the email, we want to see the HTML version, just like the user would view the email in the browser. So once we get the HTML text, we can write it into the Document object using cy.document().invoke('write', ...) commands.

1
2
3
4
5
6
7
cy.mailosaurGetMessage(serverId, {
sentTo: userEmail,
})
.its('html.body')
.then((html) => {
cy.document({ log: false }).invoke({ log: false }, 'write', html)
})

The email HTML text loaded in Cypress

Complete the test

Now we can check if the email has the right name, right code, etc.

1
2
3
4
5
6
7
8
9
10
11
cy.log('**email has the user name**')
cy.contains(`Dear Joe Bravo,`).should('be.visible')
cy.log('**email has the confirmation code**')
cy.contains('a', 'Enter the confirmation code')
.should('be.visible')
.as('codeLink')
.invoke('text')
.then((text) => Cypress._.last(text.split(' ')))
.then((code) => {
cy.log(`**confirm the code ${code} works**`)
})

We can even click on the "Confirm ..." button, but first let's ensure it does not open the new browser window.

1
2
3
4
5
6
7
cy.get('@codeLink')
// by default the link wants to open a new window
.should('have.attr', 'target', '_blank')
// but the test can point the open back at itself
// so the click opens it in the current browser window
.invoke('attr', 'target', '_self')
.click()

The navigation takes us back to our localhost:3000/confirm?code=... URL

Clicking on the email button

Let's confirm the URL and the code work.

1
2
3
4
5
6
7
8
9
10
11
12
// confirm the URL changed back to our web app
cy.location('pathname', { timeout: 30000 }).should('equal', '/confirm')
cy.location('search').should('equal', `?code=${code}`)
cy.log(`input code field is prefilled with code ${code}`)
cy.get('#confirmation_code')
.should('be.visible')
.and('have.value', 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')

Beautiful, the email confirmation flow works.

The confirmation code from the email does work

Please do not use gmail-tester

I also looked at gmail-tester plugin for accessing emails sent to a Gmail inbox. When I say "I looked", I mean I wasted almost an hour of my life trying to configure some weird OAuth way of accessing the inbox using tokens, credentials, secrets, and other Google crap. Please save yourself and use a dedicated email testing inbox. If you don't want to use cypress-mailosaur, try any other dedicated services through their Cypress email plugins.

See also