Files
gh-joel611-claude-plugins-p…/skills/test-maintainer/resources/refactoring-patterns.md
2025-11-30 08:28:25 +08:00

10 KiB

Test Refactoring Patterns

Pattern 1: Extract Method

When: Duplicate code appears in multiple tests

Before:

test('test 1', async ({ page }) => {
  await page.locator('[data-testid="email"]').fill('user@example.com');
  await page.locator('[data-testid="password"]').fill('password');
  await page.locator('[data-testid="login"]').click();
  await page.waitForURL('/dashboard');
  // test continues...
});

test('test 2', async ({ page }) => {
  await page.locator('[data-testid="email"]').fill('user@example.com');
  await page.locator('[data-testid="password"]').fill('password');
  await page.locator('[data-testid="login"]').click();
  await page.waitForURL('/dashboard');
  // different test logic...
});

After:

// utils/auth.ts
export async function login(page: Page, email = 'user@example.com', password = 'password') {
  await page.locator('[data-testid="email"]').fill(email);
  await page.locator('[data-testid="password"]').fill(password);
  await page.locator('[data-testid="login"]').click();
  await page.waitForURL('/dashboard');
}

// In tests
test('test 1', async ({ page }) => {
  await login(page);
  // test continues...
});

test('test 2', async ({ page }) => {
  await login(page);
  // different test logic...
});

Pattern 2: Extract to Fixture

When: Setup code needed for multiple tests

Before:

test('test 1', async ({ page }) => {
  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"]').click();
  await page.waitForURL('/dashboard');
  // test logic...
});

After:

// fixtures/auth.ts
export const test = base.extend<{ authenticatedPage: Page }>({
  authenticatedPage: async ({ page }, use) => {
    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"]').click();
    await page.waitForURL('/dashboard');
    await use(page);
  },
});

// In tests
test('test 1', async ({ authenticatedPage: page }) => {
  // Already logged in
  // test logic...
});

Pattern 3: Extract to Page Object

When: Many tests interact with the same page

Before:

test('test 1', async ({ page }) => {
  await page.goto('/profile');
  await page.locator('[data-testid="name"]').fill('John');
  await page.locator('[data-testid="save"]').click();
  await expect(page.locator('[data-testid="success"]')).toBeVisible();
});

test('test 2', async ({ page }) => {
  await page.goto('/profile');
  await page.locator('[data-testid="email"]').fill('john@example.com');
  await page.locator('[data-testid="save"]').click();
  await expect(page.locator('[data-testid="success"]')).toBeVisible();
});

After:

// page-objects/ProfilePage.ts
export class ProfilePage {
  constructor(readonly page: Page) {}

  async goto() {
    await this.page.goto('/profile');
  }

  async updateName(name: string) {
    await this.page.locator('[data-testid="name"]').fill(name);
  }

  async updateEmail(email: string) {
    await this.page.locator('[data-testid="email"]').fill(email);
  }

  async save() {
    await this.page.locator('[data-testid="save"]').click();
  }

  getSuccessMessage() {
    return this.page.locator('[data-testid="success"]');
  }
}

// In tests
test('test 1', async ({ page }) => {
  const profilePage = new ProfilePage(page);
  await profilePage.goto();
  await profilePage.updateName('John');
  await profilePage.save();
  await expect(profilePage.getSuccessMessage()).toBeVisible();
});

Pattern 4: Parameterized Tests

When: Same test logic with different inputs

Before:

test('validates email 1', async ({ page }) => {
  await page.locator('[data-testid="email"]').fill('invalid');
  await page.locator('[data-testid="submit"]').click();
  await expect(page.locator('[data-testid="error"]')).toBeVisible();
});

test('validates email 2', async ({ page }) => {
  await page.locator('[data-testid="email"]').fill('@example.com');
  await page.locator('[data-testid="submit"]').click();
  await expect(page.locator('[data-testid="error"]')).toBeVisible();
});

After:

const invalidEmails = [
  'invalid',
  '@example.com',
  'user@',
  '',
  'user @example.com',
];

for (const email of invalidEmails) {
  test(`validates email: ${email}`, async ({ page }) => {
    await page.locator('[data-testid="email"]').fill(email);
    await page.locator('[data-testid="submit"]').click();
    await expect(page.locator('[data-testid="error"]')).toBeVisible();
  });
}

Pattern 5: Builder Pattern for Complex Setup

When: Tests need complex object creation

Before:

test('create user', async ({ page }) => {
  await page.locator('[data-testid="name"]').fill('John');
  await page.locator('[data-testid="email"]').fill('john@example.com');
  await page.locator('[data-testid="age"]').fill('30');
  await page.locator('[data-testid="country"]').selectOption('USA');
  await page.locator('[data-testid="bio"]').fill('Software engineer');
  // ... many more fields
});

After:

// utils/UserBuilder.ts
export class UserBuilder {
  private data: Record<string, string> = {
    name: 'Default Name',
    email: 'default@example.com',
    age: '25',
    country: 'USA',
  };

