Now you will write real tests. With pytest and the pytest-playwright plugin, you never launch or close a browser yourself: you ask for a page, use it, and pytest cleans up. In this lesson you will understand every line of a Playwright test, automate a login page, add negative tests, run them in different ways, and learn to read a failure message.
📖 What You’ll Learn: test anatomy (test function, page fixture, expect), pytest discovery rules, writing a login test with Arrange-Act-Assert, negative tests, why every test gets a fresh page, running tests from the CLI, and reading a failing test’s output.
🧱 Anatomy of a Playwright Test
Create a tests folder in your project and add tests/test_title.py:
📄 tests/test_title.py
from playwright.sync_api import Page, expect # 1. imports
def test_practice_page_has_title(page: Page): # 2. a test function
page.goto("https://sqatools.in/automation-practice-page/") # 3. an action
expect(page).to_have_title("Automation Practice Page - SQA Tools") # 4. an assertion
- Imports.
Pageis the type of the page object (used for editor auto-complete) andexpectcreates Playwright assertions. - A test function. pytest runs any function whose name starts with
test_. The parameterpageis a fixture: pytest-playwright sees the name, creates a fresh page in a new browser context, passes it in, and closes it after the test. The: Pagepart is a type hint; it does not change behaviour, but it gives you auto-complete in VS Code. - An action.
page.goto()opens the URL. - An assertion.
expect(page).to_have_title(...)checks the title. If it does not match yet, it retries for up to 5 seconds before failing. This is called a *web-first assertion*.
Run it:
⌨️ Terminal
pytest tests/test_title.py
🔍 How pytest Finds Your Tests
When you run pytest, it searches the current folder and its subfolders. By default it collects:
- files named
test_*.pyor*_test.py, - functions named
test_*in those files, - methods named
test_*inside classes namedTest*(the class must not have an__init__method).
So tests/test_login.py with def test_valid_login(page) is found automatically, while tests/login.py or def check_login(page) would be silently ignored. You will learn how to change these rules in pytest Configuration.
⚠️ Watch out: A test file named test_something.py that is never collected is almost always caused by a typo in the file or function name. Run pytest --collect-only to list what pytest found without running it.
✍️ A Real Test: Login
Next, automate the login page of Sauce Demo (https://www.saucedemo.com), a free practice shop that accepts the username standard_user and password secret_sauce. Create tests/test_login.py:
📄 tests/test_login.py
import re
from playwright.sync_api import Page, expect
def test_valid_login_shows_products(page: Page):
# Arrange: open the login page
page.goto("https://www.saucedemo.com/")
# Act: fill the form and submit
page.get_by_placeholder("Username").fill("standard_user")
page.get_by_placeholder("Password").fill("secret_sauce")
page.get_by_role("button", name="Login").click()
# Assert: we land on the products page
expect(page).to_have_url(re.compile(r".*/inventory\.html"))
expect(page.locator(".title")).to_have_text("Products")
def test_wrong_password_shows_error(page: Page):
page.goto("https://www.saucedemo.com/")
page.get_by_placeholder("Username").fill("standard_user")
page.get_by_placeholder("Password").fill("wrong_password")
page.get_by_role("button", name="Login").click()
error = page.locator("[data-test='error']")
expect(error).to_be_visible()
expect(error).to_contain_text("Username and password do not match")
def test_locked_out_user_cannot_log_in(page: Page):
page.goto("https://www.saucedemo.com/")
page.get_by_placeholder("Username").fill("locked_out_user")
page.get_by_placeholder("Password").fill("secret_sauce")
page.get_by_role("button", name="Login").click()
expect(page.locator("[data-test='error']")).to_contain_text("locked out")
expect(page).to_have_url("https://www.saucedemo.com/")
What the new pieces do:
- Arrange, Act, Assert. The comments split the first test into setting up, doing the user action, and checking the result. Keeping this shape makes tests easy to read and review.
get_by_placeholder("Username")finds an input by its placeholder text;get_by_role("button", name="Login")finds the button by its role and visible name, the way a user sees it.to_have_url(re.compile(...))accepts either an exact string or a regular expression. The pattern.*/inventory\.htmlmeans “any URL ending in /inventory.html”; the backslash makes the dot literal.page.locator(".title")uses a CSS selector.to_have_text("Products")checks the element’s full text, whileto_contain_text()checks for a substring."[data-test='error']"is a CSS attribute selector. Many apps adddata-testordata-testidattributes for exactly this purpose.- The second and third tests are negative tests: they prove the app rejects bad input. A good suite always tests both the happy path and the failure paths.
Run the file with -v (verbose) to see each test by name:
⌨️ Terminal
pytest tests/test_login.py -v
🖥️ Output
tests/test_login.py::test_valid_login_shows_products[chromium] PASSED [ 33%]
tests/test_login.py::test_wrong_password_shows_error[chromium] PASSED [ 66%]
tests/test_login.py::test_locked_out_user_cannot_log_in[chromium] PASSED [100%]
The [chromium] suffix is added by pytest-playwright: it shows which browser ran the test. Chromium is the default, and you will run the same tests in Firefox and WebKit in the next lesson.
🔒 Every Test Gets a Fresh Page
The tests above never log out, yet the second test starts on the login page as a stranger. That is because each test receives a new browser context: new cookies, new storage, nothing left over from the previous test. This file proves it (tests/test_isolation.py):
📄 tests/test_isolation.py
from playwright.sync_api import Page
def test_first_saves_a_value(page: Page):
page.goto("https://sqatools.in/automation-practice-page/")
page.evaluate("localStorage.setItem('step', 'done')")
assert page.evaluate("localStorage.getItem('step')") == "done"
def test_second_starts_clean(page: Page):
page.goto("https://sqatools.in/automation-practice-page/")
assert page.evaluate("localStorage.getItem('step')") is None
- The first test stores a value in the page’s
localStorageand checks it with a plain Pythonassert. - The second test opens the same site in its own fresh context and finds nothing there.
💡 Tip: Use plain assert for Python values you already have (numbers, strings, lists). Use expect(...) for anything on the page, because it waits and retries while the page catches up.
💥 Reading a Failure
Tests fail, and reading the failure quickly is a core skill. Here is a test with a deliberate mistake (tests/test_failing.py):
📄 tests/test_failing.py
from playwright.sync_api import Page, expect
def test_title_typo(page: Page):
page.goto("https://sqatools.in/automation-practice-page/")
expect(page).to_have_title("Automation Practise Page - SQA Tools")
The expected title says “Practise” instead of “Practice”. Run it:
⌨️ Terminal
pytest tests/test_failing.py
🖥️ Output
================================== FAILURES ===================================
__________________________ test_title_typo[chromium] __________________________
page = <Page url='https://sqatools.in/automation-practice-page/'>
def test_title_typo(page: Page):
page.goto("https://sqatools.in/automation-practice-page/")
> expect(page).to_have_title("Automation Practise Page - SQA Tools")
E AssertionError: Page title expected to be 'Automation Practise Page - SQA Tools'
E Actual value: Automation Practice Page - SQA Tools
E Call log:
E - Expect "to_have_title" with timeout 5000ms
E 13 × locator resolved to <html lang="en-US" prefix="og: https://ogp.me/ns#">…</html>
E - unexpected value "Automation Practice Page - SQA Tools"
E
E Aria snapshot:
E - link "Skip to content":
E - /url: "#content"
E - banner "Site":
E ... (the rest of the page structure, cut here)
tests\test_failing.py:6: AssertionError
=========================== short test summary info ===========================
FAILED tests/test_failing.py::test_title_typo[chromium] - AssertionError: Pag...
============================== 1 failed in 7.08s ==============================
How to read it, top to bottom:
- **The
>marker** points at the line that failed, inside a copy of your test code. - What was expected and what the page actually had (
Actual value). Comparing these two lines usually reveals the bug immediately. - The call log shows that Playwright kept checking for 5 seconds (the default assertion timeout) and saw the same wrong value each time, so this is not a timing problem.
- The aria snapshot (added by recent Playwright versions) is a text outline of the page at the moment of failure: headings, links, buttons, fields. It helps you see what was really on screen, especially in CI where you cannot watch the browser.
- The file and line number (
test_failing.py:6) and the short summary list every failed test with its browser.
📝 Note: The exact retry count in the call log (here 13 ×) and the run time vary from run to run. That is normal.
▶️ Running Tests: The Essentials
| Command | What it does |
|---|---|
pytest | Run every test pytest can find |
pytest tests/test_login.py | Run one file |
pytest tests/test_login.py::test_wrong_password_shows_error | Run one test |
pytest -k "login and not locked" | Run tests whose names match the expression |
pytest -v | Show each test name and result |
pytest -x | Stop at the first failure |
pytest --headed | Show the browser window |
The next lesson covers every Playwright-specific option, such as --browser, --slowmo, --screenshot and --tracing.
❓ Frequently Asked Questions
🤔 Why do I not need to launch or close the browser?
The page fixture from pytest-playwright does it. It reuses one browser for the whole run, creates a new context and page for each test, and closes them afterwards, even when the test fails.
🤔 Can I put tests in a class?
Yes. Name the class Test..., do not give it an __init__, and add page as a parameter to each method: def test_login(self, page: Page):. Classes are useful for grouping related tests.
🤔 Why use expect instead of assert?
expect waits and retries until the condition is true or the timeout (5 seconds by default) is reached. A plain assert page.title() == "..." checks only once, so it can fail if the page is still loading.
🤔 Why does the test name end with [chromium]?
pytest-playwright parametrizes tests by browser. If you run with --browser firefox --browser webkit, each test runs once per browser and the suffix shows which run you are looking at.
🏁 Summary
- A test is a
test_function in atest_*.pyfile that asks for thepagefixture. expect()assertions wait and retry; use plainassertonly for values you already hold.- Structure tests as Arrange, Act, Assert, and always include negative tests.
- Each test gets a new context and page, so tests never share state.
- Failures show expected vs actual values, a call log and the failing line.
Next, you will learn every pytest and pytest-playwright command-line option for running tests in different browsers, headed mode, slow motion and more.
🎬 Watch the Playwright tutorial series on YouTube!
Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page
1 thought on “Write and Run Your First pytest Playwright Test in Python”