Skip to content
← Back to Codex
Automation12 min read

Building a Playwright Automation Framework from Scratch

Most teams start automation the wrong way: one engineer writes a few scripts, they work on their machine, and six months later nobody can maintain them. I've built automation frameworks at two companies now, and the difference between a pile of scripts and a real framework comes down to three decisions you make on day one.

Why Playwright

I've worked with Selenium, Cypress, and Playwright across different teams. Playwright won me over for a few reasons:

The Page Object Model

The Page Object Model (POM) is the single most important pattern in test automation. Every page or component gets a class that encapsulates its selectors and interactions.

// pages/login.page.ts
export class LoginPage {
  constructor(private page: Page) {}

  private usernameInput = this.page.getByLabel('Username');
  private passwordInput = this.page.getByLabel('Password');
  private submitButton = this.page.getByRole('button', { name: 'Sign in' });
  private errorMessage = this.page.getByRole('alert');

  async login(username: string, password: string) {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }
}

Why this matters: when the dev team changes the login form, you update one file. Not thirty tests.

Fixtures Over Setup Functions

Playwright fixtures are dependency injection for tests. Instead of calling setup functions in every test, you declare what you need and the framework provides it.

// fixtures.ts
type MyFixtures = {
  loginPage: LoginPage;
  authenticatedPage: Page;
};

export const test = base.extend<MyFixtures>({
  loginPage: async ({ page }, use) => {
    await page.goto('/login');
    await use(new LoginPage(page));
  },
  authenticatedPage: async ({ page }, use) => {
    await page.goto('/login');
    const loginPage = new LoginPage(page);
    await loginPage.login('testuser', 'password123');
    await page.waitForURL('/dashboard');
    await use(page);
  },
});

Now tests read like specifications:

test('locked account shows lockout message', async ({ loginPage }) => {
  await loginPage.login('locked-user', 'password');
  await loginPage.expectError('Account locked after multiple failed attempts');
});

Folder Structure That Scales

tests/
  fixtures/
    auth.fixture.ts
    navigation.fixture.ts
  pages/
    login.page.ts
    dashboard.page.ts
    settings.page.ts
  specs/
    auth/
      login.spec.ts
      logout.spec.ts
      password-reset.spec.ts
    dashboard/
      widgets.spec.ts
      filters.spec.ts
  helpers/
    api-client.ts
    test-data.ts
playwright.config.ts

Group specs by feature, not by type. When someone asks "do we have tests for password reset?" the answer should be a folder, not a grep.

Test Data Strategy

Hard-coded test data is a maintenance nightmare. I use a layered approach:

  1. API seeding. Before each test suite, hit the API to create the exact data state you need. This is faster than UI setup and less fragile.
  2. Factory functions. Generate unique data per test run to avoid collisions in shared environments.
  3. Cleanup hooks. Tests should clean up after themselves. If a test creates a user, it deletes that user in the teardown.
function generateUser(overrides = {}) {
  return {
    username: `test-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
    email: `test-${Date.now()}@example.com`,
    password: 'Test123!@#',
    ...overrides,
  };
}

CI Integration

Tests that only run locally aren't tests — they're demos. Every automation framework needs CI from day one.

Key CI decisions:

Lessons Learned

After building frameworks for two teams and maintaining suites of 700+ test cases:

The best automation framework is the one your team actually uses. Keep it simple, keep it fast, keep it trustworthy.