  withName(name: string) {
    this.data.name = name;
    return this;
  }

  withEmail(email: string) {
    this.data.email = email;
    return this;
  }

  async fillForm(page: Page) {
    for (const [field, value] of Object.entries(this.data)) {
      await page.locator(`[data-testid="${field}"]`).fill(value);
    }
  }
}

// In test
test('create user', async ({ page }) => {
  await new UserBuilder()
    .withName('John')
    .withEmail('john@example.com')
    .fillForm(page);
});

Pattern 6: Test Data Factory

When: Need consistent test data across tests

Before:

test('test 1', async ({ page }) => {
  const user = {
    email: 'test@example.com',
    password: 'password123',
    name: 'Test User',
  };
  // use user...
});

test('test 2', async ({ page }) => {
  const user = {
    email: 'test2@example.com',
    password: 'password123',
    name: 'Test User 2',
  };
  // use user...
});

After:

// utils/factories.ts
export const createUser = (overrides = {}) => ({
  email: 'test@example.com',
  password: 'password123',
  name: 'Test User',
  ...overrides,
});

// In tests
test('test 1', async ({ page }) => {
  const user = createUser();
  // use user...
});

test('test 2', async ({ page }) => {
  const user = createUser({ email: 'test2@example.com', name: 'Test User 2' });
  // use user...
});

Pattern 7: Extract Wait Strategy

When: Consistent waiting needed across tests

Before:

test('test 1', async ({ page }) => {
  await page.locator('[data-testid="submit"]').click();
  await page.waitForLoadState('networkidle');
  await expect(page.locator('[data-testid="result"]')).toBeVisible({ timeout: 10000 });
});

test('test 2', async ({ page }) => {
  await page.locator('[data-testid="save"]').click();
  await page.waitForLoadState('networkidle');
  await expect(page.locator('[data-testid="success"]')).toBeVisible({ timeout: 10000 });
});

After:

// utils/wait-helpers.ts
export async function waitForActionComplete(page: Page, resultTestId: string) {
  await page.waitForLoadState('networkidle');
  await page.locator(`[data-testid="${resultTestId}"]`).waitFor({
    state: 'visible',
    timeout: 10000,
  });
}

// In tests
test('test 1', async ({ page }) => {
  await page.locator('[data-testid="submit"]').click();
  await waitForActionComplete(page, 'result');
});

Pattern 8: Consolidate Assertions

When: Similar assertions repeated across tests

Before:

test('test 1', async ({ page }) => {
  // ... actions
  await expect(page.locator('[data-testid="success"]')).toBeVisible();
  await expect(page.locator('[data-testid="success"]')).toContainText('Success');
  await expect(page.locator('[data-testid="error"]')).not.toBeVisible();
});

After:

// utils/assertions.ts
export async function assertSuccess(page: Page) {
  await expect(page.locator('[data-testid="success"]')).toBeVisible();
  await expect(page.locator('[data-testid="success"]')).toContainText('Success');
  await expect(page.locator('[data-testid="error"]')).not.toBeVisible();
}

// In tests
test('test 1', async ({ page }) => {
  // ... actions
  await assertSuccess(page);
});

Pattern 9: Extract Navigation Logic

When: Complex navigation patterns repeated

Before:

test('test 1', async ({ page }) => {
  await page.goto('/');
  await page.locator('[data-testid="menu"]').click();
  await page.locator('[data-testid="settings"]').click();
  await page.locator('[data-testid="profile"]').click();
  // test logic...
});

After:

// utils/navigation.ts
export async function navigateToProfile(page: Page) {
  await page.goto('/');
  await page.locator('[data-testid="menu"]').click();
  await page.locator('[data-testid="settings"]').click();
  await page.locator('[data-testid="profile"]').click();
  await page.waitForLoadState('domcontentloaded');
}

// In tests
test('test 1', async ({ page }) => {
  await navigateToProfile(page);
  // test logic...
});

Pattern 10: Replace Magic Strings with Constants

When: Same strings/values used in multiple places

Before:

test('test 1', async ({ page }) => {
  await page.locator('[data-testid="submit-button"]').click();
});

test('test 2', async ({ page }) => {
  await expect(page.locator('[data-testid="submit-button"]')).toBeEnabled();
});

After:

// constants/testids.ts
export const TESTIDS = {
  SUBMIT_BUTTON: 'submit-button',
  CANCEL_BUTTON: 'cancel-button',
  // ... more testids
};

// In tests
test('test 1', async ({ page }) => {
  await page.locator(`[data-testid="${TESTIDS.SUBMIT_BUTTON}"]`).click();
});

// Or create a helper
function getByTestId(page: Page, testId: string) {
  return page.locator(`[data-testid="${testId}"]`);
}

test('test 1', async ({ page }) => {
  await getByTestId(page, TESTIDS.SUBMIT_BUTTON).click();
});