Cypress Testing Library adds findByRole, findByLabelText and the other Testing Library queries to Cypress, so your tests find elements the way a user or a screen reader would: by role, accessible name, and label text, not by CSS classes or DOM structure. Tests written this way survive refactors, and they fail when the UI stops being accessible, which is a bug worth catching.
This guide covers installation, the query priority you should follow, why data-testid belongs at the bottom of that list, how to find an element's accessible role, and practical details like key actions and file uploads. If you are new to Cypress itself, start with the Cypress E2E testing guide and come back here.
Installing Cypress Testing Library
Install the package as a dev dependency:
npm install --save-dev @testing-library/cypressRegister the commands in your support file. For E2E tests that is cypress/support/commands.ts (imported from cypress/support/e2e.ts). For component tests, import it in cypress/support/component.ts too.
// cypress/support/commands.ts
import '@testing-library/cypress/add-commands';If you write tests in TypeScript, add the types so cy.findByRole autocompletes:
{
"compilerOptions": {
"types": ["cypress", "@testing-library/cypress", "node"]
},
"include": ["**/*.ts"]
}Put this in cypress/tsconfig.json so the Cypress types don't leak into your application code.
Only find* queries exist in Cypress
The DOM Testing Library has three query families: getBy*, queryBy* and findBy*. Cypress Testing Library only ships findBy* and findAllBy*. That's deliberate: Cypress commands already retry until they pass or time out, and findBy* fits that model.
| Query | Matches | Fails when |
|---|---|---|
cy.findByRole(...) | exactly one element | none found before timeout, or more than one found |
cy.findAllByRole(...) | one or more elements | none found before timeout |
To assert that something is absent, chain a negative assertion instead of looking for a queryBy*:
cy.findByRole('alert').should('not.exist');The query priority: start with findByRole
Testing Library recommends querying in this order. The higher a query sits, the closer it is to how people actually use the page.
- Accessible to everyone
findByRole: buttons, links, headings, textboxes, dialogs, anything with a role and an accessible name.findByLabelText: form fields with a<label>,aria-labeloraria-labelledby.findByPlaceholderText: a fallback for fields with no label (which is itself an accessibility problem).findByText: non-interactive content such as paragraphs and messages.findByDisplayValue: form fields by their current value.
- Semantic queries
findByAltText: images.findByTitle: elements with atitleattribute. Screen readers are inconsistent here.
- Test IDs
findByTestId: only when nothing above works.
In practice, findByRole with a name option covers most of what you need:
cy.findByRole('button', { name: /save changes/i }).click();
cy.findByRole('heading', { level: 1, name: 'Account settings' }).should('be.visible');
cy.findByRole('link', { name: 'Pricing' }).should('have.attr', 'href', '/pricing');
cy.findByRole('checkbox', { name: /subscribe/i }).check();
cy.findByRole('textbox', { name: 'Email' }).type('[email protected]');The name is the accessible name: the button text, the label associated with an input, or its aria-label. Passing a regex with i makes tests tolerant of casing changes.
Why avoid test IDs
A data-testid is invisible to users. A test that clicks [data-testid="submit-btn"] still passes when:
- the button has no visible text,
- the button is a
<div>that keyboard users can't reach, - the form field lost its label.
A findByRole('button', { name: 'Submit' }) query fails in all three cases. Your test suite becomes a cheap, always-on accessibility check.
Test IDs also add markup that exists only for tests, and it tends to drift out of sync. Keep them for elements that have no meaningful role or text, such as a chart canvas or a drag handle.
Let Testing Library suggest better queries
Testing Library can fail a test when a better query exists. Turn it on in the support file:
// cypress/support/e2e.ts
import { configure } from '@testing-library/cypress';
configure({ throwSuggestions: true });Now cy.findByTestId('submit-btn') on a button with the text "Submit" fails with a message suggesting findByRole('button', { name: /submit/i }). The feature is marked experimental, so enable it while you migrate old tests and see whether it stays useful for your team. To silence it for one query, pass { suggest: false }.
Finding an element's accessible role
Many elements have an implicit role, so you don't need to add role attributes:
| HTML | Role | Example query |
|---|---|---|
<button> | button | findByRole('button', { name: 'Save' }) |
<a href="..."> | link | findByRole('link', { name: 'Docs' }) |
<h1> to <h6> | heading | findByRole('heading', { level: 2 }) |
<input type="text">, <textarea> | textbox | findByRole('textbox', { name: 'Title' }) |
<input type="checkbox"> | checkbox | findByRole('checkbox', { checked: true }) |
<select> | combobox | findByRole('combobox', { name: 'Country' }) |
<ul>, <li> | list, listitem | findAllByRole('listitem') |
<nav> | navigation | findByRole('navigation') |
<dialog> or role="dialog" | dialog | findByRole('dialog', { name: 'Confirm' }) |
<img alt="..."> | img | findByRole('img', { name: 'Logo' }) |
Note that an <a> without href has no link role, and an <input type="password"> has no role at all, so use findByLabelText for password fields.
When you're not sure, open DevTools (F12) in Chrome, select the element, and open the Accessibility pane. It shows the computed role and accessible name from the browser's accessibility tree, which is what findByRole matches against. Firefox has a similar Accessibility Inspector.
Useful findByRole options besides name: level for headings, checked, selected, expanded, pressed, and hidden: true to include elements that are hidden from the accessibility tree.
Scoping queries with within
Pages often repeat the same button in several places. Scope the query to a container instead of reaching for .eq(2):
cy.findByRole('dialog', { name: 'Delete project' }).within(() => {
cy.findByRole('button', { name: 'Delete' }).click();
});
cy.findAllByRole('row')
.filter(':contains("[email protected]")')
.within(() => {
cy.findByRole('button', { name: 'Edit' }).click();
});Testing Library queries also respect a previous subject, so cy.findByRole('navigation').findByRole('link', { name: 'Blog' }) searches only inside the nav.
Key actions with type()
.type() accepts special character sequences in curly braces:
| Sequence | Effect |
|---|---|
{enter} | Press Enter (submits forms) |
{selectAll} | Select all text in the field |
{del} | Delete key |
{backspace} | Backspace key |
{esc} | Escape key |
{upArrow} / {downArrow} | Arrow keys, useful for comboboxes |
{ctrl}, {shift}, {alt}, {meta} | Modifier keys held for the rest of the string |
cy.findByRole('searchbox', { name: 'Search articles' }).type('kubernetes{enter}');
cy.findByLabelText('Display name').type('{selectAll}{del}New name');.clear() does the same as .type('{selectAll}{del}') and reads better. If you need to type a literal {, pass { parseSpecialCharSequences: false }.
Attaching files
Since Cypress 9.3, file uploads are built in with .selectFile(). You no longer need the cypress-file-upload plugin and its attachFile command.
// A file from the project, path relative to the project root
cy.findByLabelText('Profile photo').selectFile('cypress/fixtures/avatar.png');
// Several files at once
cy.findByLabelText('Attachments').selectFile([
'cypress/fixtures/invoice.pdf',
'cypress/fixtures/receipt.pdf',
]);
// Drop onto a drop zone instead of using the input
cy.findByText(/drag files here/i).selectFile('cypress/fixtures/avatar.png', {
action: 'drag-drop',
});
// Content created in the test, no fixture file needed
cy.findByLabelText('Import CSV').selectFile({
contents: Cypress.Buffer.from('name,email\nAna,[email protected]'),
fileName: 'users.csv',
mimeType: 'text/csv',
});Many custom upload buttons hide the real <input type="file">. If the input has a label, findByLabelText still finds it, but Cypress refuses to interact with a hidden element, so add { force: true }. To assert what the app sent to the server, intercept the upload request, as shown in mocking APIs in Cypress with intercept.
A full example
A login test that uses only accessible queries:
describe('Sign in', () => {
beforeEach(() => {
cy.visit('/login');
});
it('shows an error for wrong credentials', () => {
cy.findByLabelText('Email').type('[email protected]');
cy.findByLabelText('Password').type('wrong-password{enter}');
cy.findByRole('alert').should('contain.text', 'Invalid email or password');
cy.findByRole('button', { name: 'Sign in' }).should('be.enabled');
});
it('opens the dashboard after signing in', () => {
cy.findByLabelText('Email').type('[email protected]');
cy.findByLabelText('Password').type('correct-password');
cy.findByRole('button', { name: 'Sign in' }).click();
cy.location('pathname').should('eq', '/dashboard');
cy.findByRole('heading', { level: 1, name: /welcome back/i }).should('be.visible');
});
});Nothing in this test depends on class names, IDs or nesting. A designer can restyle the form or a developer can swap the component library, and the test keeps passing as long as the form still works for users.
FAQ
What is the difference between findBy and findAllBy in Cypress Testing Library?
findBy* expects exactly one match and fails if it finds none or more than one. findAllBy* returns every match and fails only if there are none. Use findAllBy* for lists and tables.
Why does Cypress Testing Library not have getBy or queryBy?
Cypress commands already retry automatically, which is what findBy* does in other Testing Library packages. The getBy* and queryBy* variants were removed to avoid two ways of doing the same thing. Use .should('not.exist') to check that an element is absent.
Is findByRole slow?
It can be slower than a CSS selector on very large pages, because it computes the accessibility tree. In typical app pages the difference is small. Scope it with within() or a parent query if a specific page is slow.
Should I remove all data-testid attributes?
No. Keep them for elements that have no accessible role or text, like a canvas or a map. Just don't use them for buttons, links and form fields that users can already identify.
How do I upload a file in Cypress without a plugin?
Use .selectFile(), built in since Cypress 9.3. Pass a file path, an array of paths, or an object with contents, fileName and mimeType.
Takeaways
- Install
@testing-library/cypressand importadd-commandsin your support file. - Default to
findByRolewith aname, thenfindByLabelText, thenfindByText. - Treat
findByTestIdas a last resort, and trythrowSuggestionswhile migrating. - Check roles and accessible names in the DevTools Accessibility pane.
- Use
{enter},{selectAll}and.clear()for keyboard input, and.selectFile()for uploads.
Once your queries are stable, the next step is controlling the data behind the page. Cypress component testing uses the same queries to test components in isolation. If you want help setting up a test strategy for a web app, Vectorkub builds and tests production web applications.
