Tips for writing HTML that is easy to test and resilient to CSS/styling changes.
The single most impactful thing you can do. Test IDs survive class renames, component refactors, and redesigns.
<!-- Good: stable test anchor -->
<button data-testid="submit-registration">Register</button>
<!-- Fragile: breaks if class or text changes -->
<button class="btn-primary text-sm rounded-lg">Register</button>// In Playwright
page.getByTestId('submit-registration')Playwright has built-in support via getByTestId(). You can configure the attribute name in playwright.config.ts if you prefer something like data-test or data-cy.
Semantic elements and ARIA roles give tests meaningful anchors that also improve accessibility.
<form aria-label="registration">
<input name="username" aria-label="Username" />
<button type="submit" aria-label="submit">Register</button>
</form>page.getByRole('form', { name: 'registration' })
page.getByRole('textbox', { name: 'Username' })
page.getByRole('button', { name: 'submit' })These selectors are independent of CSS classes, tag nesting, and visual layout.
The name attribute is stable, functional (needed for form submission), and unlikely to change for cosmetic reasons.
<input name="first_name" />
<input name="email" />page.locator('input[name="first_name"]')
page.locator('input[name="email"]')| Fragile | Why | Better alternative |
|---|---|---|
.btn-primary |
Styling class, changes with redesigns | data-testid="submit-btn" |
.flex.items-center.space-x-3 |
Layout utility classes | data-testid="provider-row" |
div > div > button |
Structural coupling, breaks with refactors | button[data-testid="..."] |
:nth-child(2) |
Position-dependent, breaks if order changes | data-testid on each item |
When you have lists of similar elements, give each one a unique test ID.
<!-- Good: each document row is identifiable -->
<div data-testid="legal-doc-terms-of-service">
<span>Terms of Service</span>
<button data-testid="accept-terms-of-service">Read & Accept</button>
</div>
<div data-testid="legal-doc-privacy-policy">
<span>Privacy Policy</span>
<button data-testid="accept-privacy-policy">Read & Accept</button>
</div>// Direct, no ambiguity
page.getByTestId('accept-privacy-policy').click()Without this, tests resort to fragile text matching or positional selectors to distinguish items.
Expose UI state in the DOM so tests can assert on it directly.
<div data-testid="role-checker" data-state="collapsed">...</div>
<div data-testid="role-checker" data-state="expanded">...</div>await expect(page.getByTestId('role-checker')).toHaveAttribute('data-state', 'expanded')This is more reliable than checking for CSS classes like .expanded which may be renamed.
| Principle | Effect |
|---|---|
Add data-testid to interactive and assertable elements |
Tests don't break on styling changes |
| Use semantic HTML + ARIA | Tests read like user intent, not DOM spelunking |
Use name on form fields |
Stable, functional anchors |
| Avoid class-based and structural selectors | Decouples tests from CSS and layout |
| Give unique IDs to repeated items | Eliminates ambiguous selectors |
| Expose state via data attributes | Clean assertions without class sniffing |