Skip to main content
Back to Blog
Reference
2026-06-04

Playwright Python Codegen: Record Tests Guide 2026

Record Playwright tests in Python with codegen. Use --target python, save authentication, pick stable locators, and refactor generated code into real tests.

Playwright Python Codegen: Recording Tests in 2026

Writing a Playwright test from scratch means inspecting the DOM, guessing at selectors, and iterating until the locators are stable. Playwright's code generator -- codegen -- short-circuits all of that. You launch a real browser, click through the flow you want to test exactly as a user would, and Playwright watches every interaction and writes the corresponding Python code in real time. What would take twenty minutes of manual locator hunting becomes a two-minute recording session, and the generated locators favor accessible, resilient selectors over brittle CSS.

For Python teams especially, codegen is the fastest on-ramp to Playwright. It outputs idiomatic Python using the sync API by default, picks get_by_role, get_by_label, and get_by_text locators that survive UI refactors, and can even capture and reuse authentication so you record flows behind a login wall without re-typing credentials every time. The catch is that generated code is a starting point, not a finished test. Knowing how to drive the recorder, choose stable locators while recording, and then refactor the raw output into a maintainable test is what separates a throwaway script from a real test suite.

This guide is a complete, runnable reference to Playwright Python codegen for 2026. You will learn how to launch the recorder, target Python output with --target python, record on a specific URL and viewport, save and reuse authentication state, use the pick-locator and assertion tools in the recorder UI, and transform the generated script into a proper pytest test with the pytest-playwright plugin. Every example is real Python or shell you can run today.


Key Takeaways

  • playwright codegen launches a browser and writes Python as you interact, choosing accessible, resilient locators automatically.
  • --target python (sync) or --target python-async selects the output flavor; python-pytest emits a pytest-ready test.
  • --save-storage and --load-storage capture and replay authentication so you can record flows behind a login.
  • The recorder UI has tools to pick a locator, assert visibility, assert text, and assert values without writing code.
  • Generated code is a draft -- refactor it into Page Objects, add assertions, and remove redundant waits before committing.

Launching the Recorder

The entry point is the codegen subcommand. Run it with a starting URL and Playwright opens two windows: a real browser, and the Playwright Inspector showing the code being generated. Every click, fill, and navigation you perform in the browser appears as Python in the Inspector instantly.

# Launch the recorder against a URL (defaults to Chromium)
playwright codegen https://demo.playwright.dev/todomvc

# Or via the Python module if the script isn't on your PATH
python -m playwright codegen https://demo.playwright.dev/todomvc

As you interact, the Inspector accumulates code. When you are done, copy it out of the Inspector or use the record-to-file flag described below. The recorder is the single most efficient way to discover the right locators for a page, because Playwright applies its locator-priority rules (role and label first, CSS last) for you.


Targeting Python Output

By default codegen emits Python using the synchronous API. You control the exact flavor with --target. For most test suites you want either plain sync Python or, even better, the pytest target that emits a ready-to-run test function. The async target is for applications already built on asyncio.

# Synchronous Python (default)
playwright codegen --target python https://example.com

# Asynchronous Python (asyncio)
playwright codegen --target python-async https://example.com

# A pytest test function, ready for the pytest-playwright plugin
playwright codegen --target python-pytest https://example.com

The python-pytest target is the one to reach for when building a real suite. Instead of a bare script with sync_playwright() boilerplate, it produces a test_* function that takes the page fixture, which is exactly what the pytest-playwright plugin provides. Here is the kind of output you get:

# Generated by: playwright codegen --target python-pytest
from playwright.sync_api import Page, expect


def test_example(page: Page) -> None:
    page.goto("https://demo.playwright.dev/todomvc")
    page.get_by_placeholder("What needs to be done?").click()
    page.get_by_placeholder("What needs to be done?").fill("Buy milk")
    page.get_by_placeholder("What needs to be done?").press("Enter")
    expect(page.get_by_test_id("todo-title")).to_have_text("Buy milk")
TargetOutputBest for
pythonSync script with sync_playwright()Quick one-off scripts
python-asyncAsync script with async_playwright()asyncio applications
python-pytestA test_* function using the page fixtureReal test suites

Recording to a File

Rather than copy-pasting from the Inspector, write the generated code straight to a file with -o (output). Combined with --target python-pytest, this drops a runnable test onto disk the moment you close the browser.

