I was staring at a 47-tab spreadsheet our QA team used to track manual regression tests. Every release cycle, someone would spend two full days clicking through the app, verifying that nothing had broken. When I asked why we didn't have automated end-to-end tests, the answer was always the same: "We don't have time to write them."
That's when I started looking at Playwright's codegen tool. The promise was tempting—just click around your app and get a real, runnable test file out the other end. But what I quickly learned is that recording a test is the easy part. Making it survive more than one sprint is where the real work begins.
Here's what I wish someone had told me before I started.
The Problem That Led Me Here
Our team had zero E2E test coverage on a customer-facing dashboard. Every deploy was a mini heart attack. I'd used Cypress before, but writing tests from scratch felt like a huge time investment for a team already stretched thin. Playwright's codegen seemed like a way to bootstrap coverage fast—record the happy paths, check them into CI, and at least get a safety net.
The good news: it actually is that fast to start. The bad news: what you record is a starting point, not a finished product.
Step 1: Install and Sanity Check
First, make sure you have Playwright installed. If you're starting from scratch:
npm init playwright@latest
Walk through the prompts (I chose TypeScript, but codegen supports JavaScript, Python, Java, and C#). Once that's done, verify codegen works:
npx playwright codegen --help
You should see a list of options. If you do, you're good.
Step 2: Record Your First Test
Let's record a simple login flow. Run:
npx playwright codegen https://example.com/login
Two windows pop up: a real browser you can interact with, and the Playwright Inspector, which watches everything you do and writes test code in real time.
I clicked the email field, typed user@test.com, clicked the password field, typed mypassword, and clicked "Sign In." The Inspector immediately generated something like this:
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.goto('https://example.com/login');
await page.locator('[data-testid="email-input"]').click();
await page.locator('[data-testid="email-input"]').fill('user@test.com');
await page.locator('[data-testid="password-input"]').click();
await page.locator('[data-testid="password-input"]').fill('mypassword');
await page.locator('[data-testid="submit-btn"]').click();
});
That's it. You can copy this into a file, run npx playwright test, and it works. Thirty seconds to a passing test.
My first reaction was: why doesn't everyone do this?
Step 3: The Flags That Actually Matter
Before we talk about the problems, here are the codegen flags I use regularly that the basic tutorials skip:
Target language:
npx playwright codegen --target python https://example.com
Defaults to JavaScript. I work in TypeScript, so I almost always use --target playwright-test for properly typed test files.
Emulate a mobile device:
npx playwright codegen --device="iPhone 14" https://example.com
This sets the viewport, user agent, and touch events. Useful if your app has a separate mobile experience.
Record with authentication:
This one took me a while to figure out. If your app requires login, you don't want to record the login every time. Instead, save authentication state first:
npx playwright codegen --save-storage=auth.json https://example.com/login
Log in manually during the recording session. The session state (cookies, localStorage) gets saved to auth.json. Then for future recordings:
npx playwright codegen --load-storage=auth.json https://example.com/dashboard
Now you start already logged in. This is critical for recording flows behind auth walls without polluting your test with login boilerplate.
Color scheme and viewport:
npx playwright codegen --color-scheme=dark --viewport-size="1280,720" https://example.com
Useful when your app renders differently based on theme or screen size.
Step 4: The Moment of Truth—When Tests Rot
Here's the part every codegen tutorial skips.
I recorded five tests on a Monday. By Thursday, a designer had changed a button from "Submit" to "Save," and a developer had restructured a component, changing data-testid="submit-btn" to data-testid="save-btn". Two of my five tests went red.
I re-recorded them. Took five minutes. But the next week, another change broke two more. I was now spending more time re-recording tests than I would have spent writing stable ones from scratch.
This is what I mean by "recorded tests rot." Codegen captures a snapshot of your UI at a moment in time. It records what you clicked, not what you meant to click. There's no semantic understanding. When the UI changes, the test breaks, and codegen has no way to update it—you have to re-record or manually fix the locators.
Step 5: Cleaning Up Codegen Output
The fix is to treat codegen output as a first draft, not a finished test. Here's the pattern I've settled on.
Raw codegen output often looks like this:
await page.locator('internal:attr=[placeholder="Email address"]').click();
await page.locator('internal:attr=[placeholder="Email address"]').fill('user@test.com');
await page.locator('button:has-text("Sign In")').click();
These locators are brittle. Placeholder text changes. Button copy changes. Instead, I clean them up to use role-based or data-testid locators:
await page.getByRole('textbox', { name: /email/i }).fill('user@test.com');
await page.getByRole('button', { name: /sign in/i }).click();
Role-based locators are more resilient because they're tied to accessibility semantics, not visual details. A button's text might change from "Sign In" to "Log In," but if I use a regex like /sign in|log in/i, the test survives.
I also consolidate the separate .click() then .fill() that codegen emits into just .fill()—filling a field automatically focuses it, so the click is redundant.
The cleaned version of my login test:
import { test, expect } from '@playwright/test';
test('user can log in', async ({ page }) => {
await page.goto('/login');
await page.getByRole('textbox', { name: /email/i }).fill('user@test.com');
await page.getByRole('textbox', { name: /password/i }).fill('mypassword');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL('/dashboard');
});
Notice I also added an assertion at the end. Codegen records actions, but it doesn't know what success looks like. You have to add that yourself. A test without assertions is just a script that clicks things.
Step 6: When to Use Codegen (And When Not To)
After a few months of this workflow, here's my honest assessment:
Use codegen when:
- You're bootstrapping E2E coverage from zero and need something on the board fast
- You're exploring a new codebase and want to understand the page structure—recording a flow and reading the generated locators teaches you a lot
- You need to reproduce a bug report—record the exact steps, save the file, hand it to a developer
- You're writing a one-time smoke test for a stable page that rarely changes
Don't use codegen when:
- You need tests that will survive multiple sprints without constant maintenance
- Your app is in active development with frequent UI changes
- You're building a long-term test suite for CI/CD—write those by hand with Page Object Models
- You think recording replaces understanding—codegen is a speed boost, not a substitute for knowing how to write good selectors
Practical Tips I Learned the Hard Way
Always add assertions. Codegen won't do this for you. A test that clicks through a flow without checking the result is nearly worthless.
Clean locators immediately. Don't let raw codegen output into your repo. Take the five minutes to convert to role-based or data-testid selectors before committing.
Use
--load-storagefor authenticated flows. Recording login every time makes tests fragile and slow.Don't re-record; refactor. When a test breaks, resist the urge to re-record. Open the file, find the broken locator, and update it. Re-recording loses any manual improvements you've made.
Generate in your target language from the start. Converting a JavaScript codegen output to Python later is painful. Use
--targeton day one.
Honest Limitations
Codegen doesn't handle dynamic content well—if your page loads data asynchronously and you click something before it appears, the recording captures a flaky test. You'll need to add explicit waits or use Playwright's auto-waiting assertions.
It also can't record drag-and-drop reliably, and complex interactions like holding modifier keys while clicking often produce incorrect output. For these, you'll need to write the code manually.
Finally, codegen generates one long test function. No Page Objects, no modularity, no reuse. If you record the same login flow across ten tests, you'll have ten copies of the login code. Refactoring that into a shared fixture or Page Object is on you.
Where I Landed
I still use codegen regularly, but I've stopped thinking of it as "test creation." It's test prototyping. I record a flow in 30 seconds, clean up the locators, add assertions, extract common patterns into Page Objects, and commit the result. The recording saves me the tedious work of figuring out selectors and navigation paths. The manual cleanup ensures the test will still work next month.
That spreadsheet is gone. We have 40-something E2E tests now, and most of them started as codegen recordings. They just don't look like recordings anymore—and that's the point.