Evaluating CodeRabbit? Same review, plus real test runs. See why

Automation Testing12 min read

Playwright Codegen: Record and Clean Up a Test

S
Technical Writer, Qodex
A recorded Playwright test that passes with no assertions next to the cleaned-up test that fails on the same broken app, with the run results

Playwright codegen is the test generator built into Playwright: you run npx playwright codegen, click through your app in the browser it opens, and it writes the matching test code as you go. It prefers role, text and test ID locators. The recording replays your clicks, but it checks nothing until you add assertions, so treat it as a first draft of a test.

Part of our UI Testing guide. Read the guide

Checked with Playwright 1.63.0, read 4 October 2026. Every TypeScript and JavaScript file on this page ran as printed in an empty folder, and every output block is copied from that run's log. The Python file is the recorder's output, shown for comparison and not run.

If you would rather describe the flow than record it, Qodex drives the real app, brings back a screenshot of what broke, and saves the run as Playwright you own. See Qodex UI testing or try it on your app.

What Playwright codegen generates

Codegen opens two windows: a browser where you use the site, and the Playwright Inspector, which shows the code as it is written. In our recording, each click and each fill became one line. For each element, Playwright "will look at your page and figure out the best locator, prioritizing role, text and test id locators". If that locator matches more than one element, the generator refines it until it is unique. Source: the test generator guide, read 4 October 2026.

The default output is a Playwright Test file. The --target option switches it to plain JavaScript, Python, pytest, Java, JUnit or one of four C# flavours. What you get is code, not a saved macro, so you can edit it like any other test.

Two things a plain recording does not give you: checks on the result, unless you add them with the toolbar, and any structure beyond one long test. The rest of this page is about closing those two gaps.

Record a flow against a local demo app

Start in an empty folder. The demo needs Node and Playwright, nothing else:

npm init -y
npm install -D @playwright/test@1.63.0
npx playwright install chromium

Save this as demo-app.mjs. It is a sign-in page and a projects page that adds a project to a list. Any email and password sign you in, and the session lives in a cookie. Setting BROKEN=1 breaks the Create project button on purpose, so we can test the tests later.

// demo-app.mjs: a two-page app to record with Playwright codegen. Node built-ins only.
import { createServer } from 'node:http';
import { randomUUID } from 'node:crypto';

const sessions = new Set();

const login = `<!doctype html>
<title>Sign in</title>
<h1>Sign in</h1>
<form method="post" action="/login">
  <label>Email <input name="email" type="email" required></label>
  <label>Password <input name="password" type="password" required></label>
  <button type="submit">Sign in</button>
</form>`;

const projects = `<!doctype html>
<title>Projects</title>
<h1>Projects</h1>
<form id="new">
  <label>Project name <input name="name" required></label>
  <button type="submit">Create project</button>
</form>
<ul id="list"></ul>
<p role="status" id="status"></p>
<script>
  const broken = ${process.env.BROKEN === '1'};
  document.getElementById('new').addEventListener('submit', (e) => {
    e.preventDefault();
    if (broken) return; // BROKEN=1 simulates a regression: the button does nothing
    const name = e.target.name.value.trim();
    const li = document.createElement('li');
    li.textContent = name;
    document.getElementById('list').append(li);
    document.getElementById('status').textContent = 'Created ' + name;
    e.target.reset();
  });
</script>`;

function signedIn(req) {
  const m = /(?:^|; )sid=([\w-]+)/.exec(req.headers.cookie ?? '');
  return m !== null && sessions.has(m[1]);
}

const server = createServer((req, res) => {
  if (req.method === 'POST' && req.url === '/login') {
    // Demo only: any email and password signs in. A real app checks them.
    const sid = randomUUID();
    sessions.add(sid);
    res.writeHead(303, { location: '/projects', 'set-cookie': `sid=${sid}; HttpOnly; SameSite=Lax; Path=/` });
    return res.end();
  }
  if (req.url === '/projects' && !signedIn(req)) {
    res.writeHead(303, { location: '/' });
    return res.end();
  }
  const body = req.url === '/projects' ? projects : login;
  res.writeHead(200, { 'content-type': 'text/html' });
  res.end(body);
});