# Record straight into a pytest test file
playwright codegen \
  --target python-pytest \
  -o tests/test_todo_flow.py \
  https://demo.playwright.dev/todomvc

When you finish recording and close the browser window, tests/test_todo_flow.py contains the complete test. You can immediately run it with the pytest-playwright plugin installed:

# Install the plugin that provides the 'page' fixture
pip install pytest-playwright

# Run the freshly recorded test
pytest tests/test_todo_flow.py

Recording on a Specific Browser and Viewport

Codegen accepts the same browser and device options as the test runner, so you can record exactly the conditions you intend to test. Use --browser to pick the engine and --device to emulate a mobile device, including its viewport, user agent, and touch settings. This matters because locators and layout can differ between desktop and mobile, and you want the generated code to match your target.

# Record on Firefox at a fixed desktop viewport
playwright codegen --browser firefox --viewport-size 1280,720 https://example.com

# Record an emulated iPhone, capturing mobile-specific layout and locators
playwright codegen --device "iPhone 14" https://example.com

# Record in WebKit to verify Safari-specific behavior
playwright codegen --browser webkit https://example.com

You can also set color scheme, locale, timezone, and geolocation while recording, which is invaluable for capturing flows that depend on those settings:

# Record in dark mode, German locale, Berlin timezone
playwright codegen \
  --color-scheme dark \
  --lang de-DE \
  --timezone "Europe/Berlin" \
  https://example.com

Saving and Reusing Authentication

Recording a flow that lives behind a login is tedious if you must log in by hand every session. Codegen solves this with --save-storage, which writes the authenticated session (cookies and origin storage) to a file when you close the browser, and --load-storage, which restores it on a later run so you start already logged in.

First, record a session and save the auth state. You log in once, manually, then close the browser:

# Log in by hand during this session; auth is saved on close.
playwright codegen --save-storage=auth.json https://app.example.com/login

On every subsequent recording, load that saved state so the browser opens already authenticated, and you can record the protected flow directly:

# Start logged in, then record the part of the app behind auth.
playwright codegen \
  --load-storage=auth.json \
  --target python-pytest \
  -o tests/test_dashboard.py \
  https://app.example.com/dashboard

In your actual test, you reuse the same auth.json so the test runs authenticated without any login code. With the pytest-playwright plugin you point the browser_context_args fixture at the storage state:

# conftest.py -- every test reuses the saved authentication
import pytest


@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
    return {
        **browser_context_args,
        "storage_state": "auth.json",
    }
FlagPurposeWhen you use it
--save-storage=auth.jsonSave cookies + storage on closeFirst, after logging in manually
--load-storage=auth.jsonRestore the saved sessionEvery later recording of protected pages

Using the Recorder's Built-In Tools

The Inspector window is more than a code viewer -- it has interactive tools that generate locators and assertions for you. The Pick Locator tool lets you hover any element and copies a resilient locator for it, perfect for grabbing a selector without recording a full interaction. The assertion tools let you add expect statements by pointing at elements rather than typing them.

To grab a locator without performing an action, click "Pick Locator" in the Inspector, then hover the element in the browser. Playwright shows the locator it would use and copies it. This is the fastest way to find the correct get_by_role or get_by_label for an element you want to assert on later.

The recorder offers three assertion tools that generate expect calls:

  • Assert visibility generates expect(locator).to_be_visible() for the element you click.
  • Assert text generates expect(locator).to_contain_text("...") capturing the element's current text.
  • Assert value generates expect(locator).to_have_value("...") for inputs.
# Examples of assertions the recorder tools produce
expect(page.get_by_role("heading", name="Dashboard")).to_be_visible()
expect(page.get_by_test_id("cart-count")).to_contain_text("3")
expect(page.get_by_label("Email")).to_have_value("qa@example.com")

Using these tools during recording means your generated draft already contains meaningful assertions, not just actions -- which dramatically reduces the editing you do afterward.


Refactoring Generated Code Into a Real Test

Generated code works, but it is verbose and literal. It repeats locators, may include redundant clicks, and rarely reflects the structure you want long-term. Before committing, refactor it. The three highest-value edits are: extract repeated locators into variables or a Page Object, replace fragile generated values with meaningful test data, and remove any redundant waits (Playwright auto-waits, so explicit sleeps are almost never needed).

