Cypress And Twilio

Retry checking Twilio SMS inbox to receive SMS messages in your Cypress tests.

I have signed up for Twilio SMS service. Now I have a valid phone number to use during my end-to-end tests. To check the messages using API, Twilio settings page shows the account ID, the auth token, and the phone number (I set all field HTML types to "password" to hide them in this screenshot)

Twilio account settings

I will put these settings in my .as-a.ini file that I never check into the source code.

.as-a.ini
1
2
3
4
[cypress-twilio-example]
TWILIO_ACCOUNT_SID=...
TWILIO_AUTH_TOKEN=...
TWILIO_PHONE_NUMBER=...

When I start Cypress, I load the above environment values using my as-a tool

1
2
# assuming my current folder is "cypress-twilio-example"
$ as-a . npx cypress open

🎁 You can find my source code in the repo bahmutov/cypress-twilio-example.

Start the project

Let's access the Twilio SMS messages from the Cypress test. We will need to use HTTP API to check the messages, this code runs in Node in your Cypress project. For example, we can begin writing a task code:

cypress/twilio/check-sms.mjs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export function getTwilioInfo() {
// load up twilio info
const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID
if (!TWILIO_ACCOUNT_SID) {
throw new Error('TWILIO_ACCOUNT_SID is not defined')
}
const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN
if (!TWILIO_AUTH_TOKEN) {
throw new Error('TWILIO_AUTH_TOKEN is not defined')
}

const TWILIO_PHONE_NUMBER = process.env.TWILIO_PHONE_NUMBER
if (!TWILIO_PHONE_NUMBER) {
throw new Error('TWILIO_PHONE_NUMBER is not defined')
}
return { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER }
}

export async function getTwilioSms() {
// TODO: call Twilio API and get the code
}
cypress.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { defineConfig } from 'cypress'

import { getTwilioInfo, getTwilioSms } from './cypress/twilio/check-sms.mjs'

export default defineConfig({
e2e: {
// baseUrl, etc
supportFile: false,
fixturesFolder: false,
setupNodeEvents(on, config) {
// make sure we have Twilio account values
getTwilioInfo()

on('task', {
getTwilioSms,
})
},
},
})

Twilio SDK

To check the messages, let's install Twilio Node.js SDK following Twilio Node Quick Start Guide.

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

We can list the last 10 SMS messages

cypress/twilio/check-sms.mjs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import twilio from 'twilio'

export function getTwilioInfo() {
...
return { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER }
}

export async function getTwilioSms() {
const { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER } =
getTwilioInfo()
const client = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)
const messages = await client.messages.list({
to: TWILIO_PHONE_NUMBER,
limit: 10,
})
return messages
}

To quickly test message retrieval, I create another file to simply call getTwilioSms from Node without even opening Cypress

cypress/twilio/check-sms-cli.mjs
1
2
3
4
5
6
7
8
9
import { getTwilioSms } from './check-sms.mjs'

getTwilioSms()
.then((messages) => {
console.log(messages)
})
.catch((err) => {
console.error(err)
})

By default, there are no messages

1
2
$ as-a . node ./cypress/twilio/check-sms-cli.mjs
[]

Let's text "Hello 1234" to the Twilio test number. Now we are getting a message in the list

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ as-a . node ./cypress/twilio/check-sms-cli.mjs
[
{
body: 'Hello 1234',
numSegments: '1',
direction: 'inbound',
from: '...',
to: '...',
dateUpdated: 2023-05-09T19:57:20.000Z,
price: null,
errorMessage: null,
...
dateSent: 2023-05-09T19:57:20.000Z,
dateCreated: 2023-05-09T19:57:20.000Z,
errorCode: null,
...
}
]

If we send another message "Hello 5678", it will become the first message in the list

1
2
3
4
5
6
7
8
9
10
11
$ as-a . node ./cypress/twilio/check-sms-cli.mjs
[
{
body: 'Hello 5678',
...
},
{
body: 'Hello 1234',
...
}
]

