|
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644 |
- # Test Healing Patterns
-
- ## Principle
-
- Common test failures follow predictable patterns (stale selectors, race conditions, dynamic data assertions, network errors, hard waits). **Automated healing** identifies failure signatures and applies pattern-based fixes. Manual healing captures these patterns for future automation.
-
- ## Rationale
-
- **The Problem**: Test failures waste developer time on repetitive debugging. Teams manually fix the same selector issues, timing bugs, and data mismatches repeatedly across test suites.
-
- **The Solution**: Catalog common failure patterns with diagnostic signatures and automated fixes. When a test fails, match the error message/stack trace against known patterns and apply the corresponding fix. This transforms test maintenance from reactive debugging to proactive pattern application.
-
- **Why This Matters**:
-
- - Reduces test maintenance time by 60-80% (pattern-based fixes vs manual debugging)
- - Prevents flakiness regression (same bug fixed once, applied everywhere)
- - Builds institutional knowledge (failure catalog grows over time)
- - Enables self-healing test suites (automate workflow validates and heals)
-
- ## Pattern Examples
-
- ### Example 1: Common Failure Pattern - Stale Selectors (Element Not Found)
-
- **Context**: Test fails with "Element not found" or "Locator resolved to 0 elements" errors
-
- **Diagnostic Signature**:
-
- ```typescript
- // src/testing/healing/selector-healing.ts
-
- export type SelectorFailure = {
- errorMessage: string;
- stackTrace: string;
- selector: string;
- testFile: string;
- lineNumber: number;
- };
-
- /**
- * Detect stale selector failures
- */
- export function isSelectorFailure(error: Error): boolean {
- const patterns = [
- /locator.*resolved to 0 elements/i,
- /element not found/i,
- /waiting for locator.*to be visible/i,
- /selector.*did not match any elements/i,
- /unable to find element/i,
- ];
-
- return patterns.some((pattern) => pattern.test(error.message));
- }
-
- /**
- * Extract selector from error message
- */
- export function extractSelector(errorMessage: string): string | null {
- // Playwright: "locator('button[type=\"submit\"]') resolved to 0 elements"
- const playwrightMatch = errorMessage.match(/locator\('([^']+)'\)/);
- if (playwrightMatch) return playwrightMatch[1];
-
- // Cypress: "Timed out retrying: Expected to find element: '.submit-button'"
- const cypressMatch = errorMessage.match(/Expected to find element: ['"]([^'"]+)['"]/i);
- if (cypressMatch) return cypressMatch[1];
-
- return null;
- }
-
- /**
- * Suggest better selector based on hierarchy
- */
- export function suggestBetterSelector(badSelector: string): string {
- // If using CSS class → suggest data-testid
- if (badSelector.startsWith('.') || badSelector.includes('class=')) {
- const elementName = badSelector.match(/class=["']([^"']+)["']/)?.[1] || badSelector.slice(1);
- return `page.getByTestId('${elementName}') // Prefer data-testid over CSS class`;
- }
-
- // If using ID → suggest data-testid
- if (badSelector.startsWith('#')) {
- return `page.getByTestId('${badSelector.slice(1)}') // Prefer data-testid over ID`;
- }
-
- // If using nth() → suggest filter() or more specific selector
- if (badSelector.includes('.nth(')) {
- return `page.locator('${badSelector.split('.nth(')[0]}').filter({ hasText: 'specific text' }) // Avoid brittle nth(), use filter()`;
- }
-
- // If using complex CSS → suggest ARIA role
- if (badSelector.includes('>') || badSelector.includes('+')) {
- return `page.getByRole('button', { name: 'Submit' }) // Prefer ARIA roles over complex CSS`;
- }
-
- return `page.getByTestId('...') // Add data-testid attribute to element`;
- }
- ```
-
- **Healing Implementation**:
-
- ```typescript
- // tests/healing/selector-healing.spec.ts
- import { test, expect } from '@playwright/test';
- import { isSelectorFailure, extractSelector, suggestBetterSelector } from '../../src/testing/healing/selector-healing';
-
- test('heal stale selector failures automatically', async ({ page }) => {
- await page.goto('/dashboard');
-
- try {
- // Original test with brittle CSS selector
- await page.locator('.btn-primary').click();
- } catch (error: any) {
- if (isSelectorFailure(error)) {
- const badSelector = extractSelector(error.message);
- const suggestion = badSelector ? suggestBetterSelector(badSelector) : null;
-
- console.log('HEALING SUGGESTION:', suggestion);
-
- // Apply healed selector
- await page.getByTestId('submit-button').click(); // Fixed!
- } else {
- throw error; // Not a selector issue, rethrow
- }
- }
-
- await expect(page.getByText('Success')).toBeVisible();
- });
- ```
-
- **Key Points**:
-
- - Diagnosis: Error message contains "locator resolved to 0 elements" or "element not found"
- - Fix: Replace brittle selector (CSS class, ID, nth) with robust alternative (data-testid, ARIA role)
- - Prevention: Follow selector hierarchy (data-testid > ARIA > text > CSS)
- - Automation: Pattern matching on error message + stack trace
-
- ---
-
- ### Example 2: Common Failure Pattern - Race Conditions (Timing Errors)
-
- **Context**: Test fails with "timeout waiting for element" or "element not visible" errors
-
- **Diagnostic Signature**:
-
- ```typescript
- // src/testing/healing/timing-healing.ts
-
- export type TimingFailure = {
- errorMessage: string;
- testFile: string;
- lineNumber: number;
- actionType: 'click' | 'fill' | 'waitFor' | 'expect';
- };
-
- /**
- * Detect race condition failures
- */
- export function isTimingFailure(error: Error): boolean {
- const patterns = [
- /timeout.*waiting for/i,
- /element is not visible/i,
- /element is not attached to the dom/i,
- /waiting for element to be visible.*exceeded/i,
- /timed out retrying/i,
- /waitForLoadState.*timeout/i,
- ];
-
- return patterns.some((pattern) => pattern.test(error.message));
- }
-
- /**
- * Detect hard wait anti-pattern
- */
- export function hasHardWait(testCode: string): boolean {
- const hardWaitPatterns = [/page\.waitForTimeout\(/, /cy\.wait\(\d+\)/, /await.*sleep\(/, /setTimeout\(/];
-
- return hardWaitPatterns.some((pattern) => pattern.test(testCode));
- }
-
- /**
- * Suggest deterministic wait replacement
- */
- export function suggestDeterministicWait(testCode: string): string {
- if (testCode.includes('page.waitForTimeout')) {
- return `
- // ❌ Bad: Hard wait (flaky)
- // await page.waitForTimeout(3000)
-
- // ✅ Good: Wait for network response
- await page.waitForResponse(resp => resp.url().includes('/api/data') && resp.status() === 200)
-
- // OR wait for element state
- await page.getByTestId('loading-spinner').waitFor({ state: 'detached' })
- `.trim();
- }
-
- if (testCode.includes('cy.wait(') && /cy\.wait\(\d+\)/.test(testCode)) {
- return `
- // ❌ Bad: Hard wait (flaky)
- // cy.wait(3000)
-
- // ✅ Good: Wait for aliased network request
- cy.intercept('GET', '/api/data').as('getData')
- cy.visit('/page')
- cy.wait('@getData')
- `.trim();
- }
-
- return `
- // Add network-first interception BEFORE navigation:
- await page.route('**/api/**', route => route.continue())
- const responsePromise = page.waitForResponse('**/api/data')
- await page.goto('/page')
- await responsePromise
- `.trim();
- }
- ```
-
- **Healing Implementation**:
-
- ```typescript
- // tests/healing/timing-healing.spec.ts
- import { test, expect } from '@playwright/test';
- import { isTimingFailure, hasHardWait, suggestDeterministicWait } from '../../src/testing/healing/timing-healing';
-
- test('heal race condition with network-first pattern', async ({ page, context }) => {
- // Setup interception BEFORE navigation (prevent race)
- await context.route('**/api/products', (route) => {
- route.fulfill({
- status: 200,
- body: JSON.stringify({ products: [{ id: 1, name: 'Product A' }] }),
- });
- });
-
- const responsePromise = page.waitForResponse('**/api/products');
-
- await page.goto('/products');
- await responsePromise; // Deterministic wait
-
- // Element now reliably visible (no race condition)
- await expect(page.getByText('Product A')).toBeVisible();
- });
-
- test('heal hard wait with event-based wait', async ({ page }) => {
- await page.goto('/dashboard');
-
- // ❌ Original (flaky): await page.waitForTimeout(3000)
-
- // ✅ Healed: Wait for spinner to disappear
- await page.getByTestId('loading-spinner').waitFor({ state: 'detached' });
-
- // Element now reliably visible
- await expect(page.getByText('Dashboard loaded')).toBeVisible();
- });
- ```
-
- **Key Points**:
-
- - Diagnosis: Error contains "timeout" or "not visible", often after navigation
- - Fix: Replace hard waits with network-first pattern or element state waits
- - Prevention: ALWAYS intercept before navigate, use waitForResponse()
- - Automation: Detect `page.waitForTimeout()` or `cy.wait(number)` in test code
-
- ---
-
- ### Example 3: Common Failure Pattern - Dynamic Data Assertions (Non-Deterministic IDs)
-
- **Context**: Test fails with "Expected 'User 123' but received 'User 456'" or timestamp mismatches
-
- **Diagnostic Signature**:
-
- ```typescript
- // src/testing/healing/data-healing.ts
-
- export type DataFailure = {
- errorMessage: string;
- expectedValue: string;
- actualValue: string;
- testFile: string;
- lineNumber: number;
- };
-
- /**
- * Detect dynamic data assertion failures
- */
- export function isDynamicDataFailure(error: Error): boolean {
- const patterns = [
- /expected.*\d+.*received.*\d+/i, // ID mismatches
- /expected.*\d{4}-\d{2}-\d{2}.*received/i, // Date mismatches
- /expected.*user.*\d+/i, // Dynamic user IDs
- /expected.*order.*\d+/i, // Dynamic order IDs
- /expected.*to.*contain.*\d+/i, // Numeric assertions
- ];
-
- return patterns.some((pattern) => pattern.test(error.message));
- }
-
- /**
- * Suggest flexible assertion pattern
- */
- export function suggestFlexibleAssertion(errorMessage: string): string {
- if (/expected.*user.*\d+/i.test(errorMessage)) {
- return `
- // ❌ Bad: Hardcoded ID
- // await expect(page.getByText('User 123')).toBeVisible()
-
- // ✅ Good: Regex pattern for any user ID
- await expect(page.getByText(/User \\d+/)).toBeVisible()
-
- // OR use partial match
- await expect(page.locator('[data-testid="user-name"]')).toContainText('User')
- `.trim();
- }
-
- if (/expected.*\d{4}-\d{2}-\d{2}/i.test(errorMessage)) {
- return `
- // ❌ Bad: Hardcoded date
- // await expect(page.getByText('2024-01-15')).toBeVisible()
-
- // ✅ Good: Dynamic date validation
- const today = new Date().toISOString().split('T')[0]
- await expect(page.getByTestId('created-date')).toHaveText(today)
-
- // OR use date format regex
- await expect(page.getByTestId('created-date')).toHaveText(/\\d{4}-\\d{2}-\\d{2}/)
- `.trim();
- }
-
- if (/expected.*order.*\d+/i.test(errorMessage)) {
- return `
- // ❌ Bad: Hardcoded order ID
- // const orderId = '12345'
-
- // ✅ Good: Capture dynamic order ID
- const orderText = await page.getByTestId('order-id').textContent()
- const orderId = orderText?.match(/Order #(\\d+)/)?.[1]
- expect(orderId).toBeTruthy()
-
- // Use captured ID in later assertions
- await expect(page.getByText(\`Order #\${orderId} confirmed\`)).toBeVisible()
- `.trim();
- }
-
- return `Use regex patterns, partial matching, or capture dynamic values instead of hardcoding`;
- }
- ```
-
- **Healing Implementation**:
-
- ```typescript
- // tests/healing/data-healing.spec.ts
- import { test, expect } from '@playwright/test';
-
- test('heal dynamic ID assertion with regex', async ({ page }) => {
- await page.goto('/users');
-
- // ❌ Original (fails with random IDs): await expect(page.getByText('User 123')).toBeVisible()
-
- // ✅ Healed: Regex pattern matches any user ID
- await expect(page.getByText(/User \d+/)).toBeVisible();
- });
-
- test('heal timestamp assertion with dynamic generation', async ({ page }) => {
- await page.goto('/dashboard');
-
- // ❌ Original (fails daily): await expect(page.getByText('2024-01-15')).toBeVisible()
-
- // ✅ Healed: Generate expected date dynamically
- const today = new Date().toISOString().split('T')[0];
- await expect(page.getByTestId('last-updated')).toContainText(today);
- });
-
- test('heal order ID assertion with capture', async ({ page, request }) => {
- // Create order via API (dynamic ID)
- const response = await request.post('/api/orders', {
- data: { productId: '123', quantity: 1 },
- });
- const { orderId } = await response.json();
-
- // ✅ Healed: Use captured dynamic ID
- await page.goto(`/orders/${orderId}`);
- await expect(page.getByText(`Order #${orderId}`)).toBeVisible();
- });
- ```
-
- **Key Points**:
-
- - Diagnosis: Error message shows expected vs actual value mismatch with IDs/timestamps
- - Fix: Use regex patterns (`/User \d+/`), partial matching, or capture dynamic values
- - Prevention: Never hardcode IDs, timestamps, or random data in assertions
- - Automation: Parse error message for expected/actual values, suggest regex patterns
-
- ---
-
- ### Example 4: Common Failure Pattern - Network Errors (Missing Route Interception)
-
- **Context**: Test fails with "API call failed" or "500 error" during test execution
-
- **Diagnostic Signature**:
-
- ```typescript
- // src/testing/healing/network-healing.ts
-
- export type NetworkFailure = {
- errorMessage: string;
- url: string;
- statusCode: number;
- method: string;
- };
-
- /**
- * Detect network failure
- */
- export function isNetworkFailure(error: Error): boolean {
- const patterns = [
- /api.*call.*failed/i,
- /request.*failed/i,
- /network.*error/i,
- /500.*internal server error/i,
- /503.*service unavailable/i,
- /fetch.*failed/i,
- ];
-
- return patterns.some((pattern) => pattern.test(error.message));
- }
-
- /**
- * Suggest route interception
- */
- export function suggestRouteInterception(url: string, method: string): string {
- return `
- // ❌ Bad: Real API call (unreliable, slow, external dependency)
-
- // ✅ Good: Mock API response with route interception
- await page.route('${url}', route => {
- route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({
- // Mock response data
- id: 1,
- name: 'Test User',
- email: 'test@example.com'
- })
- })
- })
-
- // Then perform action
- await page.goto('/page')
- `.trim();
- }
- ```
-
- **Healing Implementation**:
-
- ```typescript
- // tests/healing/network-healing.spec.ts
- import { test, expect } from '@playwright/test';
-
- test('heal network failure with route mocking', async ({ page, context }) => {
- // ✅ Healed: Mock API to prevent real network calls
- await context.route('**/api/products', (route) => {
- route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({
- products: [
- { id: 1, name: 'Product A', price: 29.99 },
- { id: 2, name: 'Product B', price: 49.99 },
- ],
- }),
- });
- });
-
- await page.goto('/products');
-
- // Test now reliable (no external API dependency)
- await expect(page.getByText('Product A')).toBeVisible();
- await expect(page.getByText('$29.99')).toBeVisible();
- });
-
- test('heal 500 error with error state mocking', async ({ page, context }) => {
- // Mock API failure scenario
- await context.route('**/api/products', (route) => {
- route.fulfill({ status: 500, body: JSON.stringify({ error: 'Internal Server Error' }) });
- });
-
- await page.goto('/products');
-
- // Verify error handling (not crash)
- await expect(page.getByText('Unable to load products')).toBeVisible();
- await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
- });
- ```
-
- **Key Points**:
-
- - Diagnosis: Error message contains "API call failed", "500 error", or network-related failures
- - Fix: Add `page.route()` or `cy.intercept()` to mock API responses
- - Prevention: Mock ALL external dependencies (APIs, third-party services)
- - Automation: Extract URL from error message, generate route interception code
-
- ---
-
- ### Example 5: Common Failure Pattern - Hard Waits (Unreliable Timing)
-
- **Context**: Test fails intermittently with "timeout exceeded" or passes/fails randomly
-
- **Diagnostic Signature**:
-
- ```typescript
- // src/testing/healing/hard-wait-healing.ts
-
- /**
- * Detect hard wait anti-pattern in test code
- */
- export function detectHardWaits(testCode: string): Array<{ line: number; code: string }> {
- const lines = testCode.split('\n');
- const violations: Array<{ line: number; code: string }> = [];
-
- lines.forEach((line, index) => {
- if (line.includes('page.waitForTimeout(') || /cy\.wait\(\d+\)/.test(line) || line.includes('sleep(') || line.includes('setTimeout(')) {
- violations.push({ line: index + 1, code: line.trim() });
- }
- });
-
- return violations;
- }
-
- /**
- * Suggest event-based wait replacement
- */
- export function suggestEventBasedWait(hardWaitLine: string): string {
- if (hardWaitLine.includes('page.waitForTimeout')) {
- return `
- // ❌ Bad: Hard wait (flaky)
- ${hardWaitLine}
-
- // ✅ Good: Wait for network response
- await page.waitForResponse(resp => resp.url().includes('/api/') && resp.ok())
-
- // OR wait for element state change
- await page.getByTestId('loading-spinner').waitFor({ state: 'detached' })
- await page.getByTestId('content').waitFor({ state: 'visible' })
- `.trim();
- }
-
- if (/cy\.wait\(\d+\)/.test(hardWaitLine)) {
- return `
- // ❌ Bad: Hard wait (flaky)
- ${hardWaitLine}
-
- // ✅ Good: Wait for aliased request
- cy.intercept('GET', '/api/data').as('getData')
- cy.visit('/page')
- cy.wait('@getData') // Deterministic
- `.trim();
- }
-
- return 'Replace hard waits with event-based waits (waitForResponse, waitFor state changes)';
- }
- ```
-
- **Healing Implementation**:
-
- ```typescript
- // tests/healing/hard-wait-healing.spec.ts
- import { test, expect } from '@playwright/test';
-
- test('heal hard wait with deterministic wait', async ({ page }) => {
- await page.goto('/dashboard');
-
- // ❌ Original (flaky): await page.waitForTimeout(3000)
-
- // ✅ Healed: Wait for loading spinner to disappear
- await page.getByTestId('loading-spinner').waitFor({ state: 'detached' });
-
- // OR wait for specific network response
- await page.waitForResponse((resp) => resp.url().includes('/api/dashboard') && resp.ok());
-
- await expect(page.getByText('Dashboard ready')).toBeVisible();
- });
-
- test('heal implicit wait with explicit network wait', async ({ page }) => {
- const responsePromise = page.waitForResponse('**/api/products');
-
- await page.goto('/products');
-
- // ❌ Original (race condition): await page.getByText('Product A').click()
-
- // ✅ Healed: Wait for network first
- await responsePromise;
- await page.getByText('Product A').click();
-
- await expect(page).toHaveURL(/\/products\/\d+/);
- });
- ```
-
- **Key Points**:
-
- - Diagnosis: Test code contains `page.waitForTimeout()` or `cy.wait(number)`
- - Fix: Replace with `waitForResponse()`, `waitFor({ state })`, or aliased intercepts
- - Prevention: NEVER use hard waits, always use event-based/response-based waits
- - Automation: Scan test code for hard wait patterns, suggest deterministic replacements
-
- ---
-
- ## Healing Pattern Catalog
-
- | Failure Type | Diagnostic Signature | Healing Strategy | Prevention Pattern |
- | -------------- | --------------------------------------------- | ------------------------------------- | ----------------------------------------- |
- | Stale Selector | "locator resolved to 0 elements" | Replace with data-testid or ARIA role | Selector hierarchy (testid > ARIA > text) |
- | Race Condition | "timeout waiting for element" | Add network-first interception | Intercept before navigate |
- | Dynamic Data | "Expected 'User 123' but got 'User 456'" | Use regex or capture dynamic values | Never hardcode IDs/timestamps |
- | Network Error | "API call failed", "500 error" | Add route mocking | Mock all external dependencies |
- | Hard Wait | Test contains `waitForTimeout()` or `wait(n)` | Replace with event-based waits | Always use deterministic waits |
-
- ## Healing Workflow
-
- 1. **Run test** → Capture failure
- 2. **Identify pattern** → Match error against diagnostic signatures
- 3. **Apply fix** → Use pattern-based healing strategy
- 4. **Re-run test** → Validate fix (max 3 iterations)
- 5. **Mark unfixable** → Use `test.fixme()` if healing fails after 3 attempts
-
- ## Healing Checklist
-
- Before enabling auto-healing in workflows:
-
- - [ ] **Failure catalog documented**: Common patterns identified (selectors, timing, data, network, hard waits)
- - [ ] **Diagnostic signatures defined**: Error message patterns for each failure type
- - [ ] **Healing strategies documented**: Fix patterns for each failure type
- - [ ] **Prevention patterns documented**: Best practices to avoid recurrence
- - [ ] **Healing iteration limit set**: Max 3 attempts before marking test.fixme()
- - [ ] **MCP integration optional**: Graceful degradation without Playwright MCP
- - [ ] **Pattern-based fallback**: Use knowledge base patterns when MCP unavailable
- - [ ] **Healing report generated**: Document what was healed and how
-
- ## Integration Points
-
- - **Used in workflows**: `*automate` (auto-healing after test generation), `*atdd` (optional healing for acceptance tests)
- - **Related fragments**: `selector-resilience.md` (selector debugging), `timing-debugging.md` (race condition fixes), `network-first.md` (interception patterns), `data-factories.md` (dynamic data handling)
- - **Tools**: Error message parsing, AST analysis for code patterns, Playwright MCP (optional), pattern matching
-
- _Source: Playwright test-healer patterns, production test failure analysis, common anti-patterns from test-resources-for-ai_
|