Dealing With Email Flows in Cypress Tests #frontend@twiliosendgrid


November 24, 2020
Written by
Contributor
Opinions expressed by Twilio contributors are their own

Twilio SendGrid sends a lot of emails. To send all of our transactional emails—ranging from password reset to account email verification to export CSV emails—we use our own backend services. 
We recently passed a huge milestone of over 3 trillion emails sent.
In our testing environments, we send our emails to test email inboxes in self-hosted Squirrelmail servers to avoid sending out test emails to actual email inbox service providers such as Gmail. Many important flows require the user to check their email, click on an actionable link, redirect back to the web application, and then continue onward at some download or verification success page. 

We test these features manually by inputting our Squirrelmail email addresses in the necessary forms, clicking some buttons, and following email links to validate things work as expected. We can do this every time upon new code changes to ensure we did not regress anywhere, but it would be nice to automate these steps in an end-to-end (E2E) test we can run again whenever we want to. Specifically, we would like to write E2E tests with Cypress, so we do not have to test these potentially slow and confusing email flows manually in our own web browser every time. 

Before we get into the post, here are a couple of articles you may be interested in reading first.
  • If you have never written E2E tests before or would like a refresher about how to think when writing E2E tests, you may want to see this blog post before we start.
  • If you aren’t familiar with using Cypress for writing E2E tests in general, we strongly recommend you check out our one thousand foot overview about implementing Cypress tests for your web applications—this will give you a better idea of the Cypress API. 
This post assumes you know some of the Cypress functions such as cy.task() to run arbitrary code we define in a Node server to assist us with dealing with emails. Also, if the later code snippets with TypeScript are a bit confusing, it may clear things up to see our blog post about how we typed our Cypress tests. You can still modify the code in your own Cypress tests by removing the type definitions and sticking to JavaScript-only syntax.

We will not cover how to set up your own test email inbox server (like Squirrelmail), but we will focus on automating these steps related to searching for emails, parsing matching email contents, and following email links. This should give you a better picture of what kind of functions to use and implement to handle these email flows, assuming you have a test email inbox server and your own credentials to connect to. 

How do we deal with email flows in Cypress tests?

For us to test out the entire email flows, we built out cy.task()plugins to:
  • Deal with connecting to and filtering through email inboxes for emails with a certain subject line
  • Retrieving a matching email’s body contents
  • Deleting emails from a user’s inbox without ever having to log in through Squirrelmail UI
We also went this route because we do not own or have control over the Squirrelmail UI, and it is not possible to visit more than one superdomain in a Cypress test since the URLs for the Squirrelmail UI live in a separate superdomain from our deployed frontend app.  

We first installed a library called “emailjs-imap-clientto help us set up an IMAP client to connect to our Squirrelmail inbox through some credentials and host configurations. Using this library, we encapsulated all the Squirrelmail related things inside a module called squirrelmail.ts that we would later import in our plugins/index.ts for our cy.task() function definitions.

Before tests involving emails run, we should tear down all the emails with the same subject line to avoid false positives in accidentally referring to an older email triggered in a prior test. To handle this use case, we implemented this task to delete all emails with a matching subject line in a user’s inbox as follows.

During our tests, we trigger an action that will lead to an email being sent to the user’s Squirrelmail email address and often need to wait for the email with a matching subject line to arrive in the user’s email inbox. This process takes anywhere from seconds to minutes, depending on how involved the backend processes are. We need to make sure to poll until it arrives or provide a timeout error in the test to let us know if something is not working or delayed in the mail send part. Since we already deleted emails with matching subject lines beforehand, we can be mostly certain it was triggered from our test run if it does return successfully.

Here is how we developed the functionality for waiting for an email with a specific subject line such as “Your email activity export” or “Sender verification” to arrive in a user’s email inbox.
So far:
  • We cleared out the user’s email inbox
  • The test runs and triggers an email to be sent to the user’s email inbox
  • We successfully waited for the email to arrive in the user’s email inbox
Now, we need to get that specific email’s body contents.

Thankfully, we can return the matching email’s body contents as a string that we would later have to parse through for the action link to return to the web app we control and own. The task plugin below searches through a user’s inbox for an email with a matching subject line and returns the body contents for us to use later.
As a brief reminder, we could not simply create page objects for the Squirrelmail pages, visit Squirrelmail through the UI, filter for a matching subject line, open up the email, click on the actionable link directly, and be on our merry way back to our web app—because we cannot visit multiple superdomains in the same Cypress test. It is also more of an antipattern to visit pages and applications you do not control or own.