Ok, great. Let's limit ourselves to the last message

1
2
3
4
5
6
7
8
9
10
11
export async function getTwilioSms() {
const { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER } =
getTwilioInfo()
const client = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)
const messages = await client.messages.list({
to: TWILIO_PHONE_NUMBER,
limit: 10,
})
// assume there is at least one message
return messages[0]?.body
}

We can call this function as a Cypress task from the browser test.

cypress/e2e/spec.cy.js
1
2
3
4
5
6
7
8
9
10
it('retrieves the last SMS', () => {
cy.log('Checking SMS')
cy.task('getTwilioSms')
// confirm the SMS text follows the expected format
.should('match', /Hello \d{4}/)
// extract the code from the string
.invoke('match', /Hello (?<code>\d{4})/)
.its('groups.code')
.should('be.a', 'string')
})

Let's start Cypress

1
$ as-a . npx cypress open

The test runs and returns the last message and extracts the text code from it

The extracted code

Filter messages by time

We probably do not want to retrieve the last existing message. A test probably does some action that causes a message to be sent, and then needs to confirm it. Thus we want to limit our messages to return items received after a certain time. For example, we could retrieve messages received in the last minute. Using Luxon library to easily subtract a minute:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export async function getTwilioSms() {
const { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER } =
getTwilioInfo()
const client = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)

// retrieve one message sent in the last minute
const now = DateTime.now()
const prev = now.minus({ minutes: 1 })
const messages = await client.messages.list({
to: TWILIO_PHONE_NUMBER,
limit: 1,
dateSent: prev.toJSDate(),
})
// assume there is at least one message
return messages[0]?.body
}

This is where the things break down. A message might take a while to get delivered. As written now, there will be no message and the function getTwilioSms will return undefined, causing Cypress to fail the test with an error.

The test fails if there is no message

We can retry checking the SMS inbox in two ways.

Retry the cy.task command

Let's update our getTwilioSms to NOT fail if there are no messages. We must return null to signal to Cypress that there is no value, but it is ok. Thus we can change our return statement:

1
2
// assume there is at least one message
return messages[0]?.body || null

If we do not have a string message, we should wait a little and call Twilio again. We can do this using cypress-recurse plugin:

cypress/e2e/spec.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { recurse } from 'cypress-recurse'

it('retrieves the last SMS', () => {
cy.log('Checking SMS')
// call the first function that calls cy.task
// until the second predicate function returns true
recurse(() => cy.task('getTwilioSms'), Cypress._.isString, {
log: false, // no need to log every iteration
timeout: 60_000, // retry for up to 1 minute
delay: 5_000, // wait 5 seconds between the tries
})
// recurse yields the result of the command
// which in our case is the result from the task
// confirm the SMS text follows the expected format
.should('match', /Hello \d{4}/)
// extract the code from the string
.invoke('match', /Hello (?<code>\d{4})/)
.its('groups.code')
.should('be.a', 'string')
})

Suppose we text Hello 0001 while the test is running, forcing it to call the task getTwilioSms a few times until it yields a string.

The test retries fetching the SMS until a message is received

Retry calling Twilio API

Instead of retrying the () => cy.task('getTwilioSms') function until it yields a value that passes the predicate Cypress._.isString, we can retry the code in the task itself. The same plugin cypress-recurse has a plain JavaScript function retry you can use to run synchronous or asynchronous functions.

cypress/twilio/check-sms.mjs
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
51
52
53
54
import twilio from 'twilio'
import { DateTime } from 'luxon'
import { retry } from 'cypress-recurse/src/retry.js'

export function getTwilioInfo() {
...
}

export async function getTwilioSms() {
...
}

export async function retryTwilioSms() {
const { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER } =
getTwilioInfo()
const client = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)

// retrieve one message sent in the last minute
const now = DateTime.now()
const prev = now.minus({ minutes: 1 }).toJSDate()