server.listen(4173, '127.0.0.1', () => console.log('demo app on http://127.0.0.1:4173'));

Start it in one terminal and leave it running:

node demo-app.mjs

When it prints its address, open a second terminal in the same folder, point codegen at the app and save the output to a file:

npx playwright codegen -o tests/recorded.spec.ts http://127.0.0.1:4173

In the browser window, sign in as ana@example.com with any password, type a project name, and click Create project. Then close the browser. This is the file codegen saved for that flow. To make the run repeat exactly, we drove the same recorder from a script instead of by hand:

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

test('test', async ({ page }) => {
  await page.goto('http://127.0.0.1:4173/');
  await page.getByRole('textbox', { name: 'Email' }).click();
  await page.getByRole('textbox', { name: 'Email' }).fill('ana@example.com');
  await page.getByRole('textbox', { name: 'Password' }).click();
  await page.getByRole('textbox', { name: 'Password' }).fill('example-password');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.getByRole('textbox', { name: 'Project name' }).click();
  await page.getByRole('textbox', { name: 'Project name' }).fill('Checkout redesign');
  await page.getByRole('button', { name: 'Create project' }).click();
});

Every locator is a role locator with the accessible name taken from the label or the button text. The click before each fill is there because a person clicked into the field first. For what those role locators match and how the name option behaves, see our Playwright getByRole reference.

The same recording with --target python-pytest looks like this. We did not run it; it needs the Python package of Playwright and pytest:

import re
from playwright.sync_api import Page, expect

def test_example(page: Page) -> None:
    page.goto("http://127.0.0.1:4173/")
    page.get_by_role("textbox", name="Email").click()
    page.get_by_role("textbox", name="Email").fill("ana@example.com")
    page.get_by_role("textbox", name="Password").click()
    page.get_by_role("textbox", name="Password").fill("example-password")
    page.get_by_role("button", name="Sign in").click()
    page.get_by_role("textbox", name="Project name").click()
    page.get_by_role("textbox", name="Project name").fill("Checkout redesign")
    page.get_by_role("button", name="Create project").click()

The recorder toolbar: record, pick locator, assertions

By default, codegen starts a new recording as soon as it opens. The controls sit in the toolbar over the page and in the Inspector. Sources: the test generator guide and best practices, read 4 October 2026.

  • Record. Press it to stop recording. Press it again to resume.

  • Pick locator. Appears once recording is stopped. Hover over the page to see the locator under each element, click one, and its code lands in the field beside the button. You can edit it there before you copy it.

  • Assert visibility, assert text, assert value. Click one, then click an element. The guide describes them as asserting that an element "is visible", "contains specific text" and "has a specific value".

  • Copy and clear. Copy puts the generated code on your clipboard. Clear empties it so you can record again.

The assertion buttons close part of the gap described below. Recording "assert text" on the status line after Create project gives you the one check that matters in this flow. Version 1.55 also added automatic toBeVisible() assertions for common interactions, switched on in the Codegen settings UI. Source: the release notes, read 4 October 2026.

For locators you build by hand rather than record, our Playwright locator builder runs in the browser.

Playwright codegen options

This is what npx playwright codegen --help printed on 1.63.0 in our run, trimmed to the options you are likely to reach for. The usage line is npx playwright codegen [options] [url], and the URL is optional.

OptionWhat it doesExample valueHelp defaultUse it when
-o, --outputSaves the generated script to a filetests/flow.spec.tsnot statedYou want the file, not the clipboard
--targetLanguage to generatepython-pytestplaywright-testYour suite is not Playwright Test
-b, --browserBrowser to record inwebkitchromiumA bug shows in one engine only
--deviceEmulates a device"iPhone 11"not statedThe flow differs on a phone layout
--viewport-sizeSets the viewport in pixels"1280, 720"not statedYou need one exact window size
--color-schemeEmulates light or darkdarknot statedThe flow differs in dark mode
--lang, --timezone, --geolocationEmulates locale, time zone and location"Europe/Rome"not statedText, dates or maps depend on locale
--test-id-attributeAttribute used for test ID selectorsdata-qanot statedYour app uses its own test attribute
--save-storage, --load-storageSaves or restores cookies and storageauth.jsonnot statedYou record many flows behind sign-in
--http-credentialsHTTP Basic Authentication"user:pass"not statedThe site sits behind Basic Auth
--save-harSaves network activity as a HAR fileflow.harnot statedYou want the network traffic too

