How I Add Test Ids To Front-End Components

Where to place data test ids in your web markup to make them easily testable.

When writing Cypress end-to-end or component tests, it is nice to have ways to easily select elements on the page. For example:

1
2
3
4
5
6
7
8
<!-- not nice -->
<div class="Form-4hh4a">
...
</div>
<!-- very nice -->
<div class="Form-4hh4a" data-testid="LoginForm">
...
</div>

Here is how I think about adding data test ids like data-testid, or data-cy to my HTML markup:

  • make it simple to interact with the page
  • make it simple to verify the page is showing the right information

Tip: I suggest you read Cypress best practices for selecting elements

Why test id attributes

I like using explicit test ids to select elements for two reasons:

Nothing stops you from implementing a similar policy for aria attributes. You can also verify the a11y attributes from the test when needed:

1
cy.getByTest('CloseButton').should('have.attr', 'aria-role', 'button')

So how do I assign test ids to the elements on the page? Here are some practical examples from my Testing The Swag Store course.

The login page

Let's start with the first UI step: logging in. The container that has the input fields needs a test id, plus the input elements and the "Login" button.

The Login form component needs a test id

Using a custom command cy.getByTest in my project I can fill the form without relying on random HTML attributes like name or password. I will add data-test attributes to:

  • the form component
  • each input field to be filled
  • the login button

Here is the test:

cypress/e2e/login/login-page.cy.ts
1
2
3
4
5
6
7
8
9
10
11
it('fills the form and logs in', () => {
cy.visit('/')
cy.getByTest('LoginForm')
.should('be.visible')
.within(() => {
cy.getByTest('username').type('standard_user')
cy.getByTest('password').type('secret_sauce')
cy.getByTest('login-button').click()
})
cy.location('pathname').should('equal', '/inventory.html')
})

Tip: I love using cy.within command to limit query commands to the parent element. But note that this command does not retry.

Selecting the login button by its data-test attribute works better long-term than using text and cy.contains command. The text might change, but the data-test makes it clear: if you want to change this attribute, you better run the tests.

The login test using test ids

The inventory page

The inventory page has several buttons in the header, the list of products, plus the sort selection. Each inventory item has text that we might want to verify: the title, the price (maybe), plus the button to add the item to the cart.

Where to add data test ids on the inventory page

I also add a data-test attribute to the top level container element: it will help verify we are on the right page if the inventory has zero items.

Top level data test id for the inventory page

Here is a typical test to verify the inventory page loads.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
beforeEach(() => {
cy.visit('/')
cy.getByTest('LoginForm')
.should('be.visible')
.within(() => {
cy.getByTest('username').type('standard_user')
cy.getByTest('password').type('secret_sauce')
cy.getByTest('login-button').click()
})
})

it('has items on the inventory page', () => {
cy.location('pathname').should('equal', '/inventory.html')
cy.getByTest('InventoryPage').should('be.visible')
cy.getByTest('InventoryPageItem')
.should('have.length.above', 2)
})

The two assertions make it easy to understand what is happening:

1
2
3
4
5
// the inventory component loads
cy.getByTest('InventoryPage').should('be.visible')
// the inventory loads at least a few items
cy.getByTest('InventoryPageItem')
.should('have.length.above', 2)

Let's verify the information shown by the first item. Since we added test ids to the individual text fields, we can use the have.text assertion:

1
2
3
4
5
6
7
8
cy.getByTest('InventoryPageItem')
.should('have.length.above', 2)
.first()
.within(() => {
cy.getByTest('ItemTitle').should('have.text', 'Sauce Labs Backpack')
cy.getByTest('ItemPrice').should('have.text', '$29.99')
cy.getByTest('ItemActionButton').should('have.text', 'Add to cart')
})

Checking the info specific to the item

Tip: Cypress comes with many Chai and Chai-jQuery and Sinon-Chai assertions, see my examples

Since checking an element's text is so common, I prefer writing custom commands like cy.getByTest that can act like both cy.get and cy.contains commands.

1
2
3
4
5
6
7
8
cy.getByTest('InventoryPageItem')
.should('have.length.above', 2)
.first()
.within(() => {
cy.getByTest('ItemTitle', 'Sauce Labs Backpack')
cy.getByTest('ItemPrice', '$29.99')
cy.getByTest('ItemActionButton', 'Add to cart')
})

Elegant test code by passing the test id and the text to the custom command

Before we move to the cart page, let's confirm the inventory page updates the cart badge

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
it('adds items to the cart', () => {
cy.location('pathname').should('equal', '/inventory.html')
cy.getByTest('InventoryPage').should('be.visible')

// initially there is no cart badge
cy.getByTest('CartBadge').should('not.exist')

cy.getByTest('InventoryPageItem')
.first()
.within(() => {
cy.getByTest('ItemActionButton', 'Add to cart').click()
})
// cart badge shows 1
cy.getByTest('CartBadge', '1')
// and the item changes its label to "Remove"
cy.getByTest('InventoryPageItem')
.first()
.getByTest('ItemActionButton')
.should('have.text', 'Remove')
})

Here are the two test ids we used

Adding items to the cart using test ids

The cart page

Let's test just the cart page. We can set the user local state with items in the cart and visit the page.

cypress/e2e/cart/cart-page.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
it('shows the cart items', { viewportHeight: 1200 }, () => {
const ids = [
{ id: 0, n: 1 },
{ id: 1, n: 2 },
{ id: 2, n: 5 },
]
// set the ids in the local storage item "cart-contents"
window.localStorage.setItem('cart-contents', JSON.stringify(ids))
cy.visit('/cart.html')
cy.getByTest('CartBadge', '3')
cy.getByTest('CartContents').should('be.visible')
cy.getByTest('CartItem').should('have.length', 3)
// iterate over the data and verify each item shown
ids.forEach((id, k) => {
cy.getByTest('CartItem')
.eq(k)
.getByTest('CartQuantity')
.should('have.value', ids[k].n)
})
})

By setting the data in the localStorage we know exactly what to expect to see on the page. Thus we can loop through the list and confirm each CartItem item.

Test ids for the Cart page

Bonus: show test ids

If you use my plugin changed-test-ids you can see all test ids used in the source files and in the spec files. For example, let's list test ids used in the specs and also list all test ids not covered by specs.

package.json
1
2
3
4
5
6
{
"scripts": {
"specs": "find-ids find-ids --specs 'cypress/e2e/**/*.cy.{js,ts}' --command getByTest --verbose",
"missing-tests": "find-ids --specs 'cypress/e2e/**/*.cy.{js,ts}' --command getByTest --sources 'src/**/*.jsx'"
}
}

Let's list test ids in the specs

1
2
3
4
5
6
7
8
9
10
11
$ npm run specs

> [email protected] specs
> find-ids find-ids --specs 'cypress/e2e/**/*.cy.{js,ts}' --command getByTest --verbose

"CartBadge" used in 3 spec(s)
cypress/e2e/cart/cart-page.cy.ts, cypress/e2e/inventory/inventory-page.cy.ts, cypress/e2e/inventory/inventory-page.cy.ts
"CartContents" used in 1 spec(s)
cypress/e2e/cart/cart-page.cy.ts
"CartItem" used in 2 spec(s)
...

Let's see all test ids without any tests

1
2
3
4
5
6
7
8
9
10
11
12
$ npm run missing-tests

> [email protected] missing-tests
> find-ids --specs 'cypress/e2e/**/*.cy.{js,ts}' --command getByTest --sources 'src/**/*.jsx'

⚠️ found 6 test id(s) not covered by any specs
AddToCart
CartBackToShopping
cancel
continue
error
finish

Nice.