After finding the matching email body contents we triggered in the test, we have to parse through the HTML content, find the action link, trigger an HTTP request to the link, and then follow the redirect back to our web application.

For parsing through the email HTML contents and finding the action link parts, we utilized another library called “cheerio”, which loads up the HTML string and allows us to call jQuery-like functions to extract out the action buttons or links we need. Once we parsed out the links, we make an HTTP request to the link with cy.request(), follow the redirect link back to the web app we control and own on the one superdomain, and proceed with verifying success states on the page we redirected to.

In your case, you may not need to trigger an HTTP request to the link and follow the response’s redirect if your link already points to the proper place. If the link URL already points to your web app directly, nothing is stopping you from extracting out the link path and doing a cy.visit(linkPath) to redirect back to your app. In the case of Twilio SendGrid links, the links may look like “...sendgrid.net?...” if you have link tracking on for your emails or “brandedlink.com” if you have link branding on. That is why we would need to make an HTTP request and extract the redirect path to do a cy.visit(redirectPath) because the immediate “href” of the links do not match up with our web app.

Below is an example of finding the link with cheerio, making an HTTP request to the link, and following the redirect.

Conclusion

We walked you through the many cy.task() plugin functions we implemented to do more read and delete actions with matching emails in our inboxes. We created these functions to properly reset the user’s email inbox state before we trigger these email flows in the web pages, wait for the emails to arrive in the inbox, and finally follow the links back to their success states. We summarize the key steps for your Cypress tests below:
  • Tear down all the emails with a certain subject line to avoid false positives with cy.task(“teardownMatchingEmails”).
  • Log in to a user through the API and then work through a set of steps through the UI to generate that email to be sent to the user’s email inbox.
  • Poll for the user’s email inbox to receive the email with the matching subject line through cy.task(“awaitEmailInSquirrelmailInbox”).
  • Read the email’s body contents with the matching subject line using cy.task(“squirrelmailSearchBySubject”).
  • Parse out the proper action link with the cheerio library by passing in the email body HTML string and searching through elements with a jQuery-like syntax.
  • Make an HTTP request on the parsed out email links through cy.request(“link”) and follow the redirect response back to the web app or visit the path if the links already match up with your superdomain with cy.visit(“emailLinkToWebApp”).
  • Verify success states occur or follow up with more UI steps on the page you own.
We hope this blog post encourages you to start testing thoroughly from start to finish. We used to avoid writing E2E tests with email flows, but thankfully, we figured out a way with these Cypress tests to save us a lot of time we would have spent in manually regression testing everything. We learned it is much more valuable to automate and test the whole happy path flow rather than parts of the flow—unless many steps rely on third-party services you do not own or control or it is not possible to reset the user back to a certain state reliably.

If you are interested in more blog posts related to what we learned about writing Cypress tests for our web applications, check out the following articles:

Most Popular


Send With Confidence

Partner with the email service trusted by developers and marketers for time-savings, scalability, and delivery expertise.

