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 | <!-- 🚨 I do not recommend --> |
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 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 | { |
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 | import { highlight } from 'cypress-highlight' |
Adding Todos
Let's select the important elements using the data-testid
attribute. This is what Cypress selector playground tool suggests using:
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
1 | it('adds 2 items', () => { |
Hmm, how would we confirm that 2 items are visible on the page? We could use LI
selector:
1 | it('adds 2 items', () => { |
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 | it('adds 2 items', () => { |
Ughh, ok, it works.
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 | Cypress.Commands.add('getByTestId', (id) => { |
These commands make sense when checking the Todo items:
1 | cy.getByTestId('TodoInput').type('Write code{enter}').type('Test it{enter}') |
But when we check the number of remaining todos, we have to use the same prefix.
1 | cy.getByTestId('Footer') |
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 | it('shows 2 items', () => { |
The test fails when it finds 5 items instead of 2.
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 | Cypress.Commands.add( |
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 | it('has the correct item id', () => { |
The test passes and clearly shows the item's id, even if we had to concatenate Todo
and the id strings.
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 | cy.wait('@newTodo') |
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 | <!-- 🚨 BEFORE --> |
We can now remove test attribute prefix and simply confirm the Todo element.
1 | it('has the correct item id', () => { |
Let's create several todos and confirm the first item's id is unique on the page.
1 | it('has a unique item id', () => { |
Filtering all Todo elements by data-id
attribute is simple and uses the standard attribute selector.
1 | cy.getByTestId('Todo') |
The test passes - the first item has the unique id. We can even confirm it is the first one amongst its siblings.
1 | cy.wait('@newTodo') |
Beautiful.