Take this raw generated draft:

# Raw codegen output -- repetitive and literal
from playwright.sync_api import Page, expect


def test_login(page: Page) -> None:
    page.goto("https://app.example.com/login")
    page.get_by_label("Email").click()
    page.get_by_label("Email").fill("qa@example.com")
    page.get_by_label("Password").click()
    page.get_by_label("Password").fill("s3cr3t")
    page.get_by_role("button", name="Sign in").click()
    expect(page.get_by_role("heading", name="Dashboard")).to_be_visible()

Refactor it into something maintainable. Drop the redundant click() before each fill() (filling focuses the field anyway), pull credentials from fixtures or environment, and consider a small page-object helper:

# Refactored: a focused Page Object plus a clean test
from playwright.sync_api import Page, expect


class LoginPage:
    def __init__(self, page: Page) -> None:
        self.page = page
        self.email = page.get_by_label("Email")
        self.password = page.get_by_label("Password")
        self.submit = page.get_by_role("button", name="Sign in")

    def login(self, email: str, password: str) -> None:
        self.page.goto("https://app.example.com/login")
        self.email.fill(email)
        self.password.fill(password)
        self.submit.click()


def test_login_succeeds(page: Page) -> None:
    LoginPage(page).login("qa@example.com", "s3cr3t")
    expect(page.get_by_role("heading", name="Dashboard")).to_be_visible()

The refactored version is shorter, names its intent, and centralizes the locators so a UI change touches one place. This pattern -- record, then refactor toward Page Objects -- is the productive way to use codegen. Treat the generator as a locator-discovery engine, not a test author.


Recording Against a Local or Authenticated App

Codegen is not limited to public URLs. You can record against http://localhost:3000 while your dev server runs, against a staging environment, or against a page that requires specific headers. Combined with the storage-state flags, this lets you record the exact internal flows your suite needs to cover. If your local app uses a self-signed certificate, pass --ignore-https-errors so the recorder does not stall on the certificate warning.

# Record against a local dev server
playwright codegen --target python-pytest -o tests/test_checkout.py http://localhost:3000/cart

# Record against staging, ignoring a self-signed cert
playwright codegen --ignore-https-errors https://staging.internal/dashboard

A productive workflow is to keep a single saved auth.json for your app and start every recording session with --load-storage=auth.json. You log in once a week (or whenever the session expires), and every recording in between begins already authenticated, dropping you straight onto the page you want to capture. This removes the biggest friction in recording real application flows.

Editing the Generated Locators

Even the resilient locators codegen produces sometimes need a human touch. The recorder picks the first matching strategy in its priority order, but you may know a more semantic option. For instance, codegen might emit page.locator("div").filter(has_text="Save").get_by_role("button") for a deeply nested control where a single get_by_role("button", name="Save") would be clearer and more stable. Reviewing and simplifying locators is part of turning a recording into a maintainable test.

# Codegen sometimes produces an over-specific chain:
page.locator("#root").get_by_role("listitem").filter(
    has_text="Buy milk"
).get_by_role("button", name="Delete").click()

# A human can often simplify to a single semantic locator:
page.get_by_role("listitem", name="Buy milk").get_by_role(
    "button", name="Delete"
).click()

