Do Not Put Ids Into Test Ids

Do not include random data in the data testId attributes.

Here is a piece of advice based on my experience using data-testid or data-cy to access elements in my end-to-end tests: use simple test ID values. Do not include randomly generated values. For example, when testing TodoMVC, each item could be:

1
2
3
4
5
6
<!-- 🚨 I do not recommend -->
<li data-testid="Todo-734ab">Write code</li>
<li data-testid="Todo-ce601">Write tests</li>
<!-- ✅ I recommend -->
<li data-testid="Todo" data-id="734ab">Write code</li>
<li data-testid="Todo" data-id="ce601">Write tests</li>

Separating the id part from the "test-id" will make writing tests much simpler.

Example Application

I have created an example repo bahmutov/test-ids-blog-post to go with this blog post. The initial code uses data-testid attributes for the TodoMVC fields.

The application with data-testid attributes

The input element has data-testid=TodoInput, the main list has data-testid=Todos, and the two current todos have attributes data-testid="Todo-5589433909" and data-testid="Todo-7392832596". The ids are coming from the application itself. If we look at the JSON data, we can see these values:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"todos": [
{
"title": "Write code",
"completed": false,
"id": "5589433909"
},
{
"title": "Write tests",
"completed": false,
"id": "7392832596"
}
]
}

Great, so how does it affect writing the tests?

Tip: you can see which elements have data-testid attribute by using my cypress-highlight plugin:

1
2
3
4
5
6
7
8
9
10
11
12
import { highlight } from 'cypress-highlight'

it('adds 2 items', () => {
// clear all todos before the test
cy.request('POST', '/reset', { todos: [] })
cy.visit('/')
cy.get('.loaded')
cy.get('[data-testid="TodoInput"]')
.type('Write code{enter}')
.type('Test it{enter}')
highlight('[data-testid]')
})

Highlighted elements with a data-testid attribute

Adding Todos

Let's select the important elements using the data-testid attribute. This is what Cypress selector playground tool suggests using:

The input element selector suggestion

Plus having an explicit data-testid signals everyone that this field is tested, makes finding tests easier, etc.

Let's start writing an end-to-end test

cypress/e2e/spec.cy.js
1
2
3
4
5
6
7
8
9
it('adds 2 items', () => {
// clear all todos before the test
cy.request('POST', '/reset', { todos: [] })
cy.visit('/')
cy.get('.loaded')
cy.get('[data-testid="TodoInput"]')
.type('Write code{enter}')
.type('Test it{enter}')
})

Hmm, how would we confirm that 2 items are visible on the page? We could use LI selector:

1
2
3
4
5
6
7
8
9
10
it('adds 2 items', () => {
// clear all todos before the test
cy.request('POST', '/reset', { todos: [] })
cy.visit('/')
cy.get('.loaded')
cy.get('[data-testid="TodoInput"]')
.type('Write code{enter}')
.type('Test it{enter}')
cy.get('[data-testid="Todos"]').find('li').should('have.length', 2)
})

Two LI elements inside the Todos component

Ok, it works, but what if we switch from using LI elements to DIV? Ok, let's use data-testid attributes. Because we know the start of each attribute "Todo-" we could use a prefix attribute selector.

1
2
3
4
5
6
7
8
9
10
11
12
it('adds 2 items', () => {
// clear all todos before the test
cy.request('POST', '/reset', { todos: [] })
cy.visit('/')
cy.get('.loaded')
cy.get('[data-testid="TodoInput"]')
.type('Write code{enter}')
.type('Test it{enter}')
cy.get('[data-testid="Todos"]')
.find('[data-testid^=Todo-]')
.should('have.length', 2)
})

Ughh, ok, it works.

Using prefix data-testid attribute selector

Custom Commands

Since we will use data-testid in a lot of queries, let's make a tiny custom command. We want to have the equivalents to cy.get parent and cy.find child commands, so we add two commands.

1
2
3
4
5
6
7
8
9
10
11
12
13
Cypress.Commands.add('getByTestId', (id) => {
const selector = `[data-testid="${id}"]`
return cy.get(selector)
})

Cypress.Commands.add(
'findByTestId',
{ prevSubject: 'element' },
(subject, id) => {
const selector = `[data-testid^="${id}"]`
return cy.wrap(subject, { log: false }).find(selector)
},
)

These commands make sense when checking the Todo items:

1
2
cy.getByTestId('TodoInput').type('Write code{enter}').type('Test it{enter}')
cy.getByTestId('Todos').findByTestId('Todo').should('have.length', 2)

But when we check the number of remaining todos, we have to use the same prefix.

1
2
3
cy.getByTestId('Footer')
.findByTestId('TodosRemaining')
.should('have.text', '2')

The number of remaining todos is 2

Because we need the prefix for Todo- items, the cy.findByTestId uses unnecessary prefix for [data-testid^="TodosRemaining"] too. This might not be a problem, but let's try another situation. Let's say we want to confirm there are two Todo items in the <section class="todoapp"> top parent element.

