Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 010 / 100

Playwright codegen: a practical guide for developers

Record tests by clicking.

Expona Admin7 min read

Introduction

Manually writing Playwright locators and test scripts from scratch is tedious and error-prone, especially when you're building complex user journeys across evolving UIs. Playwright codegen: a practical guide for developers shows you how to record tests by clicking through your application, letting the browser automation framework generate the code for you. This approach lets you focus on test structure rather than syntax, and it's particularly valuable when onboarding to a new codebase or covering critical user flows quickly.

In this article, we'll explore how to use npx playwright codegen, understand why the generated code is a starting point rather than production-ready, and refactor it into maintainable page objects that scale across your test suite.

Getting started with npx playwright codegen

The simplest way to begin recording a test is to run the codegen command pointing at your target URL:

typescript
npx playwright codegen https://example.com

This launches a browser window and a code inspector panel. As you interact with the page—clicking buttons, filling forms, navigating—Playwright watches your actions and translates them into test code in real time. When you're done, you can copy the generated script into a new test file or save it directly.

Let's walk through a realistic scenario: suppose you're testing a checkout flow on an e-commerce site. You'd run codegen against your staging environment, then:

  1. Click the "Add to Cart" button for a product
  2. Navigate to the shopping cart
  3. Fill in shipping address fields
  4. Select a payment method
  5. Click "Place Order"

Codegen records each interaction, outputting something like this:

typescript
import { test, expect } from '@playwright/test';

test('checkout flow', async ({ page }) => {
  await page.goto('https://example.com');
  await page.getByRole('button', { name: 'Add to Cart' }).click();
  await page.getByRole('link', { name: 'Cart' }).click();
  await page.getByLabel('Full Name').fill('John Doe');
  await page.getByLabel('Address').fill('123 Main St');
  await page.getByRole('button', { name: 'Place Order' }).click();
  await expect(page.getByText('Order confirmed')).toBeVisible();
});

Notice how clean this looks compared to hand-written XPath or CSS selectors. That's the first major benefit of codegen: it surfaces the convenience and readability that Playwright's locator strategies provide.

Generates role-based locators

One of the smartest features of Playwright codegen is its preference for role-based locators. Instead of relying on fragile CSS classes or buried IDs, it queries the accessibility tree—asking "what role does this element play?" rather than "what class name does it have?"

When you click a button, codegen generates:

typescript
await page.getByRole('button', { name: 'Submit' }).click();

Not:

typescript
await page.locator('.btn-primary.submit-btn').click();

This matters because role-based locators are resilient. If your design system refactors CSS class names, your test still works. If a designer changes the button's visual styling but keeps the same label, codegen's locator strategy keeps working.

Codegen generates role locators for buttons, links, headings, textboxes, checkboxes, comboboxes, and many other semantic elements. When role-based selection isn't possible, it falls back to label matching:

typescript
await page.getByLabel('Email Address').fill('user@example.com');

This ties the locator to the form label text, which is also more stable than class or id selectors. If the label changes, you'll know immediately that the test needs updating—which is actually desirable, because the label change likely affects users too.

Occasionally, codegen will use a test ID when no semantic option exists:

typescript
await page.getByTestId('submit-button').click();

This requires you to have added data-testid attributes to your application, but it's a worthwhile practice. Test IDs are explicit, stable, and communicate intent to your team that this element is important for testing.

Great starting point, not final code

Here's the critical mindset shift: codegen output is a draft, not production code. The generated test will pass, but it often lacks structure, reusability, and resilience needed for a mature test suite.

Consider this codegen output from a SaaS application's user management flow:

typescript
test('admin creates new team member', async ({ page }) => {
  await page.goto('https://app.example.com/dashboard');
  await page.getByRole('button', { name: 'Invite Team Member' }).click();
  await page.getByLabel('Email').fill('alice@company.com');
  await page.getByLabel('Role').selectOption('Editor');
  await page.getByRole('button', { name: 'Send Invite' }).click();
  await expect(page.getByText('Invite sent to alice@company.com')).toBeVisible();
});

This test works perfectly once. But problems emerge as your test suite grows:

  • Duplication: If five tests need to navigate to the dashboard, they all repeat the same goto and click.
  • Brittleness: If the "Send Invite" button label changes to "Send Invitation", all tests using that locator break simultaneously.
  • Readability: A new team member reading this test doesn't immediately understand the business intent—they're reading UI mechanics, not test logic.
  • Maintenance burden: Updating test IDs or labels means searching across dozens of test files instead of updating one central location.

The solution is refactoring. Codegen gives you a working test; your job is to extract patterns, abstract UI interactions, and build a test infrastructure that scales.