// the result we will return after retries
let lastMessage

await retry(
// the first argument is a function to retry
// in our case it yields the list of messages
() =>
client.messages.list({
to: TWILIO_PHONE_NUMBER,
limit: 1,
dateSent: prev,
}),
// the second function is the predicate
// checking the value resolved by the first function
(messages) => {
if (!messages.length) {
// retry again
return false
}
// success!
lastMessage = messages[0].body
return true
},
// retrying options
{
limit: 20, // retry 20 times
delay: 5_000, // with 5 second pauses between attempts
log: true, // log basic info about each attempt
},
)

return lastMessage
}

The retry(fn, predicate, options) function follows similar syntax to the recurse function, but is implemented using plain JavaScript, and thus can work in Node or browser without using Cypress commands. We can test this function directly by running it from Node. While it is retrying, we can send SMS message Hello 0909 and the retries stop.

1
2
3
4
5
6
7
$ as-a . node ./cypress/twilio/check-sms-cli.mjs
attempt 1 of 20 was 🚨
attempt 2 of 20 was 🚨
attempt 3 of 20 was 🚨
attempt 4 of 20 was 🚨
attempt 5 of 20 was ✅
Hello 0909

Let's use the new retryTwilioSms as a task

cypress.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { defineConfig } from 'cypress'

import {
getTwilioInfo,
getTwilioSms,
retryTwilioSms,
} from './cypress/twilio/check-sms.mjs'

export default defineConfig({
e2e: {
// baseUrl, etc
supportFile: false,
fixturesFolder: false,
setupNodeEvents(on, config) {
// make sure we have Twilio account values
getTwilioInfo()

on('task', {
getTwilioSms, // no retries
retryTwilioSms, // with retries
})
},
},
})

The spec can simply assume the task succeeds, but it needs to set the desired maximum task timeout.

cypress/e2e/spec2.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
it('retrieves the last SMS using retryTwilioSms', () => {
cy.log('Checking SMS')
cy.task('retryTwilioSms', {
timeout: 60_000, // let the task run up to 1 minute
})
// recurse yields the result of the command
// which in our case is the result from the task
// confirm the SMS text follows the expected format
.should('match', /Hello \d{4}/)
// extract the code from the string
.invoke('match', /Hello (?<code>\d{4})/)
.its('groups.code')
.should('be.a', 'string')
})

The test calls the cy.task retryTwilioSms and the code inside the task retries

We can see the retries again in the terminal output, not in the Cypress Console Log

1
2
3
4
attempt 1 of 20 was 🚨
attempt 2 of 20 was 🚨
attempt 3 of 20 was 🚨
attempt 4 of 20 was ✅

So if you need generic code retries in Node for your End-to-End tests, check out the retry function from cypress-recurse plugin.

🎓 The cypress-recurse plugin is explained in several lessons in my Cypress Plugins course.

Bonus: extract function

Using a local variable to save the value from the predicate function is cumbersome. We can use a separate extract function to take the result yielded by the successful function (the result that passed the predicate) and extract the value to return. Let's rewrite the task

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
export async function retryTwilioSms() {
const { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER } =
getTwilioInfo()
const client = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)

// retrieve one message sent in the last minute
const now = DateTime.now()
const prev = now.minus({ minutes: 1 }).toJSDate()

const lastMessage = await retry(
// the first argument is a function to retry
// in our case it yields the list of messages
() =>
client.messages.list({
to: TWILIO_PHONE_NUMBER,
limit: 1,
dateSent: prev,
}),
// the second function is the predicate
// checking the value resolved by the first function
(messages) => messages.length > 0,
// retrying options
{
limit: 20, // retry 20 times
delay: 5_000, // with 5 second pauses between attempts
log: true, // log basic info about each attempt
extract(messages) {
// success, let's extract the value from
// the result of the last function call
return messages[0].body
},
},
)

return lastMessage
}