Writing a Custom Cypress Command

A tutorial explaining how to write a custom Cypress command with retry-ability.

This blog post teaches you how to write a reusable Cypress Custom Command.

Let's take a page with a form. There are a couple of input fields. Every input field has a label.

1
2
3
4
5
6
<form>
<label for="fname">First name:</label><br />
<input type="text" id="fname" name="fname" /><br />
<label for="lname">Last name:</label><br />
<input type="text" id="lname" name="lname" />
</form>

🔎 You can find the source code for this blog post in the "Recipes" section of glebbahmutov.com/cypress-examples.

It is simple to select and element by its attribute, like id or name using cy.get command.

1
2
3
// we can find the input field by id and name using the standard cy.get
cy.get('#fname').should('have.attr', 'type', 'text')
cy.get('input[name=fname]').should('have.attr', 'id', 'fname')

What if we want to find the input element by the label's text? We could first find the label using the cy.contains command, then grab the for attribute, and then pass it to the cy.get command.

1
2
3
4
5
6
7
8
9
cy.contains('label', 'First name:')
.invoke('attr', 'for')
.should('equal', 'fname')
.then((id) => {
// note that the last Cypress command inside the `cy.then`
// changes the yielded subject to its result
cy.get('#' + id)
})
.should('have.attr', 'name', 'fname')

If you have to find multiple input elements by the label, you can make the above code fragment reusable by making it into a function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// we return the Cypress chain so the test can attach more commands
const getInputByLabel = (label) => {
return cy
.contains('label', label)
.invoke('attr', 'for')
.then((id) => {
// no need to even return the `cy.get`
// Cypress automatically yields it
cy.get('#' + id)
})
}

// let's type the last name
getInputByLabel('Last name:').type('Smith')
// we can also check the value
getInputByLabel('Last name:').should('have.value', 'Smith')

Note the getInputByLabel returns the entire Cypress command chain, thus we can add more commands or assertions. A reusable function is simple to write and use, and is my "go to" method for factoring out the common Cypress code.

Simple custom command

You can also place common testing code into a custom command. In the simplest case, the command might simple contain the exactly the same code as the reusable function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Cypress.Commands.add('getByLabel', (label) => {
// you can disable individual command logging
// by passing {log: false} option to every
// command inside "getByLabel"
cy.log('**getByLabel**')
cy.contains('label', label)
.invoke('attr', 'for')
.then((id) => {
cy.get('#' + id)
})
})
// let's use the custom command to act on the first name
cy.getByLabel('First name:').should('have.value', '').type('Joe')
cy.getByLabel('First name:').should('have.value', 'Joe')

Notice that we did not have to return the command chain from the custom command, this is done automatically for every command.

Simple command limitation

The limitation of this simple command is its lack of retry-ability. The final input element yielded by the cy.get('#' + id) command should pass the assertion .should('have.value', ''). What happens if the input element has some other value? Let's see:

Custom command automatically retries the last command in it

Notice that because we have HTML markup <input type="text" id="fname" name="fname" value="Gleb" /> the last command cy.get ... was retried automatically, since the assertion was failing. So the custom command automatically retries the last command inside of it. What if we want to retry the entire command starting with cy.contains?

Here is an example when it is necessary. Imagine the application starts with some static HTML that later hydrates. The initial element IDs might be invalidated - because the entire or part of the page is re-rendered. Let's say the initial HTML is simple:

1
2
3
4
5
6
<form>
<label for="fname">First name:</label><br />
<input type="text" id="fname" name="fname" value="initial" /><br />
<label for="lname">Last name:</label><br />
<input type="text" id="lname" name="lname" />
</form>

A second after the page loads, the app re-renders the form. Notice how the IDs are now realistic.

1
2
3
4
5
6
7
8
9
10
11
function hydrate() {
const form = document.querySelector('form')
form.innerHTML = `
<div>Hydrated!</div>
<label for="fname111">First name:</label><br />
<input type="text" id="fname111" name="fname111" /><br />
<label for="lname222">Last name:</label><br />
<input type="text" id="lname222" name="lname222" />
`
}
setTimeout(hydrate, 1000)

What happens to our custom command? Well, it is still tries to find an element with ID 'fname' using cy.get('#' + id) code.

The test fails to find the input element

So what can we do it retry the cy.contains and other commands inside the custom command?

Custom command with retry-ability

We need to write a custom command that has logic to find the input element, then checks all upcoming assertions. If the assertions fail, then it retries the entire logic from the start again. Let's write this command in parts.

First, we need to write a command placeholder. We will log the text passed to the command:

1
2
3
4
5
6
7
8
Cypress.Commands.add('getByLabel2', (label, options = {}) => {
const log = {
name: 'getByLabel2',
message: label,
}
Cypress.log(log)
// to be continued
})