Refactor into page objects

The Page Object Model is the industry standard for exactly this situation. Each page (or logical section of your application) gets its own class that encapsulates locators and interactions. Tests then call high-level methods instead of low-level Playwright commands.

Starting from our codegen output, we create a TeamPage class:

typescript
import { Page, expect } from '@playwright/test';

export class TeamPage {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto('https://app.example.com/dashboard');
  }

  async clickInviteButton() {
    await this.page.getByRole('button', { name: 'Invite Team Member' }).click();
  }

  async fillEmail(email: string) {
    await this.page.getByLabel('Email').fill(email);
  }

  async selectRole(role: string) {
    await this.page.getByLabel('Role').selectOption(role);
  }

  async clickSendInvite() {
    await this.page.getByRole('button', { name: 'Send Invite' }).click();
  }

  async expectInviteConfirmation(email: string) {
    await expect(
      this.page.getByText(`Invite sent to ${email}`)
    ).toBeVisible();
  }
}

Now the test becomes:

typescript
import { test } from '@playwright/test';
import { TeamPage } from './pages/team.page';

test('admin creates new team member', async ({ page }) => {
  const teamPage = new TeamPage(page);
  await teamPage.goto();
  await teamPage.clickInviteButton();
  await teamPage.fillEmail('alice@company.com');
  await teamPage.selectRole('Editor');
  await teamPage.clickSendInvite();
  await teamPage.expectInviteConfirmation('alice@company.com');
});

Much clearer. But we can push further and compose methods into higher-level workflows:

typescript
export class TeamPage {
  // ... previous methods ...

  async inviteTeamMember(email: string, role: string) {
    await this.clickInviteButton();
    await this.fillEmail(email);
    await this.selectRole(role);
    await this.clickSendInvite();
    await this.expectInviteConfirmation(email);
  }
}

Now the test is almost self-documenting:

typescript
test('admin creates new team member', async ({ page }) => {
  const teamPage = new TeamPage(page);
  await teamPage.goto();
  await teamPage.inviteTeamMember('alice@company.com', 'Editor');
});

When the "Send Invite" button text changes in your application, you update it in one place—the TeamPage class—and all tests pass again.

Over time, you'll build a library of page objects covering your entire application. New tests are written faster because you're composing existing, tested methods rather than discovering locators all over again.

A practical workflow

Here's how we approach this in practice:

  1. Run codegen to capture a user flow end-to-end. Don't overthink it; just interact naturally.
  2. Get it passing in your test file without any refactoring. Commit this as a working baseline.
  3. Extract a page object for the pages involved. Start with one method per UI interaction from the codegen output.
  4. Compose higher-level methods in the page object that represent business workflows, not UI mechanics.
  5. Rewrite the test to use the page object methods, reading like a scenario description rather than a script.
  6. Reuse the page object in new tests covering related scenarios. Each new test adds coverage without duplication.

This incremental approach means you get testing coverage immediately (from codegen), then steadily improve test quality as your page object library matures.

Common mistakes

  • Deploying codegen output directly to CI: It works, but you're missing the maintainability and reusability that page objects provide. Invest 15 minutes in refactoring; your future self will thank you.
  • Recording tests on production: Always run codegen against staging or a test environment. Production data is precious, and accidental state changes are a risk. Reserve production for smoke tests that verify deployed functionality without modifying data.
  • Ignoring slow locators: Occasionally, codegen generates a locator that works but relies on nested traversals or lengthy text matching. During refactoring, simplify these. A well-chosen test ID or more direct role selector keeps tests fast.
  • Over-abstracting too early: Don't build a massive page object framework before writing tests. Write a few tests, notice the patterns, refactor, then standardize. Over-engineering upfront wastes time on abstractions you won't use.
  • Forgetting about dynamic content: If a page loads data asynchronously, codegen might record a click that happens before the element is ready. Always verify wait strategies are in place—Playwright's locators have built-in waiting, but make it explicit if needed.

Key takeaways

  • Use npx playwright codegen <url> to record user interactions and generate test code rapidly, eliminating manual locator discovery.
  • Codegen prioritizes role-based and label-based locators, which are more resilient to UI refactoring than class or ID selectors alone.
  • Generated code is a functional starting point, not final production code—always plan to refactor for maintainability and reusability.
  • Refactor codegen output into page objects that encapsulate UI locators and interactions, reducing duplication and making tests more readable.
  • Compose page object methods into business-level workflows, so tests read as scenarios rather than low-level UI mechanics.
  • Apply page objects across multiple tests to scale coverage efficiently; new tests leverage existing, battle-tested methods.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles