Create Custom Assertions For Test Readability

How to extend Cypress with custom assertions.

Application

Let's take an example page in the repo bahmutov/cypress-assertion-example. It has a list with a few items.

index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<html>
<head>
<style>
ul {
list-style: square;
}
li[data-test-id] {
font-weight: bold;
}
</style>
</head>
<body>
<h2>List</h2>
<ul id="data-attributes">
<li data-test-id="first">first</li>
<li>second</li>
</ul>
</body>
</html>

We can confirm that the list element has the ID attribute equal to "data-attributes".

1
2
3
4
5
6
7
8
describe('list', () => {
beforeEach(() => {
cy.visit('index.html')
})
it('has ID', () => {
cy.get('ul').should('have.id', 'data-attributes')
})
})

The list element has the expected ID

We can also confirm other properties, like the list style CSS.

1
2
3
4
5
6
it('has ID', () => {
cy.get('ul')
.should('have.id', 'data-attributes')
// note: you need to use the computed CSS style value
.and('have.css', 'list-style', 'outside none square')
})

Both assertions .should('have.id', 'data-attributes') and .and('have.css', 'list-style', 'outside none square') refer to the same <UL> element yielded by the previous cy.get('ul') command. We can hover over the assertions to confirm this - Cypress shows the DOM snapshot at that moment and highlights the current element.

Both assertions check the UL element

have.attr gotcha

Now let's write a test to confirm the properties of the first <LI> element.

1
2
3
4
5
6
7
8
context('item', () => {
it('has test id data attribute', () => {
cy.contains('li', 'first')
.should('have.attr', 'data-test-id', 'first')
// note: you need to use the computed CSS style value
.and('have.css', 'font-weight', '700')
})
})

The test passes - we do have an <LI> element with text "first" with the data attribute "data-test-id=first" and a bold font.

Confirm the first LI element has the expected data attribute and font weight

Great, but what if we do not know the expected data-test-id value? We can change the .should('have.attr', 'data-test-id', 'first') and remove the last argument to only confirm tha the element has a data-test-id attribute, with any value.

1
2
3
4
5
6
7
8
context('item', () => {
it('has test id data attribute', () => {
cy.contains('li', 'first')
.should('have.attr', 'data-test-id')
// note: you need to use the computed CSS style value
.and('have.css', 'font-weight', '700')
})
})

Suddenly, the test fails.

The test fails after we relax the first assertion

The error message explains that the "have.css" assertion expected an element, but instead received "first" subject. What is this about?

Typically, all Cypress assertions (which are Chai + Chai-jQuery + Chai-Sinon) keep the original subject. This makes it easy to chain multiple assertions to the same subject to confirm all its properties.

1
2
3
4
5
6
cy.get('ul')
// first assertion against <UL>
.should('have.id', 'data-attributes')
// second assertion against <UL>
.and('have.css', 'list-style', 'outside none square')
// maybe more assertions against <UL>

The assertion .should('have.attr', 'data-test-id', 'first') that confirms the value is the same way. BUT if you remove the expected value and use the single argument version "have.attr data-test-id" form, then the assertion changes the subject and yields the attribute's value. Only very few assertions change the subject like this, assertions like "have.prop", "have.attr". The reasoning being that IF you do not know the expected value, you probably want to validate the value down the line. For example, you can check if the attribute is a lowercase string.

1
2
3
4
5
cy.contains('li', 'first')
.should('have.attr', 'data-test-id')
// validate the property is a lowercase string
.should('be.a', 'string')
.and('match', /^[a-z]+$/)

Validate the attribute value

Workarounds

In our original case we have multiple assertions that need the original element. You can get the element again:

1
2
3
4
5
6
it('has some test id data attribute', () => {
cy.contains('li', 'first').should('have.attr', 'data-test-id')
cy.contains('li', 'first')
// note: you need to use the computed CSS style value
.should('have.css', 'font-weight', '700')
})

Get the element again to add another assertion

The above test splits the commands, which might go against the retry-ability best practices. Thus I would use .should(callback) to write this test