Let's write the logic to find an element by its text. We need to search the application's document object. To access it we can use the undocumented cy.state command, and then we can use jQuery commands to find the element.

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
Cypress.Commands.add('getByLabel2', (label, options = {}) => {
const log = {
name: 'getByLabel2',
message: label,
}
Cypress.log(log)

// returns the document object of the application under test
const document = cy.state('document')

// this function just tries to find the element
// we cannot use Cypress commands - aside from static ones,
// but we can use normal DOM JavaScript and jQuery methods
const getValue = () => {
const $label = Cypress.$(document).find(
'label:contains("' + label + '")',
)
if (!$label.length) {
return
}
const forId = $label.attr('for')
if (!forId) {
return
}
const input = document.getElementById(forId)
return input
}

// to be continued
})

The function getValue is ready to be tried again and again. Let's finish our custom command. We need to call getValue, get the element and then verify the upcoming assertions. Here is the end of the custom command

1
2
3
4
5
6
7
8
9
return Cypress.Promise.try(getValue).then(($el) => {
// important: pass a jQuery object to cy.verifyUpcomingAssertions
if (!Cypress.dom.isJquery($el)) {
$el = Cypress.$($el)
}
return cy.verifyUpcomingAssertions($el, options, {
onRetry: resolveValue,
})
})

The above fragment inside the custom command does the trick. One last tip: the finished command can place the found element into the log object.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
...
const resolveValue = () => {
return Cypress.Promise.try(getValue).then(($el) => {
// important: pass a jQuery object to cy.verifyUpcomingAssertions
if (!Cypress.dom.isJquery($el)) {
$el = Cypress.$($el)
}
return cy.verifyUpcomingAssertions($el, options, {
onRetry: resolveValue,
})
})
}

return resolveValue().then((el) => {
// add console props method, which is invoked
// when the user clicks on the command
log.consoleProps = () => {
return {
result: el,
}
}

return el
})

Let's verify our custom command finds the input by the label's text in the hydration example.

The test correctly retries the entire logic chain

If you click on the command getByLabel2 in the Command Log, you will find the details and the element itself printed to the console.

The found element

Note: if you try to use Cypress commands like cy.contains inside getValue function, Cypress detects it and throws an error.

Final thoughts

  • only the last Cypress command is retried when the assertions that follow it fail
  • you can write the logic to find the element using cy.state('document') and jQuery / DOM commands
  • to verify the upcoming assertions and retry your logic to find the element, use cy.verifyUpcomingAssertions
1
2
3
4
5
6
7
8
9
10
11
const resolveValue = () => {
return Cypress.Promise.try(getValue).then(($el) => {
// important: pass a jQuery object to cy.verifyUpcomingAssertions
if (!Cypress.dom.isJquery($el)) {
$el = Cypress.$($el)
}
return cy.verifyUpcomingAssertions($el, options, {
onRetry: resolveValue,
})
})
}

Happy testing!

Update 1: publish command to NPM

Read How to Publish Custom Cypress Command on NPM where I describe how to correctly publish the custom command via NPM registry, including loading with custom command type.

Update 2: add local types

To provide intelligent code completion for our new custom command, I wrote src/index.d.ts file

src/index.d.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
// load type definitions that come with Cypress module
/// <reference types="cypress" />
declare namespace Cypress {
interface Chainable {
/**
* Finds a form element using the label's text
* @param label string
* @example
* cy.getByLabel('First name:').type('Joe')
*/
getByLabel(label: string): Chainable<Element>
}
}

Typically in a JavaScript project, every spec file includes the following line to load global Cypress definitions

1
/// <reference types="cypress">

In our case, the specs exercise the new cy.getByLabel command. Thus they need to "know" about it. In the cypress/integration/spec.js I replaced the reference types=cypress comment with reference path=... pointing at the src/index.d.ts file:

cypress/integration/spec.js
1
/// <reference path="../../src/index.d.ts" />

Note that we no longer need the reference types="cypress" line - because it is loaded by src/index.d.ts file.

Next, you can add the "types" property to the "package.json" file, pointing at the index.d.ts file or a folder with it. For example:

1
2
3
src/
index.d.ts
package.json

Inside "package.json" point at the "src/index.d.ts" file

1
2
3
{
"types": "src"
}

Then you can omit the </// reference ...> comment lines - the types should be read from the "src/index.d.ts". You can even verify the types by adding typescript as a dev dependency and adding a lint step:

1
$ npm i -D typescript

In the "package.json" add a "lint" step

1
2
3
4
5
6
{
"types": "src",
"scripts": {
"lint": "tsc --pretty --noEmit --allowJS cypress/**/*.js"
}
}

For example, see cypress-data-session repo.

Read next

Examples