Maintain, refactor, and improve existing Playwright E2E tests. Handles tasks like updating locators across test suites, extracting reusable utilities, improving test stability, removing code duplication, and enforcing best practices throughout the test codebase.
Maintains and refactors existing Playwright E2E tests by updating locators, extracting utilities, fixing flaky tests, and enforcing best practices. Use this when updating tests after UI changes, consolidating duplicate code, or improving test stability—not for creating new tests.
/plugin marketplace add joel611/claude-plugins/plugin install playwright-e2e@joel-pluginsThis skill inherits all available tools. When active, it can use any tool Claude has access to.
resources/best-practices.mdresources/refactoring-patterns.mdMaintain, refactor, and improve existing Playwright E2E tests. Handles tasks like updating locators across test suites, extracting reusable utilities, improving test stability, removing code duplication, and enforcing best practices throughout the test codebase.
Use this skill when you need to:
Do NOT use this skill when:
Before using this skill:
Gather information about:
Determine the maintenance task:
Locator Updates:
Code Refactoring:
Stability Improvements:
Best Practices:
Before making changes:
Execute the maintenance based on type:
// Task: Update data-testid from "btn-submit" to "submit-button"
// Before (multiple files)
await page.locator('[data-testid="btn-submit"]').click();
// After (updated in all files)
await page.locator('[data-testid="submit-button"]').click();
// Use search and replace across files
// Find: '[data-testid="btn-submit"]'
// Replace: '[data-testid="submit-button"]'
// Before: Duplicate login code in multiple tests
test('test 1', async ({ page }) => {
await page.goto('/login');
await page.locator('[data-testid="email"]').fill('user@example.com');
await page.locator('[data-testid="password"]').fill('password');
await page.locator('[data-testid="login-button"]').click();
await page.waitForURL('/dashboard');
// ... test continues
});
// After: Extract to utility function
// In utils/auth.ts
export async function login(page: Page, email: string, password: string) {
await page.goto('/login');
await page.locator('[data-testid="email"]').fill(email);
await page.locator('[data-testid="password"]').fill(password);
await page.locator('[data-testid="login-button"]').click();
await page.waitForURL('/dashboard');
}
// In tests
test('test 1', async ({ page }) => {
await login(page, 'user@example.com', 'password');
// ... test continues
});
// Before: Inline selectors throughout tests
test('update profile', async ({ page }) => {
await page.goto('/profile');
await page.locator('[data-testid="name-input"]').fill('John Doe');
await page.locator('[data-testid="email-input"]').fill('john@example.com');
await page.locator('[data-testid="save-button"]').click();
await expect(page.locator('[data-testid="success-message"]')).toBeVisible();
});
// After: Using Page Object
// Create ProfilePage.ts (see page-object-builder skill)
test('update profile', async ({ page }) => {
const profilePage = new ProfilePage(page);
await profilePage.goto();
await profilePage.updateProfile({
name: 'John Doe',
email: 'john@example.com'
});
await expect(profilePage.getSuccessMessage()).toBeVisible();
});
// Before: Flaky due to race condition
await page.locator('[data-testid="submit"]').click();
await expect(page.locator('[data-testid="result"]')).toContainText('Success');
// After: Add proper waits
await page.locator('[data-testid="submit"]').click();
await page.waitForLoadState('networkidle');
await expect(page.locator('[data-testid="result"]')).toContainText('Success', {
timeout: 10000
});
After changes:
Run tests to ensure:
Input: "The development team changed all button data-testids from format 'btn-action' to 'action-button'. Update all tests."
Changes:
// Create mapping of old to new testids
const locatorUpdates = {
'btn-submit': 'submit-button',
'btn-cancel': 'cancel-button',
'btn-delete': 'delete-button',
'btn-edit': 'edit-button',
'btn-save': 'save-button',
};
// Apply to all test files:
// Find all instances in: tests/**/*.spec.ts
// Example in login.spec.ts:
// Before
await page.locator('[data-testid="btn-submit"]').click();
// After
await page.locator('[data-testid="submit-button"]').click();
// Use global find and replace for each mapping
Verification:
# Search for old pattern to ensure all updated
grep -r "btn-" tests/
# Run all tests
npx playwright test
# Check for any failures
Input: "Multiple tests have duplicate code for filling forms. Extract to reusable utilities."
Solution:
// Identify duplicate pattern across tests:
// Pattern 1: Form filling
await page.locator('[data-testid="field1"]').fill(value1);
await page.locator('[data-testid="field2"]').fill(value2);
await page.locator('[data-testid="field3"]').fill(value3);
// Create utils/form-helpers.ts:
import { Page } from '@playwright/test';
export async function fillForm(
page: Page,
fields: Record<string, string>
): Promise<void> {
for (const [testId, value] of Object.entries(fields)) {
await page.locator(`[data-testid="${testId}"]`).fill(value);
}
}
export async function submitForm(page: Page, submitButtonTestId: string): Promise<void> {
await page.locator(`[data-testid="${submitButtonTestId}"]`).waitFor({ state: 'visible' });
await page.locator(`[data-testid="${submitButtonTestId}"]`).click();
}
// Update all tests to use utilities:
import { fillForm, submitForm } from '../utils/form-helpers';
test('contact form submission', async ({ page }) => {
await page.goto('/contact');
await fillForm(page, {
'name-input': 'John Doe',
'email-input': 'john@example.com',
'message-input': 'Hello!',
});
await submitForm(page, 'submit-button');
await expect(page.locator('[data-testid="success"]')).toBeVisible();
});
Input: "We have 5 tests that test form validation with different invalid inputs. Consolidate using test.each."
Before:
test('should show error for empty email', async ({ page }) => {
await page.goto('/register');
await page.locator('[data-testid="email"]').fill('');
await page.locator('[data-testid="submit"]').click();
await expect(page.locator('[data-testid="email-error"]')).toBeVisible();
});
test('should show error for invalid email', async ({ page }) => {
await page.goto('/register');
await page.locator('[data-testid="email"]').fill('invalid');
await page.locator('[data-testid="submit"]').click();
await expect(page.locator('[data-testid="email-error"]')).toBeVisible();
});
// ... 3 more similar tests
After:
const invalidEmails = [
{ email: '', description: 'empty email' },
{ email: 'invalid', description: 'invalid format' },
{ email: '@example.com', description: 'missing local part' },
{ email: 'user@', description: 'missing domain' },
{ email: 'user @example.com', description: 'space in email' },
];
test.describe('Email validation', () => {
for (const { email, description } of invalidEmails) {
test(`should show error for ${description}`, async ({ page }) => {
await page.goto('/register');
await page.locator('[data-testid="email"]').fill(email);
await page.locator('[data-testid="submit"]').click();
await expect(page.locator('[data-testid="email-error"]')).toBeVisible();
});
}
});
Input: "Test 'user dashboard loads' fails intermittently with 'element not found' error."
Analysis:
// Current test (flaky):
test('user dashboard loads', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.locator('[data-testid="welcome-message"]')).toBeVisible();
await expect(page.locator('[data-testid="stats-card"]')).toHaveCount(4);
});
// Issue: Not waiting for data to load
Solution:
test('user dashboard loads', async ({ page }) => {
await page.goto('/dashboard');
// Wait for page to fully load
await page.waitForLoadState('networkidle');
// Wait for API call to complete
await page.waitForResponse('**/api/dashboard');
// Now check elements
await expect(page.locator('[data-testid="welcome-message"]')).toBeVisible({
timeout: 10000
});
// Wait for all stats cards to load
await page.locator('[data-testid="stats-card"]').first().waitFor({ state: 'visible' });
await expect(page.locator('[data-testid="stats-card"]')).toHaveCount(4);
});
Input: "Tests require authentication but each test logs in manually. Create fixture for authenticated state."
Solution:
// Create fixtures/auth.ts:
import { test as base, Page } from '@playwright/test';
type AuthFixtures = {
authenticatedPage: Page;
};
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
// Login once
await page.goto('/login');
await page.locator('[data-testid="email"]').fill('test@example.com');
await page.locator('[data-testid="password"]').fill('password');
await page.locator('[data-testid="login-button"]').click();
await page.waitForURL('/dashboard');
await use(page);
// Cleanup if needed
},
});
export { expect } from '@playwright/test';
// Update tests:
// Before
import { test, expect } from '@playwright/test';
test('view profile', async ({ page }) => {
// Login code...
await page.goto('/login');
// ... more login code
// Actual test
await page.goto('/profile');
// ...
});
// After
import { test, expect } from '../fixtures/auth';
test('view profile', async ({ authenticatedPage: page }) => {
// Already logged in!
await page.goto('/profile');
// ... test continues
});
Problem: Need to update hundreds of locators across many files
Solutions:
# Example: Update all instances in all test files
find tests -name "*.spec.ts" -exec sed -i 's/btn-submit/submit-button/g' {} +
# Verify changes
git diff
# Run tests
npx playwright test
Problem: Tests fail after refactoring
Solutions:
Problem: Different tests use different approaches
Solutions:
Problem: Tests are similar but not identical
Solutions:
// Flexible utility with options
async function performLogin(
page: Page,
options: {
email?: string;
password?: string;
rememberMe?: boolean;
expectSuccess?: boolean;
} = {}
) {
const {
email = 'default@example.com',
password = 'password',
rememberMe = false,
expectSuccess = true,
} = options;
await page.goto('/login');
await page.locator('[data-testid="email"]').fill(email);
await page.locator('[data-testid="password"]').fill(password);
if (rememberMe) {
await page.locator('[data-testid="remember-me"]').check();
}
await page.locator('[data-testid="login-button"]').click();
if (expectSuccess) {
await page.waitForURL('/dashboard');
}
}
Problem: Too many layers of abstraction make tests hard to understand
Solutions:
The resources/ directory contains helpful references:
refactoring-patterns.md - Common refactoring patterns for testsmigration-guide.md - Guide for migrating tests to new patternsbest-practices.md - Testing best practices checklistUse when working with Payload CMS projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Use when debugging validation errors, security issues, relationship queries, transactions, or hook behavior.