1
2
3
4
5
6
7
8
it('shows 2 items', () => {
// clear all todos before the test
cy.request('POST', '/reset', { todos: [] })
cy.visit('/')
cy.get('.loaded')
cy.getByTestId('TodoInput').type('Write code{enter}').type('Test it{enter}')
cy.get('.todoapp').findByTestId('Todo').should('have.length', 2)
})

The test fails when it finds 5 items instead of 2.

The test fails when it accidentally matches other elements

Turns out, when looking for Todo prefix we matched non-list items, like TodoInput and TodosRemaining. We could update the findByTestId command to assume the - separator:

1
2
3
4
5
6
7
8
Cypress.Commands.add(
'findByTestId',
{ prevSubject: 'element' },
(subject, id) => {
const selector = `[data-testid^="${id}-"]`
return cy.wrap(subject, { log: false }).find(selector)
},
)

But the larger question remains: why are we parsing the data attribute and encode values in our testing code? What if the application code changes?

Validate The Todo Id

Another situation shows how combining the element "type" with a random value in a single attribute is problematic. Let's say we spy on the added Todo items sent over the network. The network call contains the item's unique ID. Can we validate the ID against the element? Sure. Let's do this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
it('has the correct item id', () => {
// clear all todos before the test
cy.request('POST', '/reset', { todos: [] })
cy.visit('/')
cy.get('.loaded')
cy.intercept('POST', '/todos').as('newTodo')
cy.getByTestId('TodoInput').type('Write code{enter}')
cy.wait('@newTodo')
.its('response.body.id')
.should('be.a', 'string')
.then((id) => {
cy.getByTestId(`Todo-${id}`)
})
})

The test passes and clearly shows the item's id, even if we had to concatenate Todo and the id strings.

Finding the Todo item with the expected id

Great, but we do not want to do cy.getByTestId('Todo-${id}') in our code. We want to get the Todos component and find its child element with Todo-${id} data test id attribute, just like we did before. Does it work?

1
2
3
4
5
6
cy.wait('@newTodo')
.its('response.body.id')
.should('be.a', 'string')
.then((id) => {
cy.getByTestId('Todos').findByTestId(`Todo-${id}`)
})

Cannot find the item using the findByTestId command

Oops, the fix the previous stray element problem broken finding the item when we know the entire attribute and do not need the trailing - character.

A Better Way

Let's simplify our code. In the HTML template instead of concatenating Todo-<id> we can put the id value into its own data-... attribute. Almost like normalizing database schema and splitting the first and last names into two columns, we can "normalize" data attributes to keep just a single type of value in each one.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- 🚨 BEFORE -->
<li
v-for="todo in filteredTodos"
class="todo"
:data-testid="'Todo-' + todo.id"
:key="todo.id"
:class="{ completed: todo.completed }"
>
<!-- ✅ AFTER -->
<li
v-for="todo in filteredTodos"
class="todo"
data-testid="Todo"
:data-id="todo.id"
:key="todo.id"
:class="{ completed: todo.completed }"
>

We can now remove test attribute prefix and simply confirm the Todo element.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
it('has the correct item id', () => {
// clear all todos before the test
cy.request('POST', '/reset', { todos: [] })
cy.visit('/')
cy.get('.loaded')
cy.intercept('POST', '/todos').as('newTodo')
cy.getByTestId('TodoInput').type('Write code{enter}')
cy.wait('@newTodo')
.its('response.body.id')
.should('be.a', 'string')
.then((id) => {
cy.getByTestId('Todos')
.findByTestId('Todo')
.should('have.attr', 'data-id', id)
})
})

The Todo id attribute is separate

Let's create several todos and confirm the first item's id is unique on the page.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
it('has a unique item id', () => {
// clear all todos before the test
cy.request('POST', '/reset', { todos: [] })
cy.visit('/')
cy.get('.loaded')
cy.intercept('POST', '/todos').as('newTodo')
cy.getByTestId('TodoInput')
.type('Write code{enter}')
.type('Test it{enter}')
.type('Test it again{enter}')
cy.getByTestId('Todo').should('have.length', 3)
// the first item sent to the server
cy.wait('@newTodo')
.its('response.body.id')
.should('be.a', 'string')
.then((id) => {
cy.getByTestId('Todo')
.filter(`[data-id="${id}"]`)
.should('have.length', 1)
})
})

Filtering all Todo elements by data-id attribute is simple and uses the standard attribute selector.

1
2
3
cy.getByTestId('Todo')
.filter(`[data-id="${id}"]`)
.should('have.length', 1)

The test passes - the first item has the unique id. We can even confirm it is the first one amongst its siblings.

1
2
3
4
5
6
7
8
9
cy.wait('@newTodo')
.its('response.body.id')
.should('be.a', 'string')
.then((id) => {
cy.getByTestId('Todo')
.filter(`[data-id="${id}"]`)
.should('have.length', 1)
cy.getByTestId('Todo').first().should('have.attr', 'data-id', id)
})

The first todo has the correct unique id

Beautiful.