Two notes on that list. If your suite sets testIdAttribute in its config, pass the same name to --test-id-attribute, or the recorder will write test ID locators your tests do not use. And --http-credentials credentials "are included in the generated code", so they end up in the file you commit. The flag is new in 1.63. Sources: the test generator guide and the release notes, read 4 October 2026.

Record from VS Code

The Playwright extension for VS Code wraps the same generator. Click Record new in the Testing sidebar: it creates test-1.spec.ts and opens a browser, and your actions are written straight into that file. The same three assertion buttons sit in the browser toolbar.

Record at cursor adds steps to a test you already have. Put the cursor where the new steps belong and click it. If no browser window is open, the guide says to run the test once with "Show browser" checked first. Pick locator in the sidebar works like the Inspector version, and Enter copies the picked locator to your clipboard. Source: the test generator guide, read 4 October 2026.

Record at cursor is the better habit for an existing suite. You record only the new steps, inside a test that already has its setup and its checks.

Why a raw recording is not a test yet

Run the recording as it is. With no config file, Playwright picks up the test from the tests folder:

$ npx playwright test

Running 1 test using 1 worker

  ✓  1 tests/recorded.spec.ts:3:5 › test (235ms)

  1 passed (562ms)

Now stop the demo app with Ctrl+C and start it again with the regression switched on, so Create project does nothing:

BROKEN=1 node demo-app.mjs

Run the same recording again, and it still passes:

$ npx playwright test

Running 1 test using 1 worker

  ✓  1 tests/recorded.spec.ts:3:5 › test (258ms)

  1 passed (558ms)

Nothing in the file asks whether a project was created. Clicking a button that does nothing is still a successful click. That is the main risk with recorded tests: a suite of them goes green while a feature is broken. Our post on why record and playback falls short covers the wider maintenance cost.

Turn the recording into a suite test

Four edits turn the recording into something worth keeping. Stop the demo app first, because the config below starts it for each run.

  • Move the address into config. Set baseURL once and call page.goto('/'), so the same test runs against local, staging or a preview.

  • Let Playwright start the app. webServer starts the demo and waits for the URL before the first test.

  • Delete the recorded clicks before fills. The cleaned test below has none, and it still passes.

  • Assert the outcome. Check the page you land on, the status message and the list. Playwright's best practices page recommends web-first assertions such as await expect(locator).toBeVisible(), which wait and retry. Source: best practices, read 4 October 2026.

Save this as playwright.config.ts:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  use: { baseURL: 'http://127.0.0.1:4173' },
  webServer: {
    command: 'node demo-app.mjs',
    url: 'http://127.0.0.1:4173',
  },
});

And this as tests/projects.spec.ts, next to the recording:

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

test('a signed-in user can create a project', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('textbox', { name: 'Email' }).fill('ana@example.com');
  await page.getByRole('textbox', { name: 'Password' }).fill('example-password');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible();

  await page.getByRole('textbox', { name: 'Project name' }).fill('Checkout redesign');
  await page.getByRole('button', { name: 'Create project' }).click();

  await expect(page.getByRole('status')).toHaveText('Created Checkout redesign');
  await expect(page.getByRole('listitem')).toHaveText(['Checkout redesign']);
});

Run the suite with the switch set. In our run, BROKEN=1 reached the app that the config started:

$ BROKEN=1 npx playwright test

Running 2 tests using 2 workers

  ✓  2 tests/recorded.spec.ts:3:5 › test (258ms)
  ✘  1 tests/projects.spec.ts:3:5 › a signed-in user can create a project (5.2s)

    Error: expect(locator).toHaveText(expected) failed

    Locator:  getByRole('status')
    Expected: "Created Checkout redesign"
    Received: ""
    Timeout:  5000ms
    ...

  1 failed
  1 passed (5.6s)

The recording still passes. The cleaned test fails on the status line and names the empty value it found. We trimmed the call log and code frame from that output. Run it again without the switch and both pass:

$ npx playwright test

