Playwright
Playwright Codegen: Record a Test, Then Make It Maintainable
How to get the most out of Playwright codegen, and the five refactors that turn a recorded script into a test you can keep.

Playwright codegen watches you use your app and writes the test code for each click and keystroke. It is the fastest way to get a first draft of an end-to-end test. It is also a draft: the file it writes holds your password in plain text, points at one hard-coded URL, and often checks a value that will be different on the next run.
This guide covers both halves. First, how to get the most out of npx playwright codegen: recording, assertions, emulation flags and saved login state. Then five refactors that turn the recorded script into a test you can merge. Every code block below was run against a small demo shop with Playwright 1.61, and the raw recording is real codegen output, not a cleaned-up version.
TL;DR
- Start it with
npx playwright codegen <url>, or with Record new in the VS Code extension. - Use the toolbar's assertion tools while you record. A test without assertions only proves that the buttons were clickable.
--save-storageand--load-storagelet you log in once and record the rest of your flows already signed in.- Before you merge a recording: move secrets to environment variables, extract page objects, assert outcomes instead of recorded values, pull test data out, and give the test a real name and tags.
How to start Playwright codegen
You need a project with @playwright/test installed (npm init playwright@latest sets one up). Then pass the page you want to start on:
npx playwright codegen http://localhost:5317/loginTwo windows open. One is a browser for your app. The other is the Playwright Inspector, which writes code as you click, type and select. The Inspector's toolbar lets you stop recording, pick a locator, copy the code, clear it and switch the target language. Add -o tests/recorded.spec.ts to have codegen write the file for you as you go.
If you work in VS Code, the Playwright extension gives you the same recorder in the editor:
- Record new creates a new spec file and opens a browser. Each action you take appears in the file.
- Record at cursor inserts the recorded steps where your cursor is, in a test you already have. It needs a browser that is already open, so run the test with Show browser ticked first.
- Pick locator shows the locator Playwright would use for any element you hover.
Recording from the middle of a flow with page.pause()
Sometimes the state you want to record from is hard to reach by hand: a seeded account, a mocked API, a feature flag. Put await page.pause() in a test at that point and run it headed (npx playwright test --headed). The run stops there and the Inspector opens, so you can record from the state your test set up. The official codegen page shows the same idea in a plain script, which is how you record with a context that uses context.route().
Record a flow and add codegen assertions
Codegen records actions on its own. It does not record checks unless you ask for them. The toolbar has a tool for each kind:
- Assert visibility: click an element and codegen writes
toBeVisible(). - Assert text: click an element and codegen writes
toContainText()with the text it sees. You can edit the text before you accept it. - Assert value: click a field and codegen writes
toHaveValue().
Recent versions also have a snapshot tool that writes toMatchAriaSnapshot() for the element you pick. The best time to add a check is right after the action that should cause it: after Sign in, check that the product list is showing; after Add to cart, check the cart count.
Codegen prefers role, text and test id locators, and it narrows a locator until it matches only one element. In the 1.61 source, a test id outranks everything else when the element has one; next comes a role with an accessible name, then placeholder, label, alt text, text and title. CSS ids rank below all of those, though still above a role with no name, which is why the recorded text check below used #result rather than getByRole('status'). If your app uses an attribute other than data-testid, pass it with --test-id-attribute=data-qa. For how to rank locators in your own code, see Playwright locators.
Codegen flags worth knowing
Run npx playwright codegen --help for the full list. These are the ones that change what you record:
| Flag | What it does |
|---|---|
--device="Pixel 7" |
Emulates a device: viewport, user agent and touch. |
--viewport-size="1280,720" |
A fixed viewport size. |
--color-scheme=dark |
Records with the dark colour scheme. |
--lang="de-DE", --timezone="Europe/Berlin", --geolocation="52.52,13.40" |
Locale, time zone and location. |
--target=python-pytest |
Writes Python for pytest instead of TypeScript. |
--save-storage=auth.json |
Saves cookies and localStorage when the session ends. |
--load-storage=auth.json |
Starts the browser with a saved state. |
The emulation flags are carried into the generated code, so the test runs under the same conditions you recorded in. This command:
npx playwright codegen --device="Pixel 7" --color-scheme=dark --lang="de-DE" --timezone="Europe/Berlin" http://localhost:5317/loginstarts the generated file with:
test.use({
...devices['Pixel 7'],
colorScheme: 'dark',
locale: 'de-DE',
timezoneId: 'Europe/Berlin'
});One pitfall: a device preset also picks its browser. --device="iPhone 13" launches WebKit, and if you only installed Chromium, codegen stops with "Executable doesn't exist" and a prompt to run npx playwright install. Install WebKit, or choose a Chromium device such as Pixel 7.
Log in once with --save-storage and --load-storage
Logging in at the start of every recording is slow and puts your password in every file. Record the login once and save the state:
npx playwright codegen --save-storage=auth.json http://localhost:5317/loginSign in, then close the browser window. Codegen writes auth.json when the session ends; pressing Ctrl+C in the terminal saves it too. Now record any other flow already signed in:
npx playwright codegen --load-storage=auth.json http://localhost:5317/shopThe generated test then starts with test.use({ storageState: 'auth.json' }), so it runs signed in as well. The official codegen page says IndexedDB is saved too, but as of Playwright 1.64 the CLI does not save it. If your app keeps its tokens in IndexedDB (Firebase Auth does), the recorded file will not sign you in; save the state in a setup project with context.storageState({ path, indexedDB: true }) instead. That file holds live session tokens. Add it to .gitignore, and for a real suite generate it in a setup project instead of keeping a recorded copy. The Playwright authentication guide covers that pattern.
What raw codegen output looks like, and what is wrong with it
Here is a real recording of the demo shop: sign in, add a mug to the cart, check out, then two checks made with Assert visibility and Assert text. This is exactly what codegen wrote (generated output, not a recommendation):
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.goto('http://localhost:5317/login');
await page.getByRole('textbox', { name: 'Email' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('qa@example.com');
await page.getByRole('textbox', { name: 'Password' }).click();
await page.getByRole('textbox', { name: 'Password' }).fill('S3cret-pass!');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.getByRole('listitem').filter({ hasText: 'Trail Mug $18.00 Add to cart' }).getByRole('button').click();
await page.getByRole('link', { name: 'Cart (1)' }).click();
await page.getByRole('textbox', { name: 'Full name' }).click();
await page.getByRole('textbox', { name: 'Full name' }).fill('Ada Lovelace');
await page.getByRole('textbox', { name: 'Card number' }).click();
await page.getByRole('textbox', { name: 'Card number' }).fill('4242424242424242');
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
await expect(page.locator('#result')).toContainText('Order number ORD-827288');
});The locators are good. Most of them are role-based, which is what you want. The problems are elsewhere:
- Secrets in the source. The password and the card number are string literals. Commit this and they are in your git history for good.
- A hard-coded URL.
page.goto('http://localhost:5317/login')ignoresbaseURL, so the test cannot run against staging without an edit. - Brittle targeting. The product card is found by its whole text, including the price. A price change breaks the test. The text check uses
#result, an id, because the element'sstatusrole has no accessible name and codegen ranks a nameless role below an id. - A recorded value as the expected value. The order number is new on every order. The check matched while it was being recorded, and the test failed the first time it ran:
Error: expect(locator).toContainText(expected) failed
Locator: locator('#result')
Expected substring: "Order number ORD-827288"
Received string: "Order confirmedOrder number ORD-716197"- No structure. One test named
test, a click before every fill (fill()focuses the field itself), and no check between sign-in and the last step. When it fails, the report tells you very little.
None of this is a codegen bug. Codegen records what you did, and it has no way to know which values are secret, which are stable, or what the test is for. That part is your job, and it takes five refactors.

Refactor 1: move secrets and URLs to environment variables
Set baseURL in the config and load a local .env file with dotenv (npm install -D dotenv). CI sets the same variables as secrets.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'node:path';
// Local runs read .env; CI sets the same variables as secrets.
dotenv.config({ path: path.resolve(__dirname, '.env') });
export default defineConfig({
testDir: './tests',
// The raw recording is kept for comparison; run it on its own with --config playwright.recorded.config.ts.
testIgnore: ['**/recorded.spec.ts'],
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:5317',
trace: 'on-first-retry',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: 'node server.mjs',
url: 'http://localhost:5317/login',
reuseExistingServer: !process.env.CI,
},
});Commit a .env.example that lists the names with no real values, and ignore .env:
# Copy to .env for local runs. Never commit .env.
BASE_URL=http://localhost:5317
TEST_EMAIL=qa@example.com
TEST_PASSWORD=
TEST_CARD_NUMBER=4242424242424242The card number here is a payment provider's public test number, which is why it can sit in an example file. A real card number never belongs in a repo.
If you swap 'S3cret-pass!' for process.env.TEST_PASSWORD directly, your editor and npx tsc --noEmit flag it (the test runner itself does not type-check):
error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.Do not silence it with !. An unset variable then fails at fill() with value: expected string, got undefined, which does not name the variable. Worse, a .env copied from .env.example unchanged sets TEST_PASSWORD to an empty string, so the test types an empty password and fails later, at sign-in. Read it through a helper that fails with a message that says what to do:
// utils/env.ts
// Read a required environment variable, or stop with a message that says what to set.
export function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing ${name}. Copy .env.example to .env locally, or set it as a CI secret.`);
}
return value;
}The !value check rejects an empty string as well as a missing one. With TEST_PASSWORD unset or empty, each test that needs it fails as soon as it asks for it, with Error: Missing TEST_PASSWORD. Copy .env.example to .env locally, or set it as a CI secret.
Refactor 2: extract a page object
Move the locators into one class per page. The test then says what the user does, and a changed label is fixed in one place. This is also where the brittle locators get fixed: #result becomes the status role, and the order number is found inside it.
// pages/checkout-page.ts
import type { Page, Locator } from '@playwright/test';
export type Customer = { fullName: string; cardNumber: string };
export class CheckoutPage {
readonly heading: Locator;
readonly summary: Locator;
readonly fullName: Locator;
readonly cardNumber: Locator;
readonly placeOrder: Locator;
readonly result: Locator;
readonly confirmation: Locator;
readonly orderNumber: Locator;
constructor(private readonly page: Page) {
this.heading = page.getByRole('heading', { name: 'Checkout', level: 1 });
this.summary = page.getByText(/item\(s\) in your cart$/);
this.fullName = page.getByRole('textbox', { name: 'Full name' });
this.cardNumber = page.getByRole('textbox', { name: 'Card number' });
this.placeOrder = page.getByRole('button', { name: 'Place order' });
this.result = page.getByRole('status');
this.confirmation = this.result.getByRole('heading', { name: 'Order confirmed' });
this.orderNumber = this.result.getByText(/^Order number /);
}
async goto() {
await this.page.goto('/checkout');
}
async pay(customer: Customer) {
await this.fullName.fill(customer.fullName);
await this.cardNumber.fill(customer.cardNumber);
await this.placeOrder.click();
}
}The login page follows the same pattern:
// pages/login-page.ts
import type { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly email: Locator;
readonly password: Locator;
readonly submit: Locator;
readonly error: Locator;
constructor(private readonly page: Page) {
this.email = page.getByRole('textbox', { name: 'Email' });
this.password = page.getByRole('textbox', { name: 'Password' });
this.submit = page.getByRole('button', { name: 'Sign in' });
this.error = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async signIn(email: string, password: string) {
await this.email.fill(email);
await this.password.fill(password);
await this.submit.click();
}
}The shop page is where the product card gets fixed. Find it by its heading, not its full text, so the price can change:
// pages/shop-page.ts
import type { Page, Locator } from '@playwright/test';
export class ShopPage {
readonly heading: Locator;
readonly cartLink: Locator;
constructor(private readonly page: Page) {
this.heading = page.getByRole('heading', { name: 'Products', level: 1 });
this.cartLink = page.getByRole('link', { name: /^Cart \(\d+\)$/ });
}
// Find the product card by its name, not by its full text (which includes the price).
product(name: string): Locator {
return this.page
.getByRole('listitem')
.filter({ has: this.page.getByRole('heading', { name, exact: true }) });
}
async addToCart(name: string) {
await this.product(name).getByRole('button', { name: 'Add to cart' }).click();
}
async openCart() {
await this.cartLink.click();
}
}page.goto('/checkout') is relative now, so it follows baseURL. The redundant click() calls are gone. Keep page objects thin: locators and the actions a user takes, no assertions about business rules. When page objects help and when they get in the way is covered in Playwright page object model.
Refactor 3: assert outcomes, not clicks
A recorded test proves that each element was clickable. A useful test proves that each step did what it should. Add a check after every step that changes something, and check the shape of values that change between runs, not the value you happened to see:
// The number changes on every order: assert its shape, not the value you saw while recording.
await expect(checkoutPage.orderNumber).toHaveText(/^Order number ORD-\d{6}$/);For the demo flow, that means one outcome per step:
| Step | Outcome to check |
|---|---|
| Sign in | The URL ends in /shop, the Products heading is visible, the cart says Cart (0). |
| Add to cart | The cart link says Cart (1). |
| Open the cart | The URL ends in /checkout and the summary says 1 item(s) in your cart. |
| Place order | The confirmation heading is visible, the order number matches ORD- plus six digits, and the Place order button is gone. |
You will see them in the final test in Refactor 5. When a step breaks, the first failing check names the step, not some later click that could not find its button. Playwright assertions goes deeper into which matcher proves what.
Refactor 4: pull data and setup into fixtures
Test data lives in one file. Values a reviewer may read stay as literals; secrets come from the environment:
// fixtures/test-data.ts
import { requireEnv } from '../utils/env';
import type { Customer } from '../pages/checkout-page';
// Values a reviewer may read live here; secrets come from the environment.
export const products = {
mug: 'Trail Mug',
tote: 'Canvas Tote',
} as const;
// Two getters, so a test that only needs the email does not require the password.
export function shopperEmail(): string {
return process.env.TEST_EMAIL ?? 'qa@example.com';
}
export function shopperPassword(): string {
return requireEnv('TEST_PASSWORD');
}
export function customer(): Customer {
return {
fullName: 'Ada Lovelace',
cardNumber: requireEnv('TEST_CARD_NUMBER'),
};
}Then hand the page objects to tests as fixtures. A signedIn fixture does the sign-in once per test and checks that it worked, so a broken login fails in the fixture with a clear step, not three steps later:
// fixtures/base.ts
import { test as base, expect } from '@playwright/test';
import { LoginPage } from '../pages/login-page';
import { ShopPage } from '../pages/shop-page';
import { CheckoutPage } from '../pages/checkout-page';
import { shopperEmail, shopperPassword } from './test-data';
type Pages = {
loginPage: LoginPage;
shopPage: ShopPage;
checkoutPage: CheckoutPage;
/** A page that is already signed in and showing the product list. */
signedIn: ShopPage;
};
export const test = base.extend<Pages>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
shopPage: async ({ page }, use) => {
await use(new ShopPage(page));
},
checkoutPage: async ({ page }, use) => {
await use(new CheckoutPage(page));
},
signedIn: async ({ loginPage, shopPage }, use) => {
await loginPage.goto();
await loginPage.signIn(shopperEmail(), shopperPassword());
await expect(shopPage.heading).toBeVisible();
await use(shopPage);
},
});
export { expect };For a large suite, replace the signedIn login with a saved storage state from a setup project, as in the authentication guide.
Refactor 5: name, tag and split the test
One recording often covers several behaviours. Split it so each test proves one thing and its name says what. Add tags so CI can run a fast subset:
// tests/checkout.spec.ts
import { test, expect } from '../fixtures/base';
import { customer, products, shopperEmail, shopperPassword } from '../fixtures/test-data';
test.describe('Checkout', () => {
test('signing in shows the product list', { tag: '@smoke' }, async ({ page, loginPage, shopPage }) => {
await loginPage.goto();
await loginPage.signIn(shopperEmail(), shopperPassword());
await expect(page).toHaveURL(/\/shop$/);
await expect(shopPage.heading).toBeVisible();
await expect(shopPage.cartLink).toHaveText('Cart (0)');
});
test('signing in without a password shows an error', async ({ page, loginPage }) => {
await loginPage.goto();
await loginPage.signIn(shopperEmail(), '');
await expect(loginPage.error).toHaveText('Enter your email and password');
await expect(page).toHaveURL(/\/login$/);
});
test('a signed-in shopper can buy one item', { tag: ['@smoke', '@checkout'] }, async ({ page, signedIn, checkoutPage }) => {
await signedIn.addToCart(products.mug);
await expect(signedIn.cartLink).toHaveText('Cart (1)');
await signedIn.openCart();
await expect(page).toHaveURL(/\/checkout$/);
await expect(checkoutPage.summary).toHaveText('1 item(s) in your cart');
await checkoutPage.pay(customer());
await expect(checkoutPage.confirmation).toBeVisible();
// The number changes on every order: assert its shape, not the value you saw while recording.
await expect(checkoutPage.orderNumber).toHaveText(/^Order number ORD-\d{6}$/);
await expect(checkoutPage.placeOrder).toBeHidden();
});
});The second test was never recorded. Once the page objects exist, a negative case is four lines, and that is where the refactor pays off. Run the smoke subset with npx playwright test --grep @smoke. With the variables set, npx playwright test --repeat-each=3 passes all nine runs. The raw recording fails on every run, because the order number never repeats.
When codegen is exactly the right tool
Refactoring is not always worth it. Codegen's raw output is the right result when you:
- Need a locator. Pick locator, or a quick recording, tells you what Playwright would use for a hard element. Copy the line and close the window.
- Write a one-off script. Reproducing a bug report or filling a form 50 times for a demo does not need page objects.
- Learn the API. Recording an interaction you have not automated before shows you the calls:
setInputFilesfor an upload,waitForEvent('popup')for a new tab,pressfor a key. - Add steps to an existing test. Record at cursor into a test that already uses your page objects, then swap the raw locators for the page object's ones.
Treat it as a code generator for drafts, not a test framework.
Recording a suite instead of a script
Blendra takes the recording idea one step further. Its recorder is a Chrome extension of its own, not built on codegen. Values typed into fields it classifies as sensitive, such as passwords and card numbers, are not stored, and the exported test reads them from environment variables like TEST_PASSWORD (how sensitive values work). It lists what the app showed during the recording (URL changes, alerts, status messages, toasts) so you choose which ones become checks. The export is a repo with page objects, fixtures, test data and a GitHub Actions workflow (generated project structure), in Playwright TypeScript on every plan, with Playwright Python and Cypress in beta on the Pro+ and Team plans.
Codegen still wins on several counts. It is free and needs no account. It records key presses, new tabs and file uploads, which Blendra's recorder does not. It targets more languages. You can use both: codegen for a tricky interaction, a recorder that writes the suite for the rest.
FAQ
Can Playwright codegen record assertions?
Yes, but only when you ask. Use the toolbar's Assert visibility, Assert text and Assert value tools while recording, plus the snapshot tool in recent versions. Review every text check: codegen copies the text it saw, including values that change on each run, such as order numbers, dates and counts.
Can codegen log in once and reuse the session?
Yes. Record the login with --save-storage=auth.json, then start later recordings with --load-storage=auth.json. The generated test includes test.use({ storageState: 'auth.json' }). Keep the file out of git, because it holds live session data.
Which languages can codegen generate?
As of October 2026 (checked in Playwright 1.61 and 1.64), --target accepts playwright-test (the default), javascript, python, python-async, python-pytest, java, java-junit, csharp, csharp-mstest, csharp-nunit and csharp-xunit. Python users run it as playwright codegen --target python-pytest <url>. For the pytest setup around it, see pytest-playwright.
Does codegen record file uploads?
Yes, as setInputFiles(). The browser only tells codegen the file name, not where the file was on your disk, so the recorded line has a bare name like 'invoice.pdf'. Put the file in your repo, for example in fixtures/files/, and change the path to point there, or the test fails on any other machine.