Gleb Bahmutov PRO
JavaScript ninja, image processing expert, software quality fanatic
https://lizkeogh.com/2019/07/02/off-the-charts/
+3 degrees Celsius will be the end and we are close
Hello <% Name %>,
Today our company is happy to announce ...
A huge sale on all these items:
Do not miss this opportunity, it won't last!
C / C++ / C# / Java / CoffeeScript / JavaScript / Node / Angular / Vue / Cycle.js / functional programming / testing
click on the button
should take you to localhost:3000/confirm
The conference app Attendify does this!
Historical tip: SMTP was born in 1981
POP3 or IMAP
to fetch emails
const nodemailer = require('nodemailer') const host = process.env.SMTP_HOST || 'localhost' const port = Number(process.env.SMTP_PORT || 7777) const transporter = nodemailer.createTransport({ host, port, secure: port === 456, }) transporter.sendMail({ from: '"Registration system" <reg@company.co>', to: email, subject: 'Confirmation code 1οΈβ£2οΈβ£3οΈβ£', text: 'Your confirmation code is 654agc', html: confirmationEmailHTML, })
const nodemailer = require('nodemailer') const host = process.env.SMTP_HOST || 'localhost' const port = Number(process.env.SMTP_PORT || 7777) const transporter = nodemailer.createTransport({ host, port, secure: port === 456, }) transporter.sendMail({ from: '"Registration system" <reg@company.co>', to: email, subject: 'Confirmation code 1οΈβ£2οΈβ£3οΈβ£', text: 'Your confirmation code is 654agc', html: confirmationEmailHTML, })
const ms = require('smtp-tester') // starts the SMTP server at localhost:7777 const port = 7777 const mailServer = ms.init(port) mailServer.bind((addr, id, email) => { // email.body is plain text // email.html is rich HTML })
SMTP_HOST=localhost npm start
SMTP_HOST=localhost npm start
50 people. Atlanta, Philly, Boston, NYC, the world
Fast, easy and reliable testing for anything that runs in a browser
$ npm install -D cypress
const ms = require('smtp-tester') module.exports = (on, config) => { // starts the SMTP server at localhost:7777 const port = 7777 const mailServer = ms.init(port) let lastEmail = {} mailServer.bind((addr, id, email) => { // store the email by the receiver email lastEmail[email.headers.to] = { body: email.body, html: email.html, } }) on('task', { getLastEmail(userEmail) { return lastEmail[userEmail] || null }, }) }
cypress/plugin/index.js
The test can get the last email for the given user
describe('Email confirmation', () => { beforeEach(() => { cy.task('resetEmails') }) it('sends an email with code', () => { cy.visit('/') cy.get('#name').type('Joe Bravo') cy.get('#email').type('joe@acme.io') cy.get('#company_size').select('3') cy.get('button[type=submit]').click() cy.location('pathname').should('equal', '/confirm') // by now the SMTP server has probably received the email cy.task('getLastEmail', 'joe@acme.io') .its('body') // check the plain email text .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') }) }) })
describe('Email confirmation', () => { beforeEach(() => { cy.task('resetEmails') }) it('sends an email with code', () => { cy.visit('/') cy.get('#name').type('Joe Bravo') cy.get('#email').type('joe@acme.io') cy.get('#company_size').select('3') cy.get('button[type=submit]').click() cy.location('pathname').should('equal', '/confirm') // by now the SMTP server has probably received the email cy.task('getLastEmail', 'joe@acme.io') .its('body') // check the plain email text .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') }) }) })
describe('Email confirmation', () => { beforeEach(() => { cy.task('resetEmails') }) it('sends an email with code', () => { cy.visit('/') cy.get('#name').type('Joe Bravo') cy.get('#email').type('joe@acme.io') cy.get('#company_size').select('3') cy.get('button[type=submit]').click() cy.location('pathname').should('equal', '/confirm') // by now the SMTP server has probably received the email cy.task('getLastEmail', 'joe@acme.io') .its('body') // check the plain email text .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') }) }) })
describe('Email confirmation', () => { beforeEach(() => { cy.task('resetEmails') }) it('sends an email with code', () => { cy.visit('/') cy.get('#name').type('Joe Bravo') cy.get('#email').type('joe@acme.io') cy.get('#company_size').select('3') cy.get('button[type=submit]').click() cy.location('pathname').should('equal', '/confirm') // by now the SMTP server has probably received the email cy.task('getLastEmail', 'joe@acme.io') .its('body') // check the plain email text .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') }) }) })
describe('Email confirmation', () => { beforeEach(() => { cy.task('resetEmails') }) it('sends an email with code', () => { cy.visit('/') cy.get('#name').type('Joe Bravo') cy.get('#email').type('joe@acme.io') cy.get('#company_size').select('3') cy.get('button[type=submit]').click() cy.location('pathname').should('equal', '/confirm') // by now the SMTP server has probably received the email cy.task('getLastEmail', 'joe@acme.io') .its('body') // check the plain email text .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') }) }) })
describe('Email confirmation', () => { beforeEach(() => { cy.task('resetEmails') }) it('sends an email with code', () => { cy.visit('/') cy.get('#name').type('Joe Bravo') cy.get('#email').type('joe@acme.io') cy.get('#company_size').select('3') cy.get('button[type=submit]').click() cy.location('pathname').should('equal', '/confirm') // by now the SMTP server has probably received the email cy.task('getLastEmail', 'joe@acme.io') .its('body') // check the plain email text .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') }) }) })
describe('Email confirmation', () => { beforeEach(() => { cy.task('resetEmails') }) it('sends an email with code', () => { cy.visit('/') cy.get('#name').type('Joe Bravo') cy.get('#email').type('joe@acme.io') cy.get('#company_size').select('3') cy.get('button[type=submit]').click() cy.location('pathname').should('equal', '/confirm') // by now the SMTP server has probably received the email cy.task('getLastEmail', 'joe@acme.io') .its('body') // check the plain email text .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') }) }) })
cypress/integration/code-spec.js
Plaintext email, local SMTP test server
describe('Email confirmation', () => { beforeEach(() => { cy.task('resetEmails') }) it('sends an HTML email', () => { cy.visit('/') cy.get('#name').type('Joe Bravo') cy.get('#email').type('joe@acme.io') cy.get('#company_size').select('3') cy.get('button[type=submit]').click() cy.location('pathname').should('equal', '/confirm') // by now the SMTP server has probably received the email cy.task('getLastEmail', 'joe@acme.io') .its('html') // check the HTML email text .then((html) => { // load the email in the current test browser cy.document().invoke('write', html) }) 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') cy.get('#confirmation_code').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') }) })
describe('Email confirmation', () => { beforeEach(() => { cy.task('resetEmails') }) it('sends an HTML email', () => { cy.visit('/') cy.get('#name').type('Joe Bravo') cy.get('#email').type('joe@acme.io') cy.get('#company_size').select('3') cy.get('button[type=submit]').click() cy.location('pathname').should('equal', '/confirm') // by now the SMTP server has probably received the email cy.task('getLastEmail', 'joe@acme.io') .its('html') // check the HTML email text .then((html) => { // load the email in the current test browser cy.document().invoke('write', html) }) 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') cy.get('#confirmation_code').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') }) })
describe('Email confirmation', () => { beforeEach(() => { cy.task('resetEmails') }) it('sends an HTML email', () => { cy.visit('/') cy.get('#name').type('Joe Bravo') cy.get('#email').type('joe@acme.io') cy.get('#company_size').select('3') cy.get('button[type=submit]').click() cy.location('pathname').should('equal', '/confirm') // by now the SMTP server has probably received the email cy.task('getLastEmail', 'joe@acme.io') .its('html') // check the HTML email text .then((html) => { // load the email in the current test browser cy.document().invoke('write', html) }) 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') cy.get('#confirmation_code').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') }) })
describe('Email confirmation', () => { beforeEach(() => { cy.task('resetEmails') }) it('sends an HTML email', () => { cy.visit('/') cy.get('#name').type('Joe Bravo') cy.get('#email').type('joe@acme.io') cy.get('#company_size').select('3') cy.get('button[type=submit]').click() cy.location('pathname').should('equal', '/confirm') // by now the SMTP server has probably received the email cy.task('getLastEmail', 'joe@acme.io') .its('html') // check the HTML email text .then((html) => { // load the email in the current test browser cy.document().invoke('write', html) }) 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') cy.get('#confirmation_code').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') }) })
cypress/integration/html-email-spec.js
HTML email, local SMTP test server
β Our hand-rolled email HTML is testable Tip: use https://maizzle.com/
// by now the SMTP server has probably received the email cy.task('getLastEmail', 'joe@acme.io') .its('html') // check the HTML email text .then((html) => { cy.document().invoke('write', html) }) cy.screenshot('2-the-email')
cypress/integration/spec.js
Tip: save screenshots at each milestone
Image saved during the test using cy.screenshot
(if we cannot point Backend API at the smtp-tester)
const nodemailer = require('nodemailer') const host = process.env.SENDGRID_HOST const port = Number(process.env.SENDGRID_PORT) const secure = port === 465 const auth = { user: process.env.SENDGRID_USER, pass: process.env.SENDGRID_PASSWORD, } const transporter = nodemailer.createTransport({ host, port, secure, auth, })
src/emailer.js
// use Nodemailer to get an Ethereal email inbox // https://nodemailer.com/about/ const nodemailer = require('nodemailer') const testAccount = await nodemailer.createTestAccount()
cypress/plugin/index.js
const makeEmailAccount = require('./email-account') module.exports = async (on) => { const emailAccount = await makeEmailAccount() on('task', { getUserEmail() { return emailAccount.email }, getLastEmail() { return emailAccount.getLastEmail() }, }) }
cypress/plugin/index.js
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() // hmm, the email might be delayed cy.task('getLastEmail') .its('html') ... })
cypress/integration/spec.js
The email does not arrive in time to the email inbox
// https://github.com/bahmutov/cypress-recurse const { recurse } = require('cypress-recurse') it('sends confirmation code', () => { ... cy.get('button[type=submit]').click() // 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') ... })
cypress/integration/spec.js
Retry fetching an email
Design the email using 3rd party editor
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
src/emailer.js
const initEmailer = require('../../src/emailer') const emailer = await initEmailer() await emailer.sendTemplateEmail({ to: email, // the ID of the dynamic template we have designed template_id: 'd-9b1e07a7d2994b14ae394026a6ccc997', dynamic_template_data: { code: confirmationCode, username: name, confirm_url: 'http://localhost:3000/confirm', }, })
pages/api/register.js
An email that accidentally used a wrong field in the greeting line
// 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) }) cy.log('**email has the user name**') cy.contains(`Dear ${userName},`).should('be.visible')
cypress/integration/spec.js
β SendGrid generates emails compatible with most email clients
βοΈ The HTML markup makes it hard to select particular fields in the email
cy.contains('a', 'Enter the confirmation code') .should('be.visible') .invoke('text') .then((text) => Cypress._.last(text.split(' '))) .then((code) => { // click on the button // enter the code })
cypress/integration/spec.js
// https://docs.percy.io/docs/cypress require('@percy/cypress') describe('Email confirmation', () => { 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.screenshot('1 - registration screen') cy.percySnapshot('1 - registration screen') cy.get('button[type=submit]').click() ... }) })
// https://docs.percy.io/docs/cypress require('@percy/cypress') describe('Email confirmation', () => { 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.screenshot('1 - registration screen') cy.percySnapshot('1 - registration screen') cy.get('button[type=submit]').click() ... }) })
cypress/integration/spec.js
// https://docs.percy.io/docs/cypress require('@percy/cypress') describe('Email confirmation', () => { 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.screenshot('1 - registration screen') cy.percySnapshot('1 - registration screen') cy.get('button[type=submit]').click() ... }) })
cypress/integration/spec.js
// https://docs.percy.io/docs/cypress require('@percy/cypress') describe('Email confirmation', () => { 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.percySnapshot('1 - registration screen') cy.get('button[type=submit]').click() ... }) })
cypress/integration/spec.js
cy.task('getLastEmail') .its('html') .then((html) => { cy.document().invoke('write', html) }) cy.percySnapshot('2 - email')
cypress/integration/spec.js
cy.task('getLastEmail') .its('html') .then((html) => { cy.document().invoke('write', html) }) cy.percySnapshot('2 - email')
cypress/integration/spec.js
Changing confirmation code breaks the image comparison
π Cannot use image diffing with dynamic data
cypress/integration/spec.js
.then((code) => { cy.log(`**confirm the code ${code} works**`) expect(code, 'confirmation code') .to.be.a('string') .and.have.length.gt(5) // replace the dynamic confirmation code with constant text // since we already validated the code cy.get('@codeLink').invoke( 'text', 'Enter the confirmation code abc1234', ) cy.contains('strong', new RegExp('^' + code + '$')) .invoke('text', 'abc1234') cy.percySnapshot('2 - email') })
.then((code) => { cy.log(`**confirm the code ${code} works**`) expect(code, 'confirmation code') .to.be.a('string') .and.have.length.gt(5) // replace the dynamic confirmation code with constant text // since we already validated the code cy.get('@codeLink').invoke( 'text', 'Enter the confirmation code abc1234', ) cy.contains('strong', new RegExp('^' + code + '$')) .invoke('text', 'abc1234') cy.percySnapshot('2 - email') })
.then((code) => { cy.log(`**confirm the code ${code} works**`) expect(code, 'confirmation code') .to.be.a('string') .and.have.length.gt(5) // replace the dynamic confirmation code with constant text // since we already validated the code cy.get('@codeLink').invoke( 'text', 'Enter the confirmation code abc1234', ) cy.contains('strong', new RegExp('^' + code + '$')) .invoke('text', 'abc1234') cy.percySnapshot('2 - email') })
If you need to verify the actual code: https://glebbahmutov.com/blog/verify-phone-part-one/
π Consistent data generates a matching image for visual testing
If you need to verify the actual code: https://glebbahmutov.com/blog/verify-phone-part-one/
π₯ slides.com/bahmutov/email-testing
π¦ @bahmutov
By Gleb Bahmutov
Every time a new user registers for your service, your application probably sends a confirmation email.How does that email look in the user's browser? How does it look on a mobile screen? And most importantly: does it work? In this presentation, I will show the full end-to-end open-source testing procedure for validating HTML emails. We will test the email functionality, accessibility, and visual look and style to ensure that our users are not silently dropping out due to a broken email subsystem. Presented at InfobipShift 2021, video at https://youtu.be/igj8OQhY0Jg
JavaScript ninja, image processing expert, software quality fanatic