1
2
3
4
5
6
it('has some test id and CSS', () => {
cy.contains('li', 'first').should(($li) => {
expect($li).to.have.attr('data-test-id')
expect($li).to.have.css('font-weight', '700')
})
})

Custom assertion

Finally, another solution is to write your own assertion for clarity, and it is much simpler than it seems. In the support file, call the global function chai to extend the assertions

cypress/support/index.js
1
2
3
4
5
6
7
chai.use((_chai) => {
function (testId) {
// to be filled
}

_chai.Assertion.addMethod('testId', testId)
})

Let's call our assertion "testId" and it will check if the given subject has "data-test-id" attribute. If the assertion call gives a value, then our assertion should check if the "data-test-id" attribute has that exact value. Here is the complete code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
chai.use((_chai) => {
// use "function" syntax to make sure when Chai
// calls it, the "this" object points at Chai

function testId(expectedValue) {
const attr = 'data-test-id'
if (expectedValue) {
const value = this._obj.attr(attr)
this.assert(
value === expectedValue,
`expected to find data-test-id="${expectedValue}", found value "${value}"`,
)
} else {
// only confirm the "data-test-id" attribute is present

this.assert(
this._obj.attr(attr) !== undefined,
`expected to find data-test-id attribute`,
)
}
}
_chai.Assertion.addMethod('testId', testId)
})

The support file is loaded before each spec file, thus the assertion "testId" is available in every spec file automatically.

1
2
3
4
5
6
it('has some test id and CSS using custom assertion', () => {
cy.contains('li', 'first')
.should('have.testId')
// our custom "testId" assertion keeps the original subject
.and('have.css', 'list-style', 'outside none square')
})

We called our assertion using .should('have.testId') command. Since we did not pass a value, the test passed because the attribute "data-test-id" was present on the element.

Custom assertion passes

Let's confirm the value of the "data-test-id" attribute. First, let's try using a wrong expected value to see how the error message looks.

1
2
3
4
cy.contains('li', 'first')
.should('have.testId', 'invalid')
// our custom "testId" assertion keeps the original subject
.and('have.css', 'list-style', 'outside none square')

The test fails with a useful error message, notice the second attached assertion did not even run - because the first assertion "have.testId" never passed.

Trying incorrect test id value

Fix the expected value in the test and watch the test succeed.

1
2
3
4
5
6
it('has the expected test id and CSS using custom assertion', () => {
cy.contains('li', 'first')
.should('have.testId', 'first')
// our custom "testId" assertion keeps the original subject
.and('have.css', 'list-style', 'outside none square')
})

The test passed with the right values

Types

Cypress comes with TypeScript types, and even the JavaScript specs show intelligent code completion for the built-in assertions, like "have.attr"

VSCode shows the documentation for the current assertion

But the new custom assertion has no documentation, and VSCode complains that it is unknown.

VSCode has no knowledge of the custom assertion

To solve this problem, add index.d.ts file to the cypress folder and describe the new assertion.

cypress/index.d.ts
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
/// <reference types="cypress" />

declare namespace Cypress {
interface Chainer<Subject> {
/**
* Chai assertion that checks if a given element has "data-test-id" attribute.
* Yields subject.
* @param testId (optional) expected data-test-id value
* @example
* cy.get('#id').should('have.testId')
* cy.get('#id').should('have.testId', 'first')
*/
(chainer: 'have.testId', testId?: string): Chainable<Subject>
}
}

declare namespace Chai {
interface Assertion {
/**
* Chai assertion that checks if a given element has data-test-id attribute,
* with optional value check
* @param testId (optional) expected data-test-id value
* @example
* expect($el).to.have.testId()
* expect($el).to.have.testId('first')
*/
testId(testId: string): void
}
}

Tell the spec files (using tsconfig.json or jsconfig.json or via reference path) to load the cypress/index.d.ts file and enjoy the intelligent code completion in your custom assertions.

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

// @ts-check

Custom assertion with IntelliSense help

See also