You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

selector-resilience.md 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  1. # Selector Resilience
  2. ## Principle
  3. 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.
  4. ## Rationale
  5. **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.
  6. **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.
  7. **Why This Matters**:
  8. - Prevents false test failures (UI refactoring doesn't break tests)
  9. - Improves accessibility (ARIA roles benefit both tests and screen readers)
  10. - Enhances readability (semantic selectors document user intent)
  11. - Reduces maintenance burden (robust selectors survive design changes)
  12. ## Pattern Examples
  13. ### Example 1: Selector Hierarchy (Priority Order with Examples)
  14. **Context**: Choose the most resilient selector for each element type
  15. **Implementation**:
  16. ```typescript
  17. // tests/selectors/hierarchy-examples.spec.ts
  18. import { test, expect } from '@playwright/test';
  19. test.describe('Selector Hierarchy Best Practices', () => {
  20. test('Level 1: data-testid (BEST - most resilient)', async ({ page }) => {
  21. await page.goto('/login');
  22. // ✅ Best: Dedicated test attribute (survives all UI changes)
  23. await page.getByTestId('email-input').fill('user@example.com');
  24. await page.getByTestId('password-input').fill('password123');
  25. await page.getByTestId('login-button').click();
  26. await expect(page.getByTestId('welcome-message')).toBeVisible();
  27. // Why it's best:
  28. // - Survives CSS refactoring (class name changes)
  29. // - Survives layout changes (element reordering)
  30. // - Survives content changes (button text updates)
  31. // - Explicit test contract (developer knows it's for testing)
  32. });
  33. test('Level 2: ARIA roles and accessible names (GOOD - future-proof)', async ({ page }) => {
  34. await page.goto('/login');
  35. // ✅ Good: Semantic HTML roles (benefits accessibility + tests)
  36. await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
  37. await page.getByRole('textbox', { name: 'Password' }).fill('password123');
  38. await page.getByRole('button', { name: 'Sign In' }).click();
  39. await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
  40. // Why it's good:
  41. // - Survives CSS refactoring
  42. // - Survives layout changes
  43. // - Enforces accessibility (screen reader compatible)
  44. // - Self-documenting (role + name = clear intent)
  45. });
  46. test('Level 3: Text content (ACCEPTABLE - user-centric)', async ({ page }) => {
  47. await page.goto('/dashboard');
  48. // ✅ Acceptable: Text content (matches user perception)
  49. await page.getByText('Create New Order').click();
  50. await expect(page.getByText('Order Details')).toBeVisible();
  51. // Why it's acceptable:
  52. // - User-centric (what user sees)
  53. // - Survives CSS/layout changes
  54. // - Breaks when copy changes (forces test update with content)
  55. // ⚠️ Use with caution for dynamic/localized content:
  56. // - Avoid for content with variables: "User 123" (use regex instead)
  57. // - Avoid for i18n content (use data-testid or ARIA)
  58. });
  59. test('Level 4: CSS classes/IDs (LAST RESORT - brittle)', async ({ page }) => {
  60. await page.goto('/login');
  61. // ❌ Last resort: CSS class (breaks with styling updates)
  62. // await page.locator('.btn-primary').click()
  63. // ❌ Last resort: ID (breaks if ID changes)
  64. // await page.locator('#login-form').fill(...)
  65. // ✅ Better: Use data-testid or ARIA instead
  66. await page.getByTestId('login-button').click();
  67. // Why CSS/ID is last resort:
  68. // - Breaks with CSS refactoring (class name changes)
  69. // - Breaks with HTML restructuring (ID changes)
  70. // - Not semantic (unclear what element does)
  71. // - Tight coupling between tests and styling
  72. });
  73. });
  74. ```
  75. **Key Points**:
  76. - Hierarchy: data-testid (best) > ARIA (good) > text (acceptable) > CSS/ID (last resort)
  77. - data-testid survives ALL UI changes (explicit test contract)
  78. - ARIA roles enforce accessibility (screen reader compatible)
  79. - Text content is user-centric (but breaks with copy changes)
  80. - CSS/ID are brittle (break with styling refactoring)
  81. ---
  82. ### Example 2: Dynamic Selector Patterns (Lists, Filters, Regex)
  83. **Context**: Handle dynamic content, lists, and variable data with resilient selectors
  84. **Implementation**:
  85. ```typescript
  86. // tests/selectors/dynamic-selectors.spec.ts
  87. import { test, expect } from '@playwright/test';
  88. test.describe('Dynamic Selector Patterns', () => {
  89. test('regex for variable content (user IDs, timestamps)', async ({ page }) => {
  90. await page.goto('/users');
  91. // ✅ Good: Regex pattern for dynamic user IDs
  92. await expect(page.getByText(/User \d+/)).toBeVisible();
  93. // ✅ Good: Regex for timestamps
  94. await expect(page.getByText(/Last login: \d{4}-\d{2}-\d{2}/)).toBeVisible();
  95. // ✅ Good: Regex for dynamic counts
  96. await expect(page.getByText(/\d+ items in cart/)).toBeVisible();
  97. });
  98. test('partial text matching (case-insensitive, substring)', async ({ page }) => {
  99. await page.goto('/products');
  100. // ✅ Good: Partial match (survives minor text changes)
  101. await page.getByText('Product', { exact: false }).first().click();
  102. // ✅ Good: Case-insensitive (survives capitalization changes)
  103. await expect(page.getByText(/sign in/i)).toBeVisible();
  104. });
  105. test('filter locators for lists (avoid brittle nth)', async ({ page }) => {
  106. await page.goto('/products');
  107. // ❌ Bad: Index-based (breaks when order changes)
  108. // await page.locator('.product-card').nth(2).click()
  109. // ✅ Good: Filter by content (resilient to reordering)
  110. await page.locator('[data-testid="product-card"]').filter({ hasText: 'Premium Plan' }).click();
  111. // ✅ Good: Filter by attribute
  112. await page
  113. .locator('[data-testid="product-card"]')
  114. .filter({ has: page.locator('[data-status="active"]') })
  115. .first()
  116. .click();
  117. });
  118. test('nth() only when absolutely necessary', async ({ page }) => {
  119. await page.goto('/dashboard');
  120. // ⚠️ Acceptable: nth(0) for first item (common pattern)
  121. const firstNotification = page.getByTestId('notification').nth(0);
  122. await expect(firstNotification).toContainText('Welcome');
  123. // ❌ Bad: nth(5) for arbitrary index (fragile)
  124. // await page.getByTestId('notification').nth(5).click()
  125. // ✅ Better: Use filter() with specific criteria
  126. await page.getByTestId('notification').filter({ hasText: 'Critical Alert' }).click();
  127. });
  128. test('combine multiple locators for specificity', async ({ page }) => {
  129. await page.goto('/checkout');
  130. // ✅ Good: Narrow scope with combined locators
  131. const shippingSection = page.getByTestId('shipping-section');
  132. await shippingSection.getByLabel('Address Line 1').fill('123 Main St');
  133. await shippingSection.getByLabel('City').fill('New York');
  134. // Scoping prevents ambiguity (multiple "City" fields on page)
  135. });
  136. });
  137. ```
  138. **Key Points**:
  139. - Regex patterns handle variable content (IDs, timestamps, counts)
  140. - Partial matching survives minor text changes (`exact: false`)
  141. - `filter()` is more resilient than `nth()` (content-based vs index-based)
  142. - `nth(0)` acceptable for "first item", avoid arbitrary indexes
  143. - Combine locators to narrow scope (prevent ambiguity)
  144. ---
  145. ### Example 3: Selector Anti-Patterns (What NOT to Do)
  146. **Context**: Common selector mistakes that cause brittle tests
  147. **Problem Examples**:
  148. ```typescript
  149. // tests/selectors/anti-patterns.spec.ts
  150. import { test, expect } from '@playwright/test';
  151. test.describe('Selector Anti-Patterns to Avoid', () => {
  152. test('❌ Anti-Pattern 1: CSS classes (brittle)', async ({ page }) => {
  153. await page.goto('/login');
  154. // ❌ Bad: CSS class (breaks with design system updates)
  155. // await page.locator('.btn-primary').click()
  156. // await page.locator('.form-input-lg').fill('test@example.com')
  157. // ✅ Good: Use data-testid or ARIA role
  158. await page.getByTestId('login-button').click();
  159. await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com');
  160. });
  161. test('❌ Anti-Pattern 2: Index-based nth() (fragile)', async ({ page }) => {
  162. await page.goto('/products');
  163. // ❌ Bad: Index-based (breaks when product order changes)
  164. // await page.locator('.product-card').nth(3).click()
  165. // ✅ Good: Content-based filter
  166. await page.locator('[data-testid="product-card"]').filter({ hasText: 'Laptop' }).click();
  167. });
  168. test('❌ Anti-Pattern 3: Complex XPath (hard to maintain)', async ({ page }) => {
  169. await page.goto('/dashboard');
  170. // ❌ Bad: Complex XPath (unreadable, breaks with structure changes)
  171. // await page.locator('xpath=//div[@class="container"]//section[2]//button[contains(@class, "primary")]').click()
  172. // ✅ Good: Semantic selector
  173. await page.getByRole('button', { name: 'Create Order' }).click();
  174. });
  175. test('❌ Anti-Pattern 4: ID selectors (coupled to implementation)', async ({ page }) => {
  176. await page.goto('/settings');
  177. // ❌ Bad: HTML ID (breaks if ID changes for accessibility/SEO)
  178. // await page.locator('#user-settings-form').fill(...)
  179. // ✅ Good: data-testid or ARIA landmark
  180. await page.getByTestId('user-settings-form').getByLabel('Display Name').fill('John Doe');
  181. });
  182. test('✅ Refactoring: Bad → Good Selector', async ({ page }) => {
  183. await page.goto('/checkout');
  184. // Before (brittle):
  185. // await page.locator('.checkout-form > .payment-section > .btn-submit').click()
  186. // After (resilient):
  187. await page.getByTestId('checkout-form').getByRole('button', { name: 'Complete Payment' }).click();
  188. await expect(page.getByText('Payment successful')).toBeVisible();
  189. });
  190. });
  191. ```
  192. **Why These Fail**:
  193. - **CSS classes**: Change frequently with design updates (Tailwind, CSS modules)
  194. - **nth() indexes**: Fragile to element reordering (new features, A/B tests)
  195. - **Complex XPath**: Unreadable, breaks with HTML structure changes
  196. - **HTML IDs**: Not stable (accessibility improvements change IDs)
  197. **Better Approach**: Use selector hierarchy (testid > ARIA > text)
  198. ---
  199. ### Example 4: Selector Debugging Techniques (Inspector, DevTools, MCP)
  200. **Context**: Debug selector failures interactively to find better alternatives
  201. **Implementation**:
  202. ```typescript
  203. // tests/selectors/debugging-techniques.spec.ts
  204. import { test, expect } from '@playwright/test';
  205. test.describe('Selector Debugging Techniques', () => {
  206. test('use Playwright Inspector to test selectors', async ({ page }) => {
  207. await page.goto('/dashboard');
  208. // Pause test to open Inspector
  209. await page.pause();
  210. // In Inspector console, test selectors:
  211. // page.getByTestId('user-menu') ✅ Works
  212. // page.getByRole('button', { name: 'Profile' }) ✅ Works
  213. // page.locator('.btn-primary') ❌ Brittle
  214. // Use "Pick Locator" feature to generate selectors
  215. // Use "Record" mode to capture user interactions
  216. await page.getByTestId('user-menu').click();
  217. await expect(page.getByRole('menu')).toBeVisible();
  218. });
  219. test('use locator.all() to debug lists', async ({ page }) => {
  220. await page.goto('/products');
  221. // Debug: How many products are visible?
  222. const products = await page.getByTestId('product-card').all();
  223. console.log(`Found ${products.length} products`);
  224. // Debug: What text is in each product?
  225. for (const product of products) {
  226. const text = await product.textContent();
  227. console.log(`Product text: ${text}`);
  228. }
  229. // Use findings to build better selector
  230. await page.getByTestId('product-card').filter({ hasText: 'Laptop' }).click();
  231. });
  232. test('use DevTools console to test selectors', async ({ page }) => {
  233. await page.goto('/checkout');
  234. // Open DevTools (manually or via page.pause())
  235. // Test selectors in console:
  236. // document.querySelectorAll('[data-testid="payment-method"]')
  237. // document.querySelector('#credit-card-input')
  238. // Find robust selector through trial and error
  239. await page.getByTestId('payment-method').selectOption('credit-card');
  240. });
  241. test('MCP browser_generate_locator (if available)', async ({ page }) => {
  242. await page.goto('/products');
  243. // If Playwright MCP available, use browser_generate_locator:
  244. // 1. Click element in browser
  245. // 2. MCP generates optimal selector
  246. // 3. Copy into test
  247. // Example output from MCP:
  248. // page.getByRole('link', { name: 'Product A' })
  249. // Use generated selector
  250. await page.getByRole('link', { name: 'Product A' }).click();
  251. await expect(page).toHaveURL(/\/products\/\d+/);
  252. });
  253. });
  254. ```
  255. **Key Points**:
  256. - Playwright Inspector: Interactive selector testing with "Pick Locator" feature
  257. - `locator.all()`: Debug lists to understand structure and content
  258. - DevTools console: Test CSS selectors before adding to tests
  259. - MCP browser_generate_locator: Auto-generate optimal selectors (if MCP available)
  260. - Always validate selectors work before committing
  261. ---
  262. ### Example 2: Selector Refactoring Guide (Before/After Patterns)
  263. **Context**: Systematically improve brittle selectors to resilient alternatives
  264. **Implementation**:
  265. ```typescript
  266. // tests/selectors/refactoring-guide.spec.ts
  267. import { test, expect } from '@playwright/test';
  268. test.describe('Selector Refactoring Patterns', () => {
  269. test('refactor: CSS class → data-testid', async ({ page }) => {
  270. await page.goto('/products');
  271. // ❌ Before: CSS class (breaks with Tailwind updates)
  272. // await page.locator('.bg-blue-500.px-4.py-2.rounded').click()
  273. // ✅ After: data-testid
  274. await page.getByTestId('add-to-cart-button').click();
  275. // Implementation: Add data-testid to button component
  276. // <button className="bg-blue-500 px-4 py-2 rounded" data-testid="add-to-cart-button">
  277. });
  278. test('refactor: nth() index → filter()', async ({ page }) => {
  279. await page.goto('/users');
  280. // ❌ Before: Index-based (breaks when users reorder)
  281. // await page.locator('.user-row').nth(2).click()
  282. // ✅ After: Content-based filter
  283. await page.locator('[data-testid="user-row"]').filter({ hasText: 'john@example.com' }).click();
  284. });
  285. test('refactor: Complex XPath → ARIA role', async ({ page }) => {
  286. await page.goto('/checkout');
  287. // ❌ Before: Complex XPath (unreadable, brittle)
  288. // await page.locator('xpath=//div[@id="payment"]//form//button[contains(@class, "submit")]').click()
  289. // ✅ After: ARIA role
  290. await page.getByRole('button', { name: 'Complete Payment' }).click();
  291. });
  292. test('refactor: ID selector → data-testid', async ({ page }) => {
  293. await page.goto('/settings');
  294. // ❌ Before: HTML ID (changes with accessibility improvements)
  295. // await page.locator('#user-profile-section').getByLabel('Name').fill('John')
  296. // ✅ After: data-testid + semantic label
  297. await page.getByTestId('user-profile-section').getByLabel('Display Name').fill('John Doe');
  298. });
  299. test('refactor: Deeply nested CSS → scoped data-testid', async ({ page }) => {
  300. await page.goto('/dashboard');
  301. // ❌ Before: Deep nesting (breaks with structure changes)
  302. // await page.locator('.container .sidebar .menu .item:nth-child(3) a').click()
  303. // ✅ After: Scoped data-testid
  304. const sidebar = page.getByTestId('sidebar');
  305. await sidebar.getByRole('link', { name: 'Settings' }).click();
  306. });
  307. });
  308. ```
  309. **Key Points**:
  310. - CSS class → data-testid (survives design system updates)
  311. - nth() → filter() (content-based vs index-based)
  312. - Complex XPath → ARIA role (readable, semantic)
  313. - ID → data-testid (decouples from HTML structure)
  314. - Deep nesting → scoped locators (modular, maintainable)
  315. ---
  316. ### Example 3: Selector Best Practices Checklist
  317. ```typescript
  318. // tests/selectors/validation-checklist.spec.ts
  319. import { test, expect } from '@playwright/test';
  320. /**
  321. * Selector Validation Checklist
  322. *
  323. * Before committing test, verify selectors meet these criteria:
  324. */
  325. test.describe('Selector Best Practices Validation', () => {
  326. test('✅ 1. Prefer data-testid for interactive elements', async ({ page }) => {
  327. await page.goto('/login');
  328. // Interactive elements (buttons, inputs, links) should use data-testid
  329. await page.getByTestId('email-input').fill('test@example.com');
  330. await page.getByTestId('login-button').click();
  331. });
  332. test('✅ 2. Use ARIA roles for semantic elements', async ({ page }) => {
  333. await page.goto('/dashboard');
  334. // Semantic elements (headings, navigation, forms) use ARIA
  335. await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  336. await page.getByRole('navigation').getByRole('link', { name: 'Settings' }).click();
  337. });
  338. test('✅ 3. Avoid CSS classes (except when testing styles)', async ({ page }) => {
  339. await page.goto('/products');
  340. // ❌ Never for interaction: page.locator('.btn-primary')
  341. // ✅ Only for visual regression: await expect(page.locator('.error-banner')).toHaveCSS('color', 'rgb(255, 0, 0)')
  342. });
  343. test('✅ 4. Use filter() instead of nth() for lists', async ({ page }) => {
  344. await page.goto('/orders');
  345. // List selection should be content-based
  346. await page.getByTestId('order-row').filter({ hasText: 'Order #12345' }).click();
  347. });
  348. test('✅ 5. Selectors are human-readable', async ({ page }) => {
  349. await page.goto('/checkout');
  350. // ✅ Good: Clear intent
  351. await page.getByTestId('shipping-address-form').getByLabel('Street Address').fill('123 Main St');
  352. // ❌ Bad: Cryptic
  353. // await page.locator('div > div:nth-child(2) > input[type="text"]').fill('123 Main St')
  354. });
  355. });
  356. ```
  357. **Validation Rules**:
  358. 1. **Interactive elements** (buttons, inputs) → data-testid
  359. 2. **Semantic elements** (headings, nav, forms) → ARIA roles
  360. 3. **CSS classes** → Avoid (except visual regression tests)
  361. 4. **Lists** → filter() over nth() (content-based selection)
  362. 5. **Readability** → Selectors document user intent (clear, semantic)
  363. ---
  364. ## Selector Resilience Checklist
  365. Before deploying selectors:
  366. - [ ] **Hierarchy followed**: data-testid (1st choice) > ARIA (2nd) > text (3rd) > CSS/ID (last resort)
  367. - [ ] **Interactive elements use data-testid**: Buttons, inputs, links have dedicated test attributes
  368. - [ ] **Semantic elements use ARIA**: Headings, navigation, forms use roles and accessible names
  369. - [ ] **No brittle patterns**: No CSS classes (except visual tests), no arbitrary nth(), no complex XPath
  370. - [ ] **Dynamic content handled**: Regex for IDs/timestamps, filter() for lists, partial matching for text
  371. - [ ] **Selectors are scoped**: Use container locators to narrow scope (prevent ambiguity)
  372. - [ ] **Human-readable**: Selectors document user intent (clear, semantic, maintainable)
  373. - [ ] **Validated in Inspector**: Test selectors interactively before committing (page.pause())
  374. ## Integration Points
  375. - **Used in workflows**: `*atdd` (generate tests with robust selectors), `*automate` (healing selector failures), `*test-review` (validate selector quality)
  376. - **Related fragments**: `test-healing-patterns.md` (selector failure diagnosis), `fixture-architecture.md` (page object alternatives), `test-quality.md` (maintainability standards)
  377. - **Tools**: Playwright Inspector (Pick Locator), DevTools console, Playwright MCP browser_generate_locator (optional)
  378. _Source: Playwright selector best practices, accessibility guidelines (ARIA), production test maintenance patterns_