Codegen is Playwright’s test recorder. You click through your application in a real browser, and Playwright writes the Python code for every action as you go, including good locators. In this lesson you will record pytest tests, read the generated code line by line, add assertions from the recorder toolbar, and turn a raw recording into a clean, reliable test.
π What You’ll Learn: starting Codegen with the python-pytest target, saving recordings to a file, what the generated code means, the recorder toolbar (record, pick locator, assertions), emulating devices, languages and logins while recording, recording from inside a test with page.pause(), and cleaning up recorded code.
π₯ Start Recording
Activate your virtual environment and run:
β¨οΈ Terminal
playwright codegen --target python-pytest https://www.saucedemo.com/
Two windows open:
- A browser showing the URL. Everything you do here is recorded.
- The Playwright Inspector, which shows the generated code live.
--target python-pytest tells Codegen to write a pytest test function that uses the page fixture. Other Python targets are python (a sync script with sync_playwright, the default) and python-async (an asyncio script).
To save the recording straight into your project, add -o (or --output):
β¨οΈ Terminal
playwright codegen --target python-pytest -o tests/test_recorded_login.py https://www.saucedemo.com/
The file is written when you close the browser window.
π What Codegen Writes
We recorded this flow on Sauce Demo: click the Username field, type standard_user, click the Password field, type secret_sauce, click Login, click Add to cart for the backpack, then open the cart. Codegen produced exactly this:
π tests/test_recorded_login.py
import re
from playwright.sync_api import Page, expect
def test_example(page: Page) -> None:
page.goto("https://www.saucedemo.com/")
page.locator("[data-test=\"username\"]").click()
page.locator("[data-test=\"username\"]").fill("standard_user")
page.locator("[data-test=\"password\"]").click()
page.locator("[data-test=\"password\"]").fill("secret_sauce")
page.locator("[data-test=\"login-button\"]").click()
page.locator("[data-test=\"add-to-cart-sauce-labs-backpack\"]").click()
page.locator("[data-test=\"shopping-cart-link\"]").click()
Reading it line by line:
import reis always added, so you can use regular expressions in assertions.Pageandexpectare imported for the test.def test_example(page: Page) -> None:is a normal pytest test. The-> Nonetype hint just says the function returns nothing. Rename the function to describe the behaviour.page.goto(...)is the URL you started with.- Each click and each typed value became one line. Codegen records the click into a field and the fill separately; the click is harmless but unnecessary, because
fill()focuses the field itself. - This site gives its elements
data-testattributes, so Codegen used them:[data-test="username"]is a CSS attribute selector. The\"sequences are just double quotes escaped inside a Python string.
β οΈ Watch out: The recording has no assertions. It proves the clicks did not crash, not that the app works. A test without an expect() is not a real test yet.
π― How Codegen Chooses Locators
Codegen looks at each element and picks the most stable, human-readable locator it can, roughly in this order: test-id attributes, then role plus accessible name, then text, placeholder or label, and CSS only as a fallback. On the SQA Tools practice page, which has no test-id attributes, we recorded typing a username, choosing Female, ticking Python, choosing India and clicking Submit:
import re
from playwright.sync_api import Page, expect
def test_example(page: Page) -> None:
page.goto("https://sqatools.in/automation-practice-page/")
page.get_by_role("textbox", name="Enter username").click()
page.get_by_role("textbox", name="Enter username").fill("qa_learner")
page.get_by_role("radio", name="Female").check()
page.get_by_role("checkbox", name="Python").check()
page.locator("#country").select_option("india")
page.get_by_role("button", name="Submit").click()
get_by_role("textbox", name="Enter username")finds the input by its role and accessible name, which here comes from its placeholder text.- Clicking a radio button or checkbox was recorded as
check(), which is more precise thanclick(): it only ticks, never unticks. - The dropdown has the id
country, so Codegen used the CSS id selector#country, and it selected the option by its value (india), not its visible label (India).
If your app uses a different test-id attribute name, tell Codegen with --test-id-attribute:
β¨οΈ Terminal
playwright codegen --target python-pytest --test-id-attribute data-qa https://example.com
π§° The Recorder Toolbar
The toolbar at the top of the browser window (and in the Inspector) has these tools:
| Tool | What it does |
|---|---|
| Record | Start or pause recording actions |
| Pick locator | Hover over any element to see the locator Codegen would use; click to copy it into the Inspector |
| Assert visibility | Click an element to add expect(...).to_be_visible() |
| Assert text | Click an element to add expect(...).to_contain_text(...), or to_have_text(...) when you keep the whole text |
| Assert value | Click an input to add expect(...).to_have_value(...) |
| Assert snapshot | Click an element to add an accessibility-tree assertion (to_match_aria_snapshot) |
Even if you write your tests by hand, Pick locator is extremely useful: it shows you the best locator for any element in seconds.
π§Ή From Recording to a Real Test
Raw recordings are a starting point. Clean them up in four steps:
- Rename
test_exampleto describe the behaviour. - Delete the unnecessary
click()before eachfill(). - Add assertions after each important step (the recorder’s assert tools help).
- Prefer user-facing locators where they read better, and move hard-coded data into variables.
Here is the Sauce Demo recording after cleanup (tests/test_cart.py):
π tests/test_cart.py
import re
from playwright.sync_api import Page, expect
def test_user_can_add_backpack_to_cart(page: Page) -> None:
# Log in
page.goto("https://www.saucedemo.com/")
page.locator("[data-test='username']").fill("standard_user")
page.locator("[data-test='password']").fill("secret_sauce")
page.locator("[data-test='login-button']").click()
expect(page).to_have_url(re.compile(r".*/inventory\.html"))
# Add the backpack to the cart
page.locator("[data-test='add-to-cart-sauce-labs-backpack']").click()
expect(page.locator("[data-test='shopping-cart-badge']")).to_have_text("1")
# Open the cart and check its contents
page.locator("[data-test='shopping-cart-link']").click()
expect(page).to_have_url(re.compile(r".*/cart\.html"))
expect(page.locator("[data-test='inventory-item-name']")).to_have_text("Sauce Labs Backpack")
What changed:
- Single quotes inside the selectors (
[data-test='username']) avoid the\"escapes and are easier to read. Both forms are valid CSS. - Three checkpoints now prove the flow works: the inventory page opened, the cart badge shows
1, and the cart contains the backpack. - Comments split the test into readable stages.
And the practice-page recording, cleaned up (tests/test_practice_form.py):
π tests/test_practice_form.py
from playwright.sync_api import Page, expect
def test_fill_practice_form(page: Page) -> None:
page.goto("https://sqatools.in/automation-practice-page/")
username = page.get_by_role("textbox", name="Enter username")
username.fill("qa_learner")
expect(username).to_have_value("qa_learner")
female = page.get_by_role("radio", name="Female")
female.check()
expect(female).to_be_checked()
python = page.get_by_role("checkbox", name="Python")
python.check()
expect(python).to_be_checked()
country = page.locator("#country")
country.select_option("india")
expect(country).to_have_value("india")
- Storing a locator in a variable (
username = ...) avoids repeating it for the action and the assertion. to_have_value,to_be_checkedand friends verify that each action really took effect.
Both tests pass:
π₯οΈ Output
tests/test_cart.py::test_user_can_add_backpack_to_cart[chromium] PASSED
tests/test_practice_form.py::test_fill_practice_form[chromium] PASSED
tests/test_recorded_login.py::test_example[chromium] PASSED
βοΈ Recording with Emulation and Saved Logins
Codegen accepts the same kind of options as a browser context, so you can record exactly the situation you want to test:
β¨οΈ Terminal
# Record on an emulated phone
playwright codegen --target python-pytest --device "iPhone 13" https://www.saucedemo.com/
# Record with a fixed window size, language and time zone
playwright codegen --viewport-size "1280,720" --lang "en-GB" --timezone "Europe/London" https://www.saucedemo.com/
# Record in dark mode
playwright codegen --color-scheme dark https://www.saucedemo.com/
# Log in once and save the session, then record later flows already logged in
playwright codegen --save-storage auth.json https://www.saucedemo.com/
playwright codegen --load-storage auth.json https://www.saucedemo.com/inventory.html
--save-storage auth.jsonsaves cookies and local storage when you close the browser;--load-storage auth.jsonstarts the next session with them, so you skip the login steps.- Other useful options:
-b firefoxor-b webkitto record in another engine,--ignore-https-errorsfor test servers with self-signed certificates, and--save-harto save all network traffic.
β οΈ Watch out: auth.json contains session cookies, which work like a password. Add it to .gitignore and never commit it.
βΈοΈ Record from Inside a Test: page.pause()
Sometimes you want to record the middle of a flow, after your test has already logged in or prepared data. Add page.pause() where you want to start, and run the test headed:
def test_record_after_login(page: Page) -> None:
page.goto("https://www.saucedemo.com/")
page.locator("[data-test='username']").fill("standard_user")
page.locator("[data-test='password']").fill("secret_sauce")
page.locator("[data-test='login-button']").click()
page.pause() # opens the Playwright Inspector here
β¨οΈ Terminal
pytest tests/test_record_after_login.py --headed
The test stops at page.pause() and opens the Inspector. Click Record to record new steps from that point, then copy the generated lines into your test and remove page.pause().
β Frequently Asked Questions
π€ Should I build my whole suite with Codegen?
No. Use it to learn the API, discover locators and draft tests quickly. Real suites need assertions, reusable page objects and test data, which you will add yourself in later modules.
π€ Why does Codegen generate page.locator(“#id”) instead of get_by_role?
It chooses the most reliable locator it can find. When an element has no accessible name that is unique on the page, an id or test-id selector is safer. You can always change it.
π€ Can Codegen record file uploads, new tabs and dialogs?
Yes. It records uploads as set_input_files(), new tabs inside a with page.expect_popup() as page1_info: block, downloads inside with page.expect_download(), and dialogs as page.once("dialog", lambda dialog: dialog.dismiss()). Note that the dialog handler dismisses the dialog (like clicking Cancel); change it to dialog.accept() if your test needs OK. You will learn all of these in Module 4.
π€ What is the difference between python and python-pytest targets?
python writes a standalone script that launches and closes the browser itself with sync_playwright(). python-pytest writes a test function that receives the page fixture, ready to drop into your tests folder.
π Summary
playwright codegen --target python-pytest -o tests/test_x.py URLrecords a pytest test.- Codegen prefers test ids, then roles and names; Pick locator shows the locator for any element.
- Recordings have no assertions: rename, remove extra clicks, add
expect()checks and tidy locators. - Use emulation flags,
--save-storage/--load-storageandpage.pause()to record exactly the flow you need.
That completes Module 1. Test yourself with the Module 1 mock interview, then continue to Module 2, Python essentials for Playwright.
π¬ Watch the Playwright tutorial series on YouTube!
Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page