Write and Run Your First pytest Playwright Test in Python

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.

Advertisement

📖 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
  1. Imports. Page is the type of the page object (used for editor auto-complete) and expect creates Playwright assertions.
  2. A test function. pytest runs any function whose name starts with test_. The parameter page is 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 : Page part is a type hint; it does not change behaviour, but it gives you auto-complete in VS Code.
  3. An action. page.goto() opens the URL.
  4. 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_*.py or *_test.py,
  • functions named test_* in those files,
  • methods named test_* inside classes named Test* (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\.html means “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, while to_contain_text() checks for a substring.
  • "[data-test='error']" is a CSS attribute selector. Many apps add data-test or data-testid attributes 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 localStorage and checks it with a plain Python assert.
  • 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:

  1. **The > marker** points at the line that failed, inside a copy of your test code.
  2. What was expected and what the page actually had (Actual value). Comparing these two lines usually reveals the bug immediately.
  3. 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.
  4. 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.
  5. 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

CommandWhat it does
pytestRun every test pytest can find
pytest tests/test_login.pyRun one file
pytest tests/test_login.py::test_wrong_password_shows_errorRun one test
pytest -k "login and not locked"Run tests whose names match the expression
pytest -vShow each test name and result
pytest -xStop at the first failure
pytest --headedShow 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 a test_*.py file that asks for the page fixture.
  • expect() assertions wait and retry; use plain assert only 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”

Leave a Comment