# Selector Resilience ## Principle Robust selectors follow a strict hierarchy: **data-testid > ARIA roles > text content > CSS/IDs** (last resort). Selectors must be resilient to UI changes (styling, layout, content updates) and remain human-readable for maintenance. ## Rationale **The Problem**: Brittle selectors (CSS classes, nth-child, complex XPath) break when UI styling changes, elements are reordered, or design updates occur. This causes test maintenance burden and false negatives. **The Solution**: Prioritize semantic selectors that reflect user intent (ARIA roles, accessible names, test IDs). Use dynamic filtering for lists instead of nth() indexes. Validate selectors during code review and refactor proactively. **Why This Matters**: - Prevents false test failures (UI refactoring doesn't break tests) - Improves accessibility (ARIA roles benefit both tests and screen readers) - Enhances readability (semantic selectors document user intent) - Reduces maintenance burden (robust selectors survive design changes) ## Pattern Examples ### Example 1: Selector Hierarchy (Priority Order with Examples) **Context**: Choose the most resilient selector for each element type **Implementation**: ```typescript // tests/selectors/hierarchy-examples.spec.ts import { test, expect } from '@playwright/test'; test.describe('Selector Hierarchy Best Practices', () => { test('Level 1: data-testid (BEST - most resilient)', async ({ page }) => { await page.goto('/login'); // ✅ Best: Dedicated test attribute (survives all UI changes) await page.getByTestId('email-input').fill('user@example.com'); await page.getByTestId('password-input').fill('password123'); await page.getByTestId('login-button').click(); await expect(page.getByTestId('welcome-message')).toBeVisible(); // Why it's best: // - Survives CSS refactoring (class name changes) // - Survives layout changes (element reordering) // - Survives content changes (button text updates) // - Explicit test contract (developer knows it's for testing) }); test('Level 2: ARIA roles and accessible names (GOOD - future-proof)', async ({ page }) => { await page.goto('/login'); // ✅ Good: Semantic HTML roles (benefits accessibility + tests) await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com'); await page.getByRole('textbox', { name: 'Password' }).fill('password123'); await page.getByRole('button', { name: 'Sign In' }).click(); await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible(); // Why it's good: // - Survives CSS refactoring // - Survives layout changes // - Enforces accessibility (screen reader compatible) // - Self-documenting (role + name = clear intent) }); test('Level 3: Text content (ACCEPTABLE - user-centric)', async ({ page }) => { await page.goto('/dashboard'); // ✅ Acceptable: Text content (matches user perception) await page.getByText('Create New Order').click(); await expect(page.getByText('Order Details')).toBeVisible(); // Why it's acceptable: // - User-centric (what user sees) // - Survives CSS/layout changes // - Breaks when copy changes (forces test update with content) // ⚠️ Use with caution for dynamic/localized content: // - Avoid for content with variables: "User 123" (use regex instead) // - Avoid for i18n content (use data-testid or ARIA) }); test('Level 4: CSS classes/IDs (LAST RESORT - brittle)', async ({ page }) => { await page.goto('/login'); // ❌ Last resort: CSS class (breaks with styling updates) // await page.locator('.btn-primary').click() // ❌ Last resort: ID (breaks if ID changes) // await page.locator('#login-form').fill(...) // ✅ Better: Use data-testid or ARIA instead await page.getByTestId('login-button').click(); // Why CSS/ID is last resort: // - Breaks with CSS refactoring (class name changes) // - Breaks with HTML restructuring (ID changes) // - Not semantic (unclear what element does) // - Tight coupling between tests and styling }); }); ``` **Key Points**: - Hierarchy: data-testid (best) > ARIA (good) > text (acceptable) > CSS/ID (last resort) - data-testid survives ALL UI changes (explicit test contract) - ARIA roles enforce accessibility (screen reader compatible) - Text content is user-centric (but breaks with copy changes) - CSS/ID are brittle (break with styling refactoring) --- ### Example 2: Dynamic Selector Patterns (Lists, Filters, Regex) **Context**: Handle dynamic content, lists, and variable data with resilient selectors **Implementation**: ```typescript // tests/selectors/dynamic-selectors.spec.ts import { test, expect } from '@playwright/test'; test.describe('Dynamic Selector Patterns', () => { test('regex for variable content (user IDs, timestamps)', async ({ page }) => { await page.goto('/users'); // ✅ Good: Regex pattern for dynamic user IDs await expect(page.getByText(/User \d+/)).toBeVisible(); // ✅ Good: Regex for timestamps await expect(page.getByText(/Last login: \d{4}-\d{2}-\d{2}/)).toBeVisible(); // ✅ Good: Regex for dynamic counts await expect(page.getByText(/\d+ items in cart/)).toBeVisible(); }); test('partial text matching (case-insensitive, substring)', async ({ page }) => { await page.goto('/products'); // ✅ Good: Partial match (survives minor text changes) await page.getByText('Product', { exact: false }).first().click(); // ✅ Good: Case-insensitive (survives capitalization changes) await expect(page.getByText(/sign in/i)).toBeVisible(); }); test('filter locators for lists (avoid brittle nth)', async ({ page }) => { await page.goto('/products'); // ❌ Bad: Index-based (breaks when order changes) // await page.locator('.product-card').nth(2).click() // ✅ Good: Filter by content (resilient to reordering) await page.locator('[data-testid="product-card"]').filter({ hasText: 'Premium Plan' }).click(); // ✅ Good: Filter by attribute await page .locator('[data-testid="product-card"]') .filter({ has: page.locator('[data-status="active"]') }) .first() .click(); }); test('nth() only when absolutely necessary', async ({ page }) => { await page.goto('/dashboard'); // ⚠️ Acceptable: nth(0) for first item (common pattern) const firstNotification = page.getByTestId('notification').nth(0); await expect(firstNotification).toContainText('Welcome'); // ❌ Bad: nth(5) for arbitrary index (fragile) // await page.getByTestId('notification').nth(5).click() // ✅ Better: Use filter() with specific criteria await page.getByTestId('notification').filter({ hasText: 'Critical Alert' }).click(); }); test('combine multiple locators for specificity', async ({ page }) => { await page.goto('/checkout'); // ✅ Good: Narrow scope with combined locators const shippingSection = page.getByTestId('shipping-section'); await shippingSection.getByLabel('Address Line 1').fill('123 Main St'); await shippingSection.getByLabel('City').fill('New York'); // Scoping prevents ambiguity (multiple "City" fields on page) }); }); ``` **Key Points**: - Regex patterns handle variable content (IDs, timestamps, counts) - Partial matching survives minor text changes (`exact: false`) - `filter()` is more resilient than `nth()` (content-based vs index-based) - `nth(0)` acceptable for "first item", avoid arbitrary indexes - Combine locators to narrow scope (prevent ambiguity) --- ### Example 3: Selector Anti-Patterns (What NOT to Do) **Context**: Common selector mistakes that cause brittle tests **Problem Examples**: ```typescript // tests/selectors/anti-patterns.spec.ts import { test, expect } from '@playwright/test'; test.describe('Selector Anti-Patterns to Avoid', () => { test('❌ Anti-Pattern 1: CSS classes (brittle)', async ({ page }) => { await page.goto('/login'); // ❌ Bad: CSS class (breaks with design system updates) // await page.locator('.btn-primary').click() // await page.locator('.form-input-lg').fill('test@example.com') // ✅ Good: Use data-testid or ARIA role await page.getByTestId('login-button').click(); await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com'); }); test('❌ Anti-Pattern 2: Index-based nth() (fragile)', async ({ page }) => { await page.goto('/products'); // ❌ Bad: Index-based (breaks when product order changes) // await page.locator('.product-card').nth(3).click() // ✅ Good: Content-based filter await page.locator('[data-testid="product-card"]').filter({ hasText: 'Laptop' }).click(); }); test('❌ Anti-Pattern 3: Complex XPath (hard to maintain)', async ({ page }) => { await page.goto('/dashboard'); // ❌ Bad: Complex XPath (unreadable, breaks with structure changes) // await page.locator('xpath=//div[@class="container"]//section[2]//button[contains(@class, "primary")]').click() // ✅ Good: Semantic selector await page.getByRole('button', { name: 'Create Order' }).click(); }); test('❌ Anti-Pattern 4: ID selectors (coupled to implementation)', async ({ page }) => { await page.goto('/settings'); // ❌ Bad: HTML ID (breaks if ID changes for accessibility/SEO) // await page.locator('#user-settings-form').fill(...) // ✅ Good: data-testid or ARIA landmark await page.getByTestId('user-settings-form').getByLabel('Display Name').fill('John Doe'); }); test('✅ Refactoring: Bad → Good Selector', async ({ page }) => { await page.goto('/checkout'); // Before (brittle): // await page.locator('.checkout-form > .payment-section > .btn-submit').click() // After (resilient): await page.getByTestId('checkout-form').getByRole('button', { name: 'Complete Payment' }).click(); await expect(page.getByText('Payment successful')).toBeVisible(); }); }); ``` **Why These Fail**: - **CSS classes**: Change frequently with design updates (Tailwind, CSS modules) - **nth() indexes**: Fragile to element reordering (new features, A/B tests) - **Complex XPath**: Unreadable, breaks with HTML structure changes - **HTML IDs**: Not stable (accessibility improvements change IDs) **Better Approach**: Use selector hierarchy (testid > ARIA > text) --- ### Example 4: Selector Debugging Techniques (Inspector, DevTools, MCP) **Context**: Debug selector failures interactively to find better alternatives **Implementation**: ```typescript // tests/selectors/debugging-techniques.spec.ts import { test, expect } from '@playwright/test'; test.describe('Selector Debugging Techniques', () => { test('use Playwright Inspector to test selectors', async ({ page }) => { await page.goto('/dashboard'); // Pause test to open Inspector await page.pause(); // In Inspector console, test selectors: // page.getByTestId('user-menu') ✅ Works // page.getByRole('button', { name: 'Profile' }) ✅ Works // page.locator('.btn-primary') ❌ Brittle // Use "Pick Locator" feature to generate selectors // Use "Record" mode to capture user interactions await page.getByTestId('user-menu').click(); await expect(page.getByRole('menu')).toBeVisible(); }); test('use locator.all() to debug lists', async ({ page }) => { await page.goto('/products'); // Debug: How many products are visible? const products = await page.getByTestId('product-card').all(); console.log(`Found ${products.length} products`); // Debug: What text is in each product? for (const product of products) { const text = await product.textContent(); console.log(`Product text: ${text}`); } // Use findings to build better selector await page.getByTestId('product-card').filter({ hasText: 'Laptop' }).click(); }); test('use DevTools console to test selectors', async ({ page }) => { await page.goto('/checkout'); // Open DevTools (manually or via page.pause()) // Test selectors in console: // document.querySelectorAll('[data-testid="payment-method"]') // document.querySelector('#credit-card-input') // Find robust selector through trial and error await page.getByTestId('payment-method').selectOption('credit-card'); }); test('MCP browser_generate_locator (if available)', async ({ page }) => { await page.goto('/products'); // If Playwright MCP available, use browser_generate_locator: // 1. Click element in browser // 2. MCP generates optimal selector // 3. Copy into test // Example output from MCP: // page.getByRole('link', { name: 'Product A' }) // Use generated selector await page.getByRole('link', { name: 'Product A' }).click(); await expect(page).toHaveURL(/\/products\/\d+/); }); }); ``` **Key Points**: - Playwright Inspector: Interactive selector testing with "Pick Locator" feature - `locator.all()`: Debug lists to understand structure and content - DevTools console: Test CSS selectors before adding to tests - MCP browser_generate_locator: Auto-generate optimal selectors (if MCP available) - Always validate selectors work before committing --- ### Example 2: Selector Refactoring Guide (Before/After Patterns) **Context**: Systematically improve brittle selectors to resilient alternatives **Implementation**: ```typescript // tests/selectors/refactoring-guide.spec.ts import { test, expect } from '@playwright/test'; test.describe('Selector Refactoring Patterns', () => { test('refactor: CSS class → data-testid', async ({ page }) => { await page.goto('/products'); // ❌ Before: CSS class (breaks with Tailwind updates) // await page.locator('.bg-blue-500.px-4.py-2.rounded').click() // ✅ After: data-testid await page.getByTestId('add-to-cart-button').click(); // Implementation: Add data-testid to button component //