How to fix Playwright tests broken by CSS-class selectors after refactors?
PlaywrightCSS class selectors in Playwright tests couple test code to implementation details of the component's styling. When a developer renames btn-primary to button--primary, refactors from BEM to CSS modules, or migrates to a new UI library, all tests using those class names break simultaneously — even though the application functionality is unchanged. This is the leading source of test maintenance burden in codebases that use Tailwind, CSS Modules, or component libraries that generate non-semantic class names.
Common mistake
test('submits form', async ({ page }) => {
await page.goto('https://app.example.com/contact');
await page.locator('input.form-control[name="email"]').fill('ada@example.com');
await page.locator('button.btn.btn-primary.btn-lg').click();
// All three of these class names may change in a UI library upgrade
});
Also fragile: using descendant selectors that depend on component structure:
await page.locator('.card .card-footer .submit-btn').click();
// Breaks when the card layout is restructured
The fix
Replace class selectors with role-based locators, accessible names, labels, or explicit data-testid attributes:
import { test, expect } from '@playwright/test';
test('submits contact form', async ({ page }) => {
await page.goto('https://app.example.com/contact');
// Role and label based — survives any CSS refactor
await page.getByLabel('Email address').fill('ada@example.com');
await page.getByLabel('Message').fill('Hello, I have a question about your API.');
await page.getByRole('button', { name: 'Send message' }).click();
await expect(page.getByRole('alert')).toContainText('Message sent');
});
When component markup is under your control, add data-testid attributes to elements that tests interact with:
// React component
<button
type="submit"
className={styles.submitButton}
data-testid="contact-submit"
>
Send message
</button>
// Test — stable across CSS refactors
await page.getByTestId('contact-submit').click();
For cases where you must use a CSS selector (third-party components without accessible attributes):
// Scope to a stable container and use attribute selectors that reflect semantics
await page.locator('[data-component="contact-form"]').locator('button[type="submit"]').click();
// type="submit" is semantic, not stylistic — more stable than class names
Why it works
Semantic locators (role, label, text, placeholder) and data-testid attributes describe what an element is or does, not how it looks. Role-based locators map directly to the accessibility tree, which is maintained independently of styling decisions. data-testid attributes are explicitly for testing — their only purpose is as a stable test hook, so they're rarely changed incidentally during refactors. This separation of test contracts from implementation details means styling changes don't propagate into test failures.
Tips
- Make data-testid a naming convention on your team: add them when writing the component, not as an afterthought when a test breaks.
- Avoid CSS class selectors in page.locator() calls entirely — there is no scenario where a class name is more stable than a role, label, or test ID.
- When migrating from CSS selectors, run npx playwright codegen your-app-url to auto-generate locators using Playwright's preferred role and label strategies.
- Attribute selectors using semantic attributes ([type="submit"], [name="email"], [href="/settings"]) are more stable than class selectors because they reflect behavior rather than presentation.