How to fix Playwright tests broken by CSS-class selectors after refactors?

Playwright

CSS 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.