// in an emailjs-imap-client.d.ts so we can type the imap client functions ourselves
declare module 'emailjs-imap-client';
// squirrelmail.ts (where we define all of our email related task functions)
import ImapClient, { LOG_LEVEL_NONE as none } from 'emailjs-imap-client';
const Squirrelmail = () => {
const squirrelmailServer = "somesquirrelmailserver.net"
const port = 993;
// ...Other email related functions
// Teardown matching emails by search string aka subject line
// Needs credentials for user's email inbox
// This will return true if we successfully tear down all matching emails
// Otherwise, return false if it errors out and fails to do so
const teardownMatchingEmails = (
user: string,
pass: string,
searchString: string
) => {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // So we don't instafail because self-signed cert
const client = new ImapClient(squirrelMailServer, port, {
auth: {
user,
pass,
},
logLevel: none,
});
return client
.connect()
.then(() =>
// Find all emails with matching subject line and return their uids
client.search(
'inbox',
{
header: ['subject', searchString],
},
{ byUid: true }
)
)
.then((results: number[]) =>
results.map((uid: number) => {
console.log(
`Message ${uid} matches the subject of "${searchString}"`
);
return uid;
})
)
.then((mailUids: number[]) => {
console.log(
`Found ${mailUids.length} messages to clean up on ${squirrelMailServer}`
);
if (mailUids.length <= 0) {
return true; // Nothing to delete! Already cleaned up
}
mailUids.sort();
const purgeRange = mailUids.join(',');
console.log('Deleting message range: ', purgeRange);
// Pass in the list of matching email uids to delete
return client.deleteMessages('INBOX', purgeRange, { byUid: true });
})
.then(() => {
client.close();
return true;
})
.catch((err: Error) => {
client.close();
console.error(error, err);
return false;
});
};
return {
teardownMatchingEmails,
// ...Other task functions
};
};
export default Squirrelmail();
// plugins/index.ts
on('task', {
/**
* Deletes emails with matching subject line from user's squirrelmail inbox
* @param {string} user - squirrelmail username i.e. squirrelmailuser
* @param {string} pass - squirrelmail password i.e. squirrelmailpassword
* @param {string} searchString - subject i.e. "Can you help install these DNS records?"
* @returns {bool} - returns true upon successful teardown of matching emails; otherwise, returns false on error
*/
teardownMatchingEmails: ({
user,
pass,
searchString,
}: {
user: string;
pass: string;
searchString: string;
}) => Squirrelmail.teardownMatchingEmails(user, pass, searchString),
});
// Somewhere in our index.d.ts type definition file
task(
event: 'teardownMatchingEmails',
arg: {
user: string;
pass: string;
searchString: string;
},
options?: Partial<Loggable & Timeoutable>
): Chainable<boolean>;
// Somewhere in a spec.ts file
cy.task("teardownMatchingEmails", {
user: "squirrelmailusername",
pass: "squirrelmailpassword",
searchString: "subject line",
}).then((matchingEmailsDeleted) => {
if (matchingEmailsDeleted) {
cy.log("Deleted matching emails from inbox!");
} else {
cy.log("Failed to delete matching emails!");
}
});
// in an emailjs-imap-client.d.ts so we can type the imap client functions ourselves
declare module 'emailjs-imap-client';
// squirrelmail.ts (where we define all of our email related task functions
import ImapClient, { LOG_LEVEL_NONE as none } from 'emailjs-imap-client';
const Squirrelmail = () => {
const squirrelmailServer = "somesquirrelmailserver.net"
const port = 993;
// ...Other email related functions
// Poll for a given asynchronous function that returns a Promise
// over a certain timeout and check interval
function poll(
asyncFn: () => Promise<boolean>,
timeout: number = 10000,
interval: number = 5000
) {
const endTime = Number(new Date()) + timeout;
const checkCondition = (
resolve: (value: true) => void,
reject: (value: false) => void
) => {
const asyncPromise = asyncFn();
asyncPromise
.then((isSuccess) => {
const resolveTime = Number(new Date());
// If the condition is met, we're done!
if (isSuccess) {
resolve(true);
}
// If the condition isn't met but the timeout hasn't elapsed, go again
else if (resolveTime < endTime) {
setTimeout(checkCondition, interval, resolve, reject);
}
// Didn't match and too much time, reject false
else {
console.error(
`Failed to satisfy checkCondition in timeout duration`
);
reject(false);
}
})
.catch(() => {
const resolveTime = Number(new Date());
if (resolveTime < endTime) {
setTimeout(checkCondition, interval, resolve, reject);
} else {
console.error(
`Failed to satisfy checkCondition in timeout duration`
);
reject(false);
}
});
};
return new Promise(checkCondition);
}
const findMatchingMessage = (
user: string,
pass: string,
searchString: string
) => {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // So we don't instafail because self-signed cert
const client = new ImapClient(squirrelMailServer, port, {
auth: {
user,
pass,
},
logLevel: none,
});
return client
.connect()
.then(() =>
client.search(
'inbox',
{
header: ['subject', searchString],
},
{ byUid: true }
)
)
.then((matchedEmails?: number[]) => {
console.log('Matched emails: ', matchedEmails);
if (matchedEmails && matchedEmails.length > 0) {
console.log(`A message has arrived matching "${searchString}"`);
return true;
}
throw new Error(`No messages matched "${searchString}" yet`);
})
.then((result: boolean) => {
client.close();
return result;
})
.catch((err: Error) => {
client.close();
console.error(error, err);
return false;
});
};
const awaitEmail = (
user: string,
pass: string,
searchString: string,
timeoutInMs: number,
checkIntervalInMs: number
) =>
poll(
() => findMatchingMessage(user, pass, searchString),
timeoutInMs,
checkIntervalInMs
);
return {
awaitEmail,
// ...Other task functions
};
};
export default Squirrelmail();
// plugins/index.ts - where we define our task functions
on('task', {
/**
* Awaits matching email to arrive in squirrelmail inbox within timeout specified
* @param {string} user - squirrelmail username i.e. squirrelmailusername
* @param {string} pass - squirrelmail password i.e. squirrelmailpassword
* @param {string} searchString - subject search string i.e. "Can you help install these DNS records?"
* @param {integer} timeoutInMs - how long to wait for email to arrive in milliseconds i.e. 30000 => 30s
* @param {integer} checkIntervalInMs - how often to check for email in inbox i.e. 5000 => every 5s
* @returns {bool} - returns true if matching email arrived within timeout; otherwise, returns false on error
*/
awaitEmailInSquirrelmailInbox: ({
user,
pass,
searchString,
timeoutInMs,
checkIntervalInMs,
}: {
user: string;
pass: string;
searchString: string;
timeoutInMs: number;
checkIntervalInMs: number;
}) =>
Squirrelmail.awaitEmail(user, pass, searchString, timeoutInMs, checkIntervalInMs),
});
// Somewhere in our index.d.ts type definition file
task(
event: 'awaitEmailInSquirrelmailInbox',
arg: {
user: string;
pass: string;
searchString: string;
timeoutInMs: number;
checkIntervalInMs: number;
},
options?: Partial<Loggable & Timeoutable>
): Chainable<boolean>;
// Somewhere in a spec.ts file
cy.task("awaitEmailInSquirrelmailInbox", {
user: "squirrelmailusername",
pass: "squirrelmailpassword",
searchString: "subject line",
timeoutInMs: 120000,
checkIntervalInMs: 15000,
}).then((isEmailInInbox) => {
if (isEmailInInbox) {
cy.log("Email arrived in user's inbox!");
} else {
cy.log("Failed to receive email in user's inbox in time!");
}
});
// in an emailjs-imap-client.d.ts so we can type the imap client functions ourselves
declare module 'emailjs-imap-client';
// squirrelmail.ts (where we define all of our email related task functions
import ImapClient, { LOG_LEVEL_NONE as none } from 'emailjs-imap-client';
const Squirrelmail = () => {
const squirrelmailServer = "somesquirrelmailserver.net"
const port = 993;
// ...Other email related functions
const searchBySubject = (
user: string,
pass: string,
searchString: string,
sinceNumDaysAgo: number // To focus our search for emails within a certain number of days ago
) => {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // So we don't instafail because self-signed cert
const currentDate = new Date();
const sinceDate = new Date(
currentDate.setDate(currentDate.getDate() - sinceNumDaysAgo)
);
// Make sure date object has time set to 0 i.e. .setHours(0,0,0,0)
// or else it will have invalid search parameter errors
sinceDate.setHours(0, 0, 0, 0);
const client = new ImapClient(squirrelMailServer, port, {
auth: {
user,
pass,
},
logLevel: none,
});
return client
.connect()
.then(() =>
client.search(
'inbox',
{
header: ['subject', searchString],
since: sinceDate,
},
{ byUid: true }
)
)
.then((matchedEmails: number[]) => {
console.log(
`${matchedEmails.length} messages matches the subject of "${searchString}"`
);
if (matchedEmails.length > 0) {
// If there are multiple matched emails, let's return the latest one
return matchedEmails[matchedEmails.length - 1];
}
throw new Error(`No messages match the subject of "${searchString}"`);
})
.then((latestMatchedEmail: string) => {
console.log('Latest matched email: ', latestMatchedEmail);
return client.listMessages('inbox', latestMatchedEmail, ['body[]'], {
byUid: true,
});
})
.then(
(
listedEmail: {
'body[]': string;
}[]
) => {
if (listedEmail.length > 0) {
return listedEmail[0]['body[]'];
}
throw new Error('Failed to list out email body');
}
)
.then((emailBody: string) => {
client.close();
return emailBody;
})
.catch((err: Error) => {
console.log('Error: ', err);
return '';
});
};
return {
searchBySubject,
// ...Other task functions
};
};
export default Squirrelmail();
// plugins/index.ts - where we define our task functions
on('task', {
/**
* Searches for latest matching email by subject in squirrelmail inbox and returns raw email body if found
* @param {string} user - squirrelmail username i.e. squirrelmailuser
* @param {string} pass - squirrelmail password i.e. squirrelmailpassword
* @param {string} searchString - subject search string i.e. "Your email activity export"
* @param {integer} sinceNumDaysAgo - search for email since a certain number of days ago
* @returns {string} - raw email body string of latest email matching subject or empty string if failed to retrieve email in any way
*/
squirrelmailSearchBySubject: ({
user,
pass,
searchString,
sinceNumDaysAgo,
}: {
user: string;
pass: string;
searchString: string;
sinceNumDaysAgo: number;
}) =>
Squirrelmail.searchBySubject(user, pass, searchString, sinceNumDaysAgo),
});
// Somewhere in our index.d.ts type definition file
task(
event: 'squirrelmailSearchBySubject',
arg: {
user: string;
pass: string;
searchString: string;
sinceNumDaysAgo: number;
},
options?: Partial<Loggable & Timeoutable>
): Chainable<string>;
// Somewhere in a spec.ts file
cy.task("squirrelmailSearchBySubject", {
user: "squirrelmailusername",
pass: "squirrelmailpassword",
searchString: "subject line",
sinceNumDaysAgo: 1,
}).then((rawEmailBody) => {
if (rawEmailBody) {
cy.log("Matching email body contents!", rawEmailBody);
} else {
cy.log("Failed to find matching email and extract its body contents!");
}
});
// In some page_object.ts
// For this scenario, we have a Cypress test that triggers a download email to be sent to the user's inbox after performing
// some UI steps on the page i.e. clicking a button on the page to export data to a CSV for us to download
// We need to follow the download link in the email back to the web app we own and control to continue the test
redirectToCSVDownloadPageFromEmail({
user,
pass,
searchString,
}: {
user: string;
pass: string;
searchString: string;
}) {
// First, wait for export email to arrive in squirrelmail inbox
const squirrelmailTimeoutInMs = 120000;
const checkIntervalInMs = 15000;
// This wraps our cy.task("awaitEmailInSquirrelmailInbox") function call
return this.awaitEmailInSquirrelmailInbox({
user,
pass,
searchString,
timeoutInMs: squirrelmailTimeoutInMs,
checkIntervalInMs,
}).then(() =>
// We find the matching email and extract its email body string
cy
.task('squirrelmailSearchBySubject', {
user,
pass,
searchString,
// CSV downloads are valid for up to 3 days
// We'll check since two days ago to be safely within 0-3 days range
sinceNumDaysAgo: 2,
})
.then((rawEmailBody) => {
if (rawEmailBody) {
return this.parseDownloadLink(rawEmailBody);
}
cy.log(
'Failed to retrieve latest matching email by subject due to some error'
);
throw new Error('Test failed to get to CSV download page');
})
// Make an HTTP request to the download link that we find in the email body
.then((downloadLink) => this.requestCSVDownload(downloadLink))
// Extract out the 302 redirect path for us to visit back to our web app
.then((results) => this.redirectToCSVDownloadPage(results))
);
}
parseDownloadLink(rawEmailBody: string) {
// Parse out the HTML part of the email body for us to look through
let flattenedMIMEBody = rawEmailBody.replace(/(=\r\n)/g, '');
flattenedMIMEBody = flattenedMIMEBody.replace(/(=\n)/g, '');
flattenedMIMEBody = flattenedMIMEBody.replace(/(=3D)/g, '=');
const startOfHTML = flattenedMIMEBody.indexOf('<html>');
const endOfHTML = flattenedMIMEBody.indexOf('</html>') + '</html>'.length;
const emailHTML = flattenedMIMEBody.slice(startOfHTML, endOfHTML);
// Using cheerio, we can load up the HTML string of the email body
// and then easily filter through the elements for the Download link with jQuery-like functions
const $ = cheerio.load(emailHTML);
const downloadTag = $('a').filter(
(i, aTag) => $(aTag).text() === 'Download'
);
const downloadLink = downloadTag.attr('href') || '';
return downloadLink;
}
// We make an HTTP request to the link that looks like "...sendgrid.net..." or "brandedlink.com"
requestCSVDownload(downloadLink: string) {
return cy.request(downloadLink);
}
// After making the HTTP request to the download link, we extract out the redirect path for
// us to cy.visit() back to our web app
redirectToCSVDownloadPage(results: any) {
const redirect = results.redirects[0];
// String looks like 302: https://staging.app.com/download/path
const [redirectStatus, redirectRoute] = redirect.split(' ');
// We extract out the /download/path plus any query params after it
const redirectURL = new URL(redirectRoute);
const csvDownloadPath = `${redirectURL.pathname}${redirectURL.search}`;
// cy.visit(/download/path?token=<token>) to go back to our web app on the same superdomain
return cy.visit(csvDownloadPath);
}