When two elements share a role and name, codegen may fall back to .nth(0) or a positional selector. Those positional locators are the most fragile part of any generated test because they break when the page order changes. Replace them with a more specific filter -- a parent container, a nearby label, or a test id -- before committing. A good rule: if a generated locator contains .nth( or a raw CSS structural selector, treat it as a candidate for hardening.

Generated locator smellWhy it is fragileBetter approach
.nth(2)Breaks when order changesFilter by text or test id
Long CSS chainBreaks on markup refactorSingle get_by_role/get_by_label
get_by_text on dynamic textBreaks when copy changesUse a stable test id

Codegen Versus Hand-Writing Tests

It is worth being clear about where codegen fits. For discovering locators on an unfamiliar page, it is unbeatable -- it applies Playwright's locator priority instantly and shows you the resilient option you might not have found by reading the DOM. For authoring a maintainable suite, hand-structuring still wins: you decide the Page Object boundaries, the test data strategy, and the assertions. The most effective teams use codegen as a fast front-end to discovery and then refactor, rather than treating its output as final.

This is also why codegen pairs so well with AI coding agents. The recorder gives you accurate locators; the agent (guided by a skill) restructures the raw script into Page Objects, parametrizes the data, and adds the assertions you actually care about. Recording plus agent-driven refactor is far faster than either approach alone, and it consistently produces cleaner tests than an agent working from a screenshot or a vague description with no real locators to anchor on.

Generating Codegen and pytest Skills with AI Agents

Codegen produces a draft; turning that draft into a clean, asserted, Page-Object-structured pytest test is repetitive work that AI coding agents handle well -- if they know the conventions. A generic agent often keeps the redundant clicks, hardcodes test data, and never extracts a Page Object. A QA skill teaches the agent your refactoring rules so it transforms recordings into production tests automatically.

Browse the Playwright Python and pytest skills at qaskills.sh/skills and install one:

# Install a Playwright Python codegen/refactor skill
npx @qaskills/cli add playwright-python-codegen

For the broader Python testing workflow, see our pytest complete guide, and for end-to-end patterns, our Playwright E2E complete guide.


Frequently Asked Questions

How do I record a Playwright test in Python?

Run playwright codegen --target python-pytest -o tests/test_flow.py <url>. Playwright opens a browser and the Inspector; as you click through your flow, it writes a pytest test to the file. When you close the browser, the file contains a runnable test. Install pytest-playwright to get the page fixture, then run it with pytest. The recorder picks resilient role- and label-based locators automatically.

What is the difference between python, python-async, and python-pytest targets?

--target python emits a synchronous script wrapped in sync_playwright(). --target python-async emits an asyncio script using async_playwright(). --target python-pytest emits a test_* function that takes the page fixture from the pytest-playwright plugin, which is the format you want for a real test suite. Choose pytest unless you are building a standalone script or an async application.

How do I record tests behind a login?

Use --save-storage=auth.json on a first session where you log in manually; Playwright saves the cookies and storage when you close the browser. On later recordings, add --load-storage=auth.json so the browser opens already authenticated and you can record the protected flow. In your tests, set the storage_state in browser_context_args to reuse the same file without any login code.

Can I record on mobile or a specific browser?

Yes. Use --browser firefox|webkit|chromium to choose the engine and --device "iPhone 14" to emulate a device with its viewport, user agent, and touch settings. You can also set --viewport-size, --color-scheme, --lang, --timezone, and --geolocation. Recording under the exact conditions you intend to test ensures the generated locators and layout match your target environment.

Is the generated codegen output ready to commit?

Not as-is. Generated code is a literal transcript: it repeats locators, includes redundant clicks before fills, and hardcodes values. Treat it as a draft. Refactor it by extracting locators into Page Objects, sourcing test data from fixtures or environment variables, and removing explicit waits since Playwright auto-waits. The recorder is best used as a locator-discovery tool, with you authoring the final structure.

How do I add assertions while recording?

Use the assertion tools in the Inspector. "Assert visibility" generates expect(locator).to_be_visible(), "Assert text" generates expect(locator).to_contain_text(...), and "Assert value" generates expect(locator).to_have_value(...) for inputs. Click the tool, then click the element in the browser. This embeds meaningful assertions in your recording so the generated draft already verifies state, not just performs actions.

Why does codegen pick get_by_role instead of CSS selectors?

Playwright applies a locator priority that favors user-facing, accessible attributes -- roles, labels, placeholders, and text -- over implementation details like CSS classes or DOM structure. Role- and label-based locators survive styling and markup refactors that would break CSS selectors, so they produce far more resilient tests. Codegen encodes this best practice, which is a major reason recording beats hand-writing selectors.


Conclusion

Playwright's Python codegen turns the slowest part of writing tests -- discovering stable locators -- into a two-minute recording session. Launch it with playwright codegen --target python-pytest -o test_file.py, click through your flow, and you get a runnable pytest test using resilient role- and label-based locators. Save authentication once with --save-storage and replay it with --load-storage to record flows behind a login without re-typing credentials, and use the Inspector's assertion tools to embed expect checks as you go.

The key discipline is treating generated code as a draft: refactor it into Page Objects, source test data from fixtures, and strip redundant waits before committing. To have your AI coding agent perform that refactor automatically and follow your conventions, install a Playwright Python skill from qaskills.sh/skills and read our pytest complete guide.

Playwright Python Codegen: Record Tests Guide 2026 | QASkills.sh