Running 2 tests using 2 workers

  ✓  2 tests/projects.spec.ts:3:5 › a signed-in user can create a project (161ms)
  ✓  1 tests/recorded.spec.ts:3:5 › test (259ms)

  2 passed (670ms)

Once a test like this exists, delete the raw recording. It adds run time and no protection. To pick the right matcher for each check, see the Playwright assertions reference.

Reuse sign-in state while recording

Signing in at the start of each recording is slow. Codegen can save the browser's state when the session ends and load it next time. Source: the test generator guide, read 4 October 2026.

node demo-app.mjs
npx playwright codegen --save-storage=auth.json http://127.0.0.1:4173
npx playwright codegen --load-storage=auth.json http://127.0.0.1:4173/projects

Start the demo app again first, in its own terminal, because the test run above stopped it. The first codegen command records your sign-in and writes cookies, localStorage and IndexedDB data to auth.json when you close the browser. The second starts the next recording already signed in. With this demo app, sessions live in memory, so a restart of the app logs that file out.

Treat auth.json as a secret. The guide says to use it locally only and add it to .gitignore, because it holds session data. Playwright's authentication guide adds that stored state "may contain sensitive cookies and headers that could be used to impersonate you or your test account". Source: authentication, read 4 October 2026.

Recording state and test state are separate jobs. In the suite, sign in once in a setup project or a fixture and share the saved state with the tests that need it. The authentication guide says a shared account suits tests that do not change server-side state; tests that do change it need one account each. Our Playwright fixtures guide shows the fixture side of that.

Codegen and Playwright Test Agents

Codegen is not Playwright's only way to produce tests. Version 1.56 introduced Playwright Test Agents, three agent definitions for an LLM. A planner "explores the app and produces a Markdown test plan". A generator "transforms the Markdown plan into the Playwright Test files". A healer "executes the test suite and automatically repairs failing tests". npx playwright init-agents writes the definitions for VS Code, Claude Code or opencode. Source: the release notes, read 4 October 2026.

The two answer different questions. Codegen records what you did, step for step, with no model in the loop. The agents decide what to test and write it, and their output needs the same review for missing checks. Either way, a generated test is a draft until it asserts the result a user would notice.

Conclusion

Use codegen to get the locators and the steps for free, then do the part it skips. Move the address into config, delete the noise, and add assertions that fail when the feature fails. Check that they do by breaking the feature once on purpose, as the demo above does.

Frequently Asked Questions

What does npx playwright codegen do?

It opens a browser and the Playwright Inspector. As you click and type in the browser, it writes the matching Playwright code, using role, text and test ID locators where it can. You can copy the code from the Inspector or save it with -o.

How do I save the generated test to a file?

Pass -o or --output with a file name, for example npx playwright codegen -o tests/flow.spec.ts http://127.0.0.1:4173. Without it, copy the code from the Inspector.

Which languages can Playwright codegen generate?

In 1.63.0 the help lists javascript, playwright-test, python, python-async, python-pytest, csharp, csharp-mstest, csharp-nunit, csharp-xunit, java and java-junit. The default is playwright-test.

Does codegen add assertions automatically?

Not from the clicks in a plain recording. You add them with the assert visibility, assert text and assert value buttons. Since 1.55, a Codegen setting can also add automatic toBeVisible() assertions for common interactions. Neither one knows which outcome matters, so review what you got.

Why does my recorded test pass when the feature is broken?

Because it only replays actions. A click on a button that does nothing still succeeds. Add an assertion on the result, such as a status message or a new list row, and break the feature once to see the test fail.

How do I record a mobile layout with codegen?

Pass --device with a device name, for example --device="iPhone 11", or set --viewport-size. The device option sets the viewport size and user agent among other settings. Record against the layout your tests will run in.

How do I record without signing in again?

Record the sign-in once with --save-storage=auth.json, then start later sessions with --load-storage=auth.json. Keep that file out of git, because it holds session cookies.

Is Playwright codegen the same as Playwright Test Agents?

No. Codegen turns your own clicks into code with no model involved. Test Agents, added in 1.56, are agent definitions that guide an LLM to plan tests, write them and repair failing ones.

Ship continuously. Test continuously.

Qodex explores your app, writes runnable tests, and replays them on every change at zero LLM cost.