Alternatives to Data-Test Attributes in Modern Testing
10 Nov 2024Building maintainable end-to-end tests doesn’t have to be complicated. While data-test attributes are a common approach, there’s an elegant alternative that aligns better with how users actually interact with your application. Let’s explore how we can write more resilient tests by focusing on user behavior and accessibility patterns.
Building User-Centric Tests
Let’s explore how we can build tests that mirror real user interactions. Here’s a typical user settings panel implementation:
// Initial component implementation
const UserSettingsPanel = () => {
return (
<div data-testid="settings-panel">
<div data-testid="settings-header">
<h2 data-testid="settings-title">User Settings</h2>
</div>
<div data-testid="settings-form-container">
<form data-testid="settings-form">
<div data-testid="email-group">
<label data-testid="email-label">
Email
<input
data-testid="email-input"
type="email"
name="email"
/>
</label>
</div>
<button
data-testid="save-button"
type="submit"
>
Save Changes
</button>
</form>
</div>
</div>
);
};
// Initial tests
describe('User Settings Panel', () => {
it('should update user settings', () => {
cy.get('[data-testid="settings-panel"]').should('be.visible');
cy.get('[data-testid="email-input"]').type('[email protected]');
cy.get('[data-testid="save-button"]').click();
cy.get('[data-testid="success-message"]').should('be.visible');
});
});
Later, your team decides to improve accessibility and semantic structure. A careful developer might update the component like this:
// Refactored component maintaining both test IDs and semantic structure
const UserSettingsPanel = () => {
return (
<main data-testid="settings-panel">
<header data-testid="settings-header">
<h2 data-testid="settings-title">User Settings</h2>
</header>
<form data-testid="settings-form">
<fieldset>
<legend className="sr-only">User Preferences</legend>
<div className="form-group" data-testid="email-group">
<label htmlFor="email" data-testid="email-label">Email</label>
<input
id="email"
type="email"
name="email"
aria-describedby="email-help"
data-testid="email-input"
/>
</div>
<button
type="submit"
aria-label="Save user settings"
data-testid="save-button"
>
Save Changes
</button>
</form>
</main>
);
};
By focusing on semantic HTML and accessibility patterns, we can create a more maintainable approach that:
- Mirrors User Behavior: Tests interact with elements the same way users do
- Promotes Accessibility: Using ARIA labels and semantic HTML improves both testing and user experience
- Simplifies Component Structure: Components remain clean and focused on their primary purpose
A Better Approach
Instead of maintaining parallel identification systems, we could focus on testing the way users actually interact with our application:
// Component focusing on user interaction patterns
const UserSettingsPanel = () => {
return (
<main>
<form aria-label="User Settings">
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
name="email"
/>
</div>
<button type="submit">Save Changes</button>
</form>
</main>
);
};
// Tests focusing on user interaction patterns
describe('User Settings Panel', () => {
it('should update user settings', () => {
cy.findByRole('form', { name: /user settings/i }).within(() => {
cy.findByLabelText(/email/i).type('[email protected]');
cy.findByRole('button', { name: /save changes/i }).click();
});
cy.findByRole('alert').should('be.visible');
});
});
This approach offers several benefits:
- Tests actual user experience
- Encourages good accessibility practices
- Reduces maintenance burden
- Naturally resilient to refactoring
The Cypress Perspective
While Cypress actually recommends using data-* attributes in their Best Practices guide, there’s a growing movement in the testing community to reconsider this approach. Here’s why:
// Traditional Cypress test with data-test attributes
cy.get('[data-testid="login-form"]')
.find('[data-testid="username-input"]')
.type('[email protected]')
cy.get('[data-testid="submit-button"]').click()
// More resilient approach using user-centric selectors
cy.get('form').within(() => {
cy.get('input[type="email"]').type('[email protected]')
cy.get('button[type="submit"]').click()
})
Better Alternatives for Cypress
1. Use Semantic HTML and ARIA Roles
// Instead of:
cy.get('[data-testid="navigation"]')
// Use:
cy.get('nav')
// Or even better:
cy.get('[role="navigation"]')
2. Leverage Text Content and Labels
// Instead of:
cy.get('[data-testid="login-button"]')
// Use:
cy.contains('button', 'Log in')
// Or:
cy.get('button').contains('Log in')
3. Use Form Elements Wisely
// Instead of:
cy.get('[data-testid="email-input"]')
// Use:
cy.get('input[type="email"]')
// Or:
cy.get('label').contains('Email').siblings('input')
When Data Attributes Might Make Sense in Cypress
There are legitimate cases for data attributes in Cypress tests:
- Dynamic content where text might change:
// Valid use case cy.get('[data-testid="user-notification"]').should('be.visible') - Internationalized applications:
// Text content might vary by locale cy.get('[data-testid="welcome-message"]').should('be.visible') - Complex data grids or tables:
cy.get('[data-testid="data-grid-row-1"]') .should('contain', expectedData)
Conclusion
While Cypress’s documentation suggests using data-test attributes, we can write more maintainable tests by prioritizing selectors that reflect how users actually interact with our applications. The key is finding the right balance between test reliability and maintenance overhead.
When writing Cypress tests, ask yourself: “How would a user find this element?” If the answer isn’t “by looking for a data-test attribute,” consider using a more user-centric selector.
Sources: