pytest Tutorial for Beginners: Test Discovery, assert and Running Tests

pytest is the test runner behind every Playwright Python suite in this course. Before going deeper into fixtures and plugins, it pays to understand pytest itself: how it finds tests, why a plain assert statement gives such helpful failure messages, how to test for exceptions and floating-point values, what each test outcome means, and how to read the results. Most examples in this lesson use plain Python so you can focus on pytest; the last one brings Playwright back in.

📖 What You’ll Learn: test discovery rules, plain assert and pytest’s assertion introspection, pytest.raises and pytest.approx, test classes, the five test outcomes, reading the short summary with -r, controlling tracebacks with –tb, exit codes for CI, and the most useful command-line flags.


🤔 Why pytest?

Python ships with a test framework called unittest, modelled on Java’s JUnit: tests live in classes that inherit from unittest.TestCase and use methods such as self.assertEqual(). pytest, the most popular alternative, lets you write tests as plain functions with plain assert, and adds fixtures, parametrization and a large plugin ecosystem. It can also run existing unittest tests, so teams can switch gradually.

unittestpytest
A test isA method in a TestCase subclassAny test_ function (or method in a Test class)
Checking a valueself.assertEqual(a, b)assert a == b
Setup and teardownsetUp / tearDown methodsFixtures with yield
PluginsFewMore than a thousand, including pytest-playwright

🔍 How pytest Finds Tests

When you run pytest, it walks through the current folder (or the testpaths you configured) and collects:

  1. files named test_*.py or *_test.py;
  2. inside them, functions whose names start with test;
  3. classes whose names start with Test and that have no __init__ method, and inside them, methods whose names start with test.

Everything else is ignored. Helper functions without the test prefix are never run as tests, which is exactly what you want.


✅ Plain assert, Powerful Messages

Create tests/test_basics.py:

📄 tests/test_basics.py

import pytest


def to_price(text: str) -> float:
    """Convert '$29.99' to 29.99."""
    return float(text.replace("$", ""))


def test_price_is_parsed():
    assert to_price("$29.99") == 29.99


def test_total_with_tax():
    total = to_price("$29.99") + to_price("$9.99")
    assert total * 1.08 == pytest.approx(43.1784)


def test_bad_price_raises_value_error():
    with pytest.raises(ValueError, match="could not convert"):
        to_price("free")


def test_tags_contain_smoke():
    tags = ["smoke", "login", "regression"]
    assert "smoke" in tags
    assert len(tags) == 3

What each test shows:

  • **assert expression**: if the expression is false, the test fails. You need no special methods; ==, in, <, is None and every other Python expression work.
  • **pytest.approx()**: floating-point maths is not exact (0.1 + 0.2 is 0.30000000000000004), so compare floats with approx, which allows a tiny tolerance.
  • **pytest.raises()**: a context manager that passes only if the code inside raises the given exception. match= checks the error message with a regular expression.

⌨️ Terminal

pytest -q tests/test_basics.py

🖥️ Output

....                                                                     [100%]
4 passed in 0.02s

What a failure looks like

pytest rewrites assert statements while importing test files, so when one fails it can show you the values involved, not just “AssertionError”. These tests fail on purpose (tests/test_failures.py):

📄 tests/test_failures.py

def test_compare_lists():
    expected = ["Backpack", "Bike Light", "Onesie"]
    actual = ["Backpack", "Bolt T-Shirt", "Onesie"]
    assert actual == expected


def test_compare_dicts():
    expected = {"browser": "chromium", "retries": 2, "headless": True}
    actual = {"browser": "chromium", "retries": 0, "headless": True}
    assert actual == expected

⌨️ Terminal

pytest -q tests/test_failures.py

🖥️ Output

_____________________________ test_compare_lists ______________________________

    def test_compare_lists():
        expected = ["Backpack", "Bike Light", "Onesie"]
        actual = ["Backpack", "Bolt T-Shirt", "Onesie"]
>       assert actual == expected
E       AssertionError: assert ['Backpack', ...rt', 'Onesie'] == ['Backpack', ...ht', 'Onesie']
E
E         At index 1 diff: 'Bolt T-Shirt' != 'Bike Light'
E         Use -v to get more diff

tests\test_failures.py:4: AssertionError
_____________________________ test_compare_dicts ______________________________

    def test_compare_dicts():
        expected = {"browser": "chromium", "retries": 2, "headless": True}
        actual = {"browser": "chromium", "retries": 0, "headless": True}
>       assert actual == expected
E       AssertionError: assert {'browser': '...adless': True} == {'browser': '...adless': True}
E
E         Omitting 2 identical items, use -vv to show
E         Differing items:
E         {'retries': 0} != {'retries': 2}
E         Use -v to get more diff

tests\test_failures.py:10: AssertionError
=========================== short test summary info ===========================
FAILED tests/test_failures.py::test_compare_lists - AssertionError: assert ['...
FAILED tests/test_failures.py::test_compare_dicts - AssertionError: assert {'...
2 failed in 0.06s

For the list, pytest pinpoints the first difference (“At index 1 diff”). For the dictionary, it hides the two identical items and shows only the key that differs: {'retries': 0} != {'retries': 2}. The long values are shortened with ...; add -v or -vv to see a full diff.

Shorter tracebacks: –tb

When many tests fail, long tracebacks are hard to scan. --tb controls them: --tb=short (shorter), --tb=line (the error plus one location line per failure) or --tb=no (none).

⌨️ Terminal

pytest -q --tb=line tests/test_failures.py

🖥️ Output

E   AssertionError: assert ['Backpack', ...rt', 'Onesie'] == ['Backpack', ...ht', 'Onesie']

      At index 1 diff: 'Bolt T-Shirt' != 'Bike Light'
      Use -v to get more diff
C:\pwv\pytest-tutorial-for-beginners\tests\test_failures.py:4: AssertionError: assert ['Backpack', ...rt', 'Onesie'] == ['Backpack', ...ht', 'Onesie']
E   AssertionError: assert {'browser': '...adless': True} == {'browser': '...adless': True}
      ...
C:\pwv\pytest-tutorial-for-beginners\tests\test_failures.py:10: AssertionError: assert {'browser': '...adless': True} == {'browser': '...adless': True}
2 failed in 0.01s

The code listings are gone; only the assertion message and the file and line of each failure remain.


🚦 The Five Test Outcomes

Every collected test ends in one of these outcomes. This file produces all of them (tests/test_outcomes.py):

📄 tests/test_outcomes.py

import sys

import pytest


@pytest.fixture
def broken_setup():
    raise RuntimeError("database not reachable")


def test_passes():
    assert 2 + 2 == 4


def test_fails():
    assert "Products" == "Product"


def test_errors_in_setup(broken_setup):
    assert True


@pytest.mark.skipif(sys.platform == "win32", reason="Linux-only check")
def test_skipped_on_windows():
    assert True


@pytest.mark.xfail(reason="known bug #123: cart badge shows 0")
def test_known_bug():
    assert 1 == 0
OutcomeLetterMeaning
passed.All asserts held
failedFAn assert or exception inside the test body
errorESomething went wrong outside the test body, in a fixture’s setup or teardown
skippedsThe test was not run (by marker or pytest.skip())
xfailedxExpected to fail, and did; xpassed (X) means it unexpectedly passed

The -r option prints a short summary for chosen outcomes; -rA means “all”:

⌨️ Terminal

pytest -q -rA tests/test_outcomes.py

🖥️ Output

.FEsx                                                                    [100%]
...
=========================== short test summary info ===========================
PASSED tests/test_outcomes.py::test_passes
SKIPPED [1] tests\test_outcomes.py:23: Linux-only check
XFAIL tests/test_outcomes.py::test_known_bug - known bug #123: cart badge shows 0
ERROR tests/test_outcomes.py::test_errors_in_setup - RuntimeError: database n...
FAILED tests/test_outcomes.py::test_fails - AssertionError: assert 'Products'...
1 failed, 1 passed, 1 skipped, 1 xfailed, 1 error in 0.07s

The progress line .FEsx shows one letter per test. The skip appears because this run was on Windows; on Linux that test would pass. pytest shortens long summary lines with ... to fit the terminal width.

The difference between failed and error matters when you read CI results: a failure usually means the application is wrong, an error usually means your test setup is broken. Markers such as skipif and xfail are covered in detail in pytest Markers.


🗃️ Grouping Tests in a Class

Classes are a simple way to group related tests. pytest creates a new instance for every test method, so tests never share instance attributes (tests/test_cart_class.py):

📄 tests/test_cart_class.py

class TestCart:
    items = []                          # class attribute: shared, avoid mutating

    def test_starts_empty(self):
        cart = []
        assert cart == []

    def test_add_item(self):
        cart = ["Backpack"]
        cart.append("Onesie")
        assert cart == ["Backpack", "Onesie"]

    def test_instances_are_separate(self):
        self.note = "set in this test only"
        assert hasattr(self, "note")

⚠️ Watch out: Do not store state on the class (like items = [] above) and change it from tests. Class attributes are shared between all tests and make results depend on run order. Use fixtures instead (next lesson).


🎭 And with Playwright

Everything above applies unchanged to browser tests. The only addition is the page fixture (tests/test_ui.py):

📄 tests/test_ui.py

import pytest
from playwright.sync_api import Page, expect


def test_practice_page_heading(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")
    expect(page.get_by_role("heading", name="Web Table")).to_be_visible()


def test_table_has_three_rows(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")
    rows = page.locator("table tbody tr")
    assert rows.count() == 3                       # plain assert on a Python value
    with pytest.raises(AssertionError):
        expect(rows).to_have_count(4, timeout=500)  # expect raises AssertionError
  • rows.count() returns a plain int, so a plain assert is fine.
  • A failing expect() raises AssertionError, which is why pytest reports it as a normal test failure. Here pytest.raises proves it, using a short timeout to keep the demo fast.

🔢 Exit Codes for CI

pytest’s exit code tells CI servers what happened. This script runs pytest from Python and prints the code for three situations (exit_codes.py):

📄 exit_codes.py

import subprocess

PY = r"C:\pwv\_venv0\Scripts\python"
cases = {
    "all passed": ["tests/test_basics.py"],
    "some failed": ["tests/test_failures.py"],
    "no tests collected": ["tests/test_basics.py", "-k", "nothing_matches"],
}
for label, args in cases.items():
    code = subprocess.run([PY, "-m", "pytest", "-q", "-p", "no:cacheprovider", *args], capture_output=True).returncode
    print(f"{label:20} -> exit code {code}")

🖥️ Output

all passed           -> exit code 0
some failed          -> exit code 1
no tests collected   -> exit code 5
CodeMeaning
0All collected tests passed
1Some tests failed
2Run interrupted (for example Ctrl+C or a collection error)
3Internal pytest error
4Command-line usage error (for example an unknown option)
5No tests were collected

💡 Tip: Exit code 5 catches a sneaky CI problem: a typo in a -k or -m filter that selects nothing would otherwise look like a “green” build.


🧭 Everyday Command-Line Flags

FlagEffect
-v / -qMore / less output
-k "login and not slow"Select by name expression
-m smokeSelect by marker
-x / --maxfail=3Stop after the first / third failure
--lf / --ffRun only last failures / run them first
-r fESummary of failed and errored tests
--tb=shortShorter tracebacks
--durations=5Show the 5 slowest tests
--collect-only -qList tests without running them
-sShow print() output

❓ Frequently Asked Questions

🤔 Can pytest run my old unittest tests?

Yes. pytest collects unittest.TestCase classes automatically, so you can migrate step by step.

🤔 Why does pytest show values in assertion errors but plain Python does not?

pytest rewrites the assert statements in test modules (and conftest.py files) when it imports them, adding code that records the values. Plain Python only knows the assertion failed.

🤔 Should I use assert or expect in Playwright tests?

Use expect() for anything on the page, because it waits and retries. Use plain assert for values you already have, such as a count you read or data you computed.

🤔 What is the difference between a failure and an error?

A failure happens in the test body; an error happens in setup or teardown, usually in a fixture. Errors point at broken test infrastructure rather than a broken application.


🏁 Summary

  • pytest collects test_*.py files, test functions and Test classes without __init__.
  • Plain assert plus assertion rewriting gives detailed failure messages; use pytest.approx for floats and pytest.raises for exceptions.
  • Outcomes are passed, failed, error, skipped and xfailed/xpassed; -rA summarises them.
  • Exit codes (0, 1, 2, 3, 4, 5) tell CI what happened; 5 means nothing was collected.

Next, you will learn fixtures, the feature that makes pytest so powerful for setup, teardown and sharing resources such as browsers.

🎬 Watch the Playwright tutorial series on YouTube!

Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page

Python async/await and asyncio for Playwright: A Beginner’s Guide

Playwright for Python offers an async API next to the sync API you have used so far. To read and write async code confidently you need three ideas: coroutines, await and the event loop. In this lesson you will learn them with small timing experiments, avoid the classic mistakes, and then use async Playwright to load several pages at the same time.

📖 What You’ll Learn: what concurrency means, async def and coroutines, await, asyncio.run, running tasks together with asyncio.gather, the “coroutine was never awaited” mistake, async with and async for, using async_playwright to open pages concurrently, and when async is (and is not) worth it for testing.


⏳ Why Async Exists

Most of the time a browser test is waiting: for a page to load, a response to arrive, an element to appear. In normal (synchronous) code, while one operation waits, nothing else happens. Asynchronous code lets a single Python thread start an operation, and while it waits, switch to other work. This is called concurrency.

An everyday comparison: a synchronous cook boils the water, waits, then chops vegetables. An asynchronous cook starts the water, chops the vegetables while it heats, and comes back when it boils. One cook, less total time.


🧩 async def, await and asyncio.run

Three building blocks:

  • async def defines a coroutine function. Calling it does not run it yet; it returns a coroutine object.
  • await runs a coroutine (or another awaitable) and pauses the current coroutine until the result is ready, letting other work run meanwhile. You can only use await inside an async def.
  • asyncio.run(main()) creates the event loop (the scheduler that switches between waiting tasks), runs main() until it finishes, and closes the loop.

⏱️ Experiment: Sequential vs Concurrent

asyncio.sleep(1) simulates waiting one second for something, such as a page load. Save this as sequential_vs_gather.py:

📄 sequential_vs_gather.py

import asyncio
import time


async def load_page(name: str, seconds: float) -> str:
    await asyncio.sleep(seconds)          # pretend we are waiting for the network
    return f"{name} loaded"


async def one_after_another() -> None:
    start = time.perf_counter()
    a = await load_page("home", 1)
    b = await load_page("login", 1)
    c = await load_page("cart", 1)
    print("sequential:", [a, b, c], f"{time.perf_counter() - start:.1f}s")


async def all_together() -> None:
    start = time.perf_counter()
    results = await asyncio.gather(
        load_page("home", 1),
        load_page("login", 1),
        load_page("cart", 1),
    )
    print("gather:    ", results, f"{time.perf_counter() - start:.1f}s")


asyncio.run(one_after_another())
asyncio.run(all_together())

🖥️ Output

sequential: ['home loaded', 'login loaded', 'cart loaded'] 3.0s
gather:     ['home loaded', 'login loaded', 'cart loaded'] 1.0s

What happened:

  • In one_after_another, each await waits for its one-second sleep to finish before the next one starts: 3 seconds in total.
  • asyncio.gather(...) starts all three coroutines at once and waits until all are done. Because the waits overlap, the total is about 1 second.
  • gather returns the results in the order you passed the coroutines, not in the order they finished.

📝 Note: Concurrency helps when code is waiting (network, disk, browsers). It does not make CPU-heavy calculations faster, because one thread still does one calculation at a time.


🐞 The Classic Mistake: Forgetting await

Calling a coroutine function without await does not run it. Save this as forgot_await.py:

📄 forgot_await.py

import asyncio


async def get_title() -> str:
    await asyncio.sleep(0.1)
    return "Swag Labs"


async def main() -> None:
    wrong = get_title()               # missing await: nothing runs
    print("Without await:", type(wrong).__name__)

    right = await get_title()
    print("With await:   ", right)


asyncio.run(main())

🖥️ Output

Without await: coroutine
With await:    Swag Labs

Python also prints a warning on the error stream when the forgotten coroutine is cleaned up:

🖥️ Output

RuntimeWarning: coroutine 'get_title' was never awaited

In async Playwright the same mistake looks like title = page.title() printing <coroutine object Page.title at 0x...> instead of the title. Whenever you see “coroutine” where you expected a value, look for a missing await.


🎭 Async Playwright: Load Three Pages Concurrently

The async API has the same methods as the sync API; every call is awaited, and with becomes async with. This script opens three practice sites at the same time in one browser (async_titles.py):

📄 async_titles.py

import asyncio
import time

from playwright.async_api import Browser, async_playwright

URLS = [
    "https://sqatools.in/automation-practice-page/",
    "https://www.saucedemo.com/",
    "https://sqatools.in/login-page/",
]


async def get_title(browser: Browser, url: str) -> str:
    context = await browser.new_context()       # one isolated context per page
    page = await context.new_page()
    await page.goto(url)
    title = await page.title()
    await context.close()
    return title


async def main() -> None:
    async with async_playwright() as p:
        browser = await p.chromium.launch()

        start = time.perf_counter()
        titles = await asyncio.gather(*(get_title(browser, url) for url in URLS))
        elapsed = time.perf_counter() - start

        for url, title in zip(URLS, titles):
            print(f"{title:40} <- {url}")
        print(f"{len(titles)} pages loaded concurrently")
        print("Finished in under 10 seconds:", elapsed < 10)

        await browser.close()


asyncio.run(main())

🖥️ Output

Automation Practice Page - SQA Tools     <- https://sqatools.in/automation-practice-page/
Swag Labs                                <- https://www.saucedemo.com/
Login Page - SQA Tools                   <- https://sqatools.in/login-page/
3 pages loaded concurrently
Finished in under 10 seconds: True

Line by line:

  1. from playwright.async_api import ... selects the async API. Browser is imported for the type hint.
  2. get_title() is a coroutine: it creates its own context and page, loads the URL, reads the title, and closes the context. Each call is independent, just like separate tests.
  3. async with async_playwright() as p: starts and stops Playwright.
  4. get_title(browser, url) for url in URLS is a generator expression that creates one coroutine per URL; the * unpacks them into asyncio.gather(...), which runs them concurrently.
  5. zip(URLS, titles) pairs each URL with its title, in the original order. {title:40} pads the title to 40 characters so the arrows line up.

💡 Tip: Exact timings depend on your network, so this script prints a yes/no check instead of a number. Try changing it to load the URLs one by one with a for loop and compare the times yourself.


🔁 async with and async for

Two more async keywords you will meet:

  • async with uses an *asynchronous* context manager, one whose setup and cleanup are awaited (async with async_playwright() as p:).
  • async for loops over an *asynchronous* iterator, which produces items that must be awaited. Playwright’s main API rarely needs it, but you will see it in async libraries.

🚫 You Cannot Start an Event Loop Inside Another

asyncio.run() creates a new event loop, and Python allows only one running loop per thread. Calling asyncio.run() from code that is already async fails (nested_run.py):

📄 nested_run.py

import asyncio


async def inner() -> str:
    return "done"


async def outer() -> None:
    coroutine = inner()
    try:
        asyncio.run(coroutine)                 # not allowed inside a running loop
    except RuntimeError as error:
        print("RuntimeError:", error)
        coroutine.close()                      # tidy up the unused coroutine
    print("Correct way:", await inner())


asyncio.run(outer())

🖥️ Output

RuntimeError: asyncio.run() cannot be called from a running event loop
Correct way: done

Inside async code, always await coroutines instead of calling asyncio.run() again. This is also why the sync Playwright API refuses to run inside an event loop (you saw that error in Lesson 1).


🧪 Should Your Tests Be Async?

For most UI test suites, no. The sync API with pytest-playwright is simpler, gives each test its own page automatically, and pytest-xdist (Module 12) already runs tests in parallel processes. Async is worth it when:

  • your application or tooling is already built on asyncio;
  • one test or script must drive many pages or API calls at the same time (for example, simulating several users);
  • you build tools such as crawlers or monitors rather than test suites.

When you do want async tests, the official pytest-playwright-asyncio plugin provides async fixtures. Module 13 covers it in detail.


❓ Frequently Asked Questions

🤔 Is async the same as multithreading?

No. Async runs on one thread and switches between tasks only at await points. Threads can switch at any moment and need more care with shared data. For network-bound work like browser automation, async is usually simpler and lighter.

🤔 Can I call a sync function inside an async function?

Yes, but if it blocks (for example time.sleep(5)), it freezes the whole event loop and every other task waits too. Use await asyncio.sleep(5) instead inside async code.

🤔 Does gather stop if one coroutine fails?

By default the first exception is raised from await asyncio.gather(...), but the other coroutines are not cancelled and keep running in the background. Pass return_exceptions=True to get exceptions back as results instead.

🤔 Do I need the async API to run tests in parallel?

No. Parallel test runs come from pytest-xdist with the sync API, which is the most common setup for Playwright Python suites.


🏁 Summary

  • async def creates coroutines; await runs them; asyncio.run() starts the event loop.
  • asyncio.gather() runs waiting tasks concurrently and returns results in order.
  • A missing await gives you a coroutine object and a “never awaited” warning.
  • Async Playwright uses the same methods with await and async with; use it when you truly need concurrency.

That completes Module 2. Test yourself with the Module 2 mock interview, then continue to Module 3, where you master pytest itself.

🎬 Watch the Playwright tutorial series on YouTube!

Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page

Python Context Managers and the with Statement for Playwright (expect_popup, expect_download)

The with statement appears in almost every Playwright Python program: it starts Playwright, waits for new tabs, catches downloads and opens files. It is powered by context managers, objects that run setup code when a block starts and cleanup code when it ends, even if the block fails. In this lesson you will learn how they work, use Playwright’s built-in ones, and write your own for timing and logging test steps.

📖 What You’ll Learn: what the with statement does, __enter__ and __exit__, why cleanup runs even after an exception, Playwright’s context managers (sync_playwright, expect_popup, expect_download), writing context managers with a class and with contextlib.contextmanager, and how pytest yield fixtures use the same idea.


🚪 What the with Statement Does

You have already used with to work with files. This program (files_demo.py) writes a small test data file and reads it back:

📄 files_demo.py

with open("users.txt", "w", encoding="utf-8") as f:
    f.write("standard_user\n")
    f.write("problem_user\n")

print("File closed after the block?", f.closed)

with open("users.txt", encoding="utf-8") as f:
    users = f.read().splitlines()

print(users)

🖥️ Output

File closed after the block? True
['standard_user', 'problem_user']
  • open(...) returns a file object, and as f gives it a name for the block.
  • When the indented block ends, the file is closed automatically: you never forget f.close().
  • splitlines() turns the text into a list of lines without the \n characters.

Any object that knows how to “start” and “finish” a block like this is a context manager.


🔧 How It Works: __enter__ and __exit__

A context manager is an object with two special methods. Python calls __enter__ when the block starts and __exit__ when it ends. This demo (enter_exit.py) prints each step, including what happens when the block raises an error:

📄 enter_exit.py

class Step:
    def __init__(self, name: str) -> None:
        self.name = name

    def __enter__(self) -> "Step":
        print(f"START {self.name}")
        return self                      # this becomes the 'as' variable

    def __exit__(self, exc_type, exc_value, traceback) -> bool:
        status = "FAILED" if exc_type else "OK"
        print(f"END   {self.name}: {status}")
        return False                     # False = let any exception continue


with Step("open login page"):
    print("  ...doing the work")

try:
    with Step("submit form"):
        raise ValueError("button not found")
except ValueError as error:
    print("Caught after cleanup:", error)

🖥️ Output

START open login page
  ...doing the work
END   open login page: OK
START submit form
END   submit form: FAILED
Caught after cleanup: button not found

Line by line:

  1. __enter__ runs first; whatever it returns is assigned to the variable after as.
  2. __exit__ always runs when the block ends, normally or through an exception. Its three parameters describe the exception (exc_type is None when there was none).
  3. Returning False from __exit__ means “do not swallow the error”, so the ValueError still reaches the except block, after the cleanup printed FAILED.

⚠️ Watch out: If __exit__ returns True, the exception is silently swallowed. In test code that hides real failures, so return False (or None) unless you truly mean to suppress an error.


🎭 Context Managers in Playwright

Starting Playwright

with sync_playwright() as p: starts the Playwright driver in __enter__ and stops it in __exit__. That is why standalone scripts in Lesson 3 never leave a Playwright process running, even when they crash.

Waiting for a new tab: expect_popup

When a click opens a new tab, you must start waiting for it before the click, otherwise the tab might open before you are listening. Playwright solves this with a context manager: the waiting starts when the block begins, and the result is available when the block ends. The practice page’s Open Google link opens in a new tab (target="_blank"). Save this as tests/test_popup.py:

📄 tests/test_popup.py

import re

from playwright.sync_api import Page, expect


def test_link_opens_new_tab(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")

    with page.expect_popup() as popup_info:              # 1. start waiting
        page.get_by_role("link", name="Open Google").click()   # 2. trigger it

    new_tab = popup_info.value                           # 3. the new Page
    new_tab.wait_for_load_state()

    print("\nNew tab URL:", new_tab.url.split("?")[0])
    expect(new_tab).to_have_url(re.compile(r"google\."))
    expect(page).to_have_url("https://sqatools.in/automation-practice-page/")
    new_tab.close()
  1. page.expect_popup() returns a context manager. Entering it starts listening for a popup.
  2. The click inside the block opens the new tab.
  3. When the block ends, Playwright waits (up to the default timeout) for the popup and stores it in popup_info.value. It is an ordinary Page, so you can use locators and assertions on it.

The original page is untouched and still on the practice page. You will learn more popup and tab techniques in Module 4.

Waiting for a download: expect_download

Downloads work the same way. To keep the example self-contained, the test builds a tiny page with a download link using page.set_content() (tests/test_download.py):

📄 tests/test_download.py

from pathlib import Path

from playwright.sync_api import Page


def test_download_report(page: Page, tmp_path: Path):
    page.set_content(
        '<a href="data:text/plain,id,name%0A1,Deepesh" download="report.csv">Download report</a>'
    )

    with page.expect_download() as download_info:        # start waiting
        page.get_by_role("link", name="Download report").click()

    download = download_info.value
    target = tmp_path / download.suggested_filename      # report.csv
    download.save_as(target)

    print("\nSaved:", target.name, "->", target.read_text().splitlines())
    assert download.suggested_filename == "report.csv"
    assert target.read_text().splitlines() == ["id,name", "1,Deepesh"]
  • page.set_content(html) replaces the page with your own HTML; handy for small, reliable demos.
  • The download attribute on the link makes the browser download the data instead of opening it. %0A is an encoded line break.
  • download.suggested_filename is the name the site proposed; save_as() copies the file where you want it.
  • tmp_path is a built-in pytest fixture that gives each test its own temporary folder, cleaned up automatically.

Other Playwright waiters follow the same pattern: page.expect_response(...), page.expect_request(...), page.expect_file_chooser(), page.expect_console_message() and context.expect_page().

Run both tests:

⌨️ Terminal

pytest -q -s tests/test_popup.py tests/test_download.py

🖥️ Output

New tab URL: https://www.google.com/
.
Saved: report.csv -> ['id,name', '1,Deepesh']
.
2 passed in 3.42s

🛠️ Write Your Own with contextlib

Writing a class with __enter__ and __exit__ works, but for most cases the contextlib.contextmanager decorator is shorter: you write a generator function with one yield. Code before yield is the setup, code after it is the cleanup. This helper times a test step (tests/test_timed_steps.py):

📄 tests/test_timed_steps.py

import time
from contextlib import contextmanager
from typing import Iterator

from playwright.sync_api import Page, expect


@contextmanager
def timed_step(name: str) -> Iterator[None]:
    start = time.perf_counter()
    print(f"\n> {name}")
    try:
        yield                                   # the body of the with block runs here
    finally:
        seconds = time.perf_counter() - start   # always runs, even if the step fails
        print(f"< {name} took {seconds:.1f}s")


def test_login_with_timed_steps(page: Page):
    with timed_step("open login page"):
        page.goto("https://www.saucedemo.com/")

    with timed_step("log in"):
        page.get_by_placeholder("Username").fill("standard_user")
        page.get_by_placeholder("Password").fill("secret_sauce")
        page.get_by_role("button", name="Login").click()

    with timed_step("check products page"):
        expect(page.locator(".title")).to_have_text("Products")
  • @contextmanager turns the generator function into a context manager.
  • Iterator[None] is the type hint for a generator that yields nothing useful.
  • try/finally around yield guarantees the cleanup line runs even when a step inside the with block raises an error. Without it, a failure would skip the timing message.
  • time.perf_counter() is a precise clock for measuring durations.

When you run it with -s, each step reports its duration (your timings will differ):

🖥️ Output

> open login page
< open login page took 0.5s

> log in
< log in took 0.1s

> check products page
< check products page took 0.1s

🔗 The Same Idea in pytest Fixtures

pytest fixtures that use yield follow exactly this setup / yield / cleanup shape:

import pytest


@pytest.fixture
def temp_user():
    user = {"name": "qa_user"}      # setup: before the test
    yield user                      # the test runs here and receives 'user'
    user.clear()                    # cleanup: after the test, even if it failed

pytest-playwright’s own page fixture works this way: it creates a context and page, yields the page to your test, and closes them afterwards. You will write many fixtures like this in Module 3.


❓ Frequently Asked Questions

🤔 Why can’t I just click and then call page.wait_for_event(“popup”)?

Because the popup may already have opened before you start waiting, and then you would wait until the timeout. Starting the wait first with with page.expect_popup() removes that race.

🤔 Can one with statement manage several context managers?

Yes: with open("a.txt") as a, open("b.txt") as b:. Both are cleaned up in reverse order when the block ends.

🤔 What is the difference between a context manager and a fixture?

A context manager wraps a block of code inside a function. A pytest fixture wraps a whole test and is requested by name. Fixtures with yield use the same setup/cleanup pattern.

🤔 Is there an async version of with?

Yes, async with, used with the async API (async with async_playwright() as p:). You will see it in the next lesson.


🏁 Summary

  • with calls __enter__ at the start and __exit__ at the end, even after an exception.
  • Playwright uses context managers to start the driver and to wait for popups, downloads, responses and more: start waiting, trigger the action, then read .value.
  • Write your own with @contextmanager plus try/finally around a single yield.
  • pytest’s yield fixtures apply the same pattern to whole tests.

Next, you will learn Python’s async/await and asyncio, the foundation of Playwright’s async API.

🎬 Watch the Playwright tutorial series on YouTube!

Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page

Python Type Hints for Playwright Tests: Page, Locator and Typed Test Data

Type hints are labels that say what kind of value a variable, parameter or return value should hold. Python does not enforce them when the code runs, but your editor and tools like mypy read them to give you accurate auto-complete and to catch mistakes before a test ever opens a browser. Playwright for Python ships complete type information, so a few hints make your test code much easier to write and maintain.

📖 What You’ll Learn: basic hints for str, int, float, bool, lists and dictionaries, optional values with str | None, function signatures, Playwright’s types (Page, Locator, BrowserContext, Response), typed test data with dataclasses and Literal, why hints are not checked at runtime, and how mypy finds bugs before you run tests.


🏷️ The Basics

A type hint goes after a colon for variables and parameters, and after -> for a function’s return value:

browser_name: str = "chromium"
retries: int = 2
timeout_seconds: float = 7.5
headless: bool = True

tags: list[str] = ["smoke", "login"]
expected_counts: dict[str, int] = {"products": 6, "cart": 0}
viewport: tuple[int, int] = (1280, 720)


def full_url(base: str, path: str) -> str:
    return base.rstrip("/") + "/" + path.lstrip("/")
  • list[str] means “a list whose items are strings”; dict[str, int] means “string keys with integer values”. Since Python 3.9 you can use the built-in list, dict and tuple directly in hints.
  • -> str documents that full_url returns a string.
  • Hints are optional. You can add them gradually, starting with function parameters, where they help most.

Values that might be missing: str | None

Many values can be “something or nothing”, such as an attribute that may not exist. Since Python 3.10 you write that with |:

def read_error(text: str | None) -> str:
    if text is None:
        return "no error shown"
    return text.strip()

str | None means “a string or None”. Older code writes the same thing as Optional[str] from the typing module. Playwright uses it everywhere: locator.get_attribute("href") returns str | None, because the attribute might not be there.


🎭 Playwright’s Own Types

Every Playwright class can be imported from playwright.sync_api and used as a hint:

TypeWhat it isWhere you meet it
PageA browser tabThe page fixture
LocatorA way to find element(s)page.get_by_role(...), page.locator(...)
BrowserContextAn isolated sessionThe context fixture
BrowserA browser processThe browser fixture
ResponseAn HTTP responsepage.goto() returns `ResponseNone`
APIRequestContextAn HTTP client for API testsModule 9

With these hints, VS Code knows exactly which methods exist: type page. and it lists goto, get_by_role, screenshot and the rest, with their parameters.


🧱 A Typed Page Object and Typed Test Data

Real projects combine hints in three places: page objects, test data and tests. First, the test data. A dataclass is a class that only holds data; Python writes its __init__ for you from the typed fields. Save this as data.py:

📄 data.py

from dataclasses import dataclass
from typing import Literal

BrowserName = Literal["chromium", "firefox", "webkit"]


@dataclass(frozen=True)
class User:
    username: str
    password: str
    can_log_in: bool = True


STANDARD_USER = User("standard_user", "secret_sauce")
LOCKED_USER = User("locked_out_user", "secret_sauce", can_log_in=False)


def is_supported(name: str) -> bool:
    supported: tuple[BrowserName, ...] = ("chromium", "firefox", "webkit")
    return name in supported
  • @dataclass(frozen=True) creates User(username, password, can_log_in) and makes instances read-only, so a test cannot accidentally change shared data.
  • can_log_in: bool = True has a default value, so most users only need two arguments.
  • Literal["chromium", "firefox", "webkit"] allows only those exact strings. BrowserName is a type alias: a short name for a longer hint.
  • tuple[BrowserName, ...] means “a tuple of any length whose items are BrowserName values”.

Next, a typed page object (pages/login_page.py):

📄 pages/login_page.py

from playwright.sync_api import Locator, Page

from data import User


class LoginPage:
    URL = "https://www.saucedemo.com/"

    def __init__(self, page: Page) -> None:
        self.page = page
        self.username: Locator = page.get_by_placeholder("Username")
        self.password: Locator = page.get_by_placeholder("Password")
        self.error: Locator = page.locator("[data-test='error']")

    def open(self) -> None:
        self.page.goto(self.URL)

    def login_as(self, user: User) -> None:
        self.username.fill(user.username)
        self.password.fill(user.password)
        self.page.get_by_role("button", name="Login").click()

    def error_text(self) -> str | None:
        if self.error.count() == 0:
            return None
        return self.error.inner_text()
  • The constructor takes a Page and stores Locator objects. -> None says __init__ and the action methods return nothing.
  • login_as(self, user: User) accepts a whole User, so a test cannot pass the username and password in the wrong order.
  • error_text() returns str | None, which forces callers to think about the “no error” case.

And the tests (tests/test_typed_login.py):

📄 tests/test_typed_login.py

from playwright.sync_api import Page, expect

from data import LOCKED_USER, STANDARD_USER, is_supported
from pages.login_page import LoginPage


def test_standard_user_logs_in(page: Page, browser_name: str) -> None:
    assert is_supported(browser_name)
    login = LoginPage(page)
    login.open()
    login.login_as(STANDARD_USER)
    expect(page.locator(".title")).to_have_text("Products")
    assert login.error_text() is None


def test_locked_user_sees_error(page: Page) -> None:
    login = LoginPage(page)
    login.open()
    login.login_as(LOCKED_USER)
    message = login.error_text()
    assert message is not None and "locked out" in message

🖥️ Output

..                                                                       [100%]
2 passed, 1 warning in 6.63s

(The one warning is the harmless pytest-playwright base_url deprecation notice explained in Lesson 2.)

Notice assert message is not None and ... in the second test: because error_text() may return None, checking it first is both correct Python and what the type checker expects.


⚠️ Hints Are Not Checked When Code Runs

Python stores hints but never enforces them at runtime. This file (runtime_demo.py) passes the wrong types, yet runs without any error:

📄 runtime_demo.py

def triple_retries(retries: int) -> int:
    return retries * 3


print(triple_retries(5))           # correct use: 15
print(triple_retries("5"))         # wrong type, but Python does not stop it
print(triple_retries.__annotations__)

🖥️ Output

15
555
{'retries': <class 'int'>, 'return': <class 'int'>}

Multiplying the string "5" by 3 repeats it, so instead of a crash you get a silently wrong value (555): exactly the kind of bug a type checker catches before you run anything. The hints are still stored, in the function’s __annotations__.


🔎 Catch Bugs Early with mypy

mypy is the most widely used Python type checker. Install it in your virtual environment and point it at your code:

⌨️ Terminal

python -m pip install mypy
python -m mypy --strict pages tests data.py

🖥️ Output

Success: no issues found in 4 source files

--strict turns on all of mypy’s optional checks, such as requiring hints on every function. Now look at a file with three typical mistakes (broken.py):

📄 broken.py

from playwright.sync_api import Page

from data import STANDARD_USER


def broken_steps(page: Page) -> None:
    page.goto(42)                                   # 1. a number instead of a URL string
    page.get_by_rol("button", name="Login")         # 2. typo in the method name
    href: str = page.locator("a").get_attribute("href")   # 3. may be None
    STANDARD_USER.password = "changed"              # 4. frozen dataclass

⌨️ Terminal

python -m mypy --strict broken.py

🖥️ Output

broken.py:7: error: Argument 1 to "goto" of "Page" has incompatible type "int"; expected "str"  [arg-type]
broken.py:8: error: "Page" has no attribute "get_by_rol"; maybe "get_by_role" or "get_by_ref"?  [attr-defined]
broken.py:9: error: Incompatible types in assignment (expression has type "str | None", variable has type "str")  [assignment]
broken.py:10: error: Property "password" defined in "User" is read-only  [misc]
Found 4 errors in 1 file (checked 1 source file)

mypy found all four bugs in under a second, without starting a browser:

  1. goto() expects a string URL.
  2. The typo get_by_rol is caught, with suggestions of similar real methods.
  3. get_attribute() can return None, so assigning it to a plain str is unsafe. Fix it with str | None and an if check.
  4. The frozen dataclass protects shared test data from being changed.

💡 Tip: VS Code’s Python extension (with its Pylance language server) shows many of these problems as red underlines while you type. Running mypy in CI makes the checks automatic for the whole team.


❓ Frequently Asked Questions

🤔 Do type hints make my tests run faster or slower?

Neither, in any noticeable way. Python stores them but does not check them at runtime.

🤔 Do I have to add hints everywhere?

No. Start with function parameters and return types, especially page: Page in tests and page objects. Add more as the project grows.

🤔 What is the difference between str | None and Optional[str]?

They mean the same thing. str | None is the modern syntax (Python 3.10+, which Playwright requires anyway); Optional[str] is the older form you will still see in many projects.

🤔 Should test data be a dict or a dataclass?

A dataclass gives you named, typed fields, auto-complete and protection from typos. A dict is fine for quick, loosely structured data such as JSON you load and pass straight through.


🏁 Summary

  • Hints use name: type and -> return_type; collections use list[str], dict[str, int]; missing values use str | None.
  • Import Playwright types (Page, Locator, BrowserContext, Response) from playwright.sync_api.
  • Dataclasses and Literal give typed, read-only test data.
  • Python never enforces hints at runtime; mypy and your editor do, catching bugs before tests run.

Next, you will learn the with statement and context managers, which Playwright uses for starting browsers, waiting for new tabs and downloads, and which pytest fixtures are built on.

🎬 Watch the Playwright tutorial series on YouTube!

Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page

Python Virtual Environments and pip for Playwright Test Projects

In Lesson 2 you created a virtual environment and installed pytest-playwright with one command. Now it is time to understand what really happens, because most “it works on my machine” problems in Python test projects come from packages and environments. You will learn how a virtual environment works, the pip commands you will use every week, how to pin versions for a team and CI, and how to diagnose the most common errors.

📖 What You’ll Learn: what a virtual environment contains, how to confirm which Python is running, the essential pip commands (install, show, list, uninstall, check, index versions), version specifiers, requirements.txt and a separate dev requirements file, upgrading Playwright safely, and fixing common environment errors.


🧠 What Is Inside a Virtual Environment?

A virtual environment is just a folder (we call it .venv) that contains:

  • a small copy of (or link to) the Python interpreter, in .venv\Scripts on Windows or .venv/bin on macOS and Linux;
  • its own site-packages folder (.venv\Lib\site-packages), where pip installs packages;
  • a pyvenv.cfg file that records which Python it was created from.

Activating the environment only changes your terminal’s PATH, so that typing python, pip, pytest or playwright runs the copies inside .venv. You can also skip activation and call the environment’s Python directly, which is handy in scripts and CI:

⌨️ Terminal

.venv\Scripts\python -m pytest

📝 Note: The browsers are not inside the virtual environment. playwright install puts them in a shared per-user cache (for example %USERPROFILE%\AppData\Local\ms-playwright on Windows), so several environments that use the same Playwright version share one download.


🔍 Which Python Am I Running?

Half of all environment problems come from running a different Python than you think. Ask Python itself:

⌨️ Terminal

python -c "import sys; print(sys.prefix); print(sys.executable)"

Inside a freshly created .venv in C:\pwv\exp9, the output is:

🖥️ Output

C:\pwv\exp9\.venv
C:\pwv\exp9\.venv\Scripts\python.exe
  • sys.prefix is the root of the active environment. If it points at your project’s .venv, you are in the right place.
  • sys.executable is the full path of the running interpreter.
  • python -c "..." runs a one-line program without creating a file.

The same check works from inside a test, which is useful when CI behaves differently from your laptop. Save it as check_env.py:

📄 check_env.py

import sys
from importlib.metadata import version

print("Python:", sys.version.split()[0])
print("Inside a virtual environment:", sys.prefix != sys.base_prefix)
print("playwright:", version("playwright"))
print("pytest-playwright:", version("pytest-playwright"))
  • sys.base_prefix is the Python the environment was created from. When it differs from sys.prefix, you are inside a virtual environment.
  • importlib.metadata.version("name") reads the installed version of any package.

🖥️ Output

Python: 3.15.1
Inside a virtual environment: True
playwright: 1.64.1
pytest-playwright: 0.10.0

🧰 The pip Commands You Will Actually Use

Always run pip as python -m pip .... It guarantees that the pip belongs to the Python you mean.

CommandWhat it does
python -m pip install pytest-playwrightInstall the latest version and its dependencies
python -m pip install "pytest-playwright==0.10.0"Install an exact version
python -m pip install --upgrade playwrightUpgrade to the newest version
python -m pip install -r requirements.txtInstall everything listed in a file
python -m pip listList installed packages and versions
python -m pip show playwrightDetails of one package, including what it requires
python -m pip uninstall -y greenletRemove a package without asking for confirmation
python -m pip checkVerify that every package’s dependencies are installed and compatible
python -m pip index versions pytest-playwrightList the versions available on PyPI
python -m pip freezePrint installed packages as name==version lines

A brand-new environment contains only pip itself:

🖥️ Output

Package Version
------- -------
pip     26.2.2

After installing, pip show explains how packages depend on each other:

🖥️ Output

Name: playwright
Version: 1.64.1
Summary: A high-level API to automate web browsers
Home-page: https://github.com/Microsoft/playwright-python
Author: Microsoft Corporation
License-Expression: Apache-2.0
Location: C:\pwv\exp9\.venv\Lib\site-packages
Requires: greenlet, pyee
Required-by: pytest-playwright
  • Requires lists what playwright needs (greenlet and pyee).
  • Required-by shows that pytest-playwright depends on it.

pip check is a quick health check. In a healthy environment it prints No broken requirements found.. To see it catch a problem, we uninstalled greenlet on purpose:

🖥️ Output

playwright 1.64.1 requires greenlet, which is not installed.

Reinstalling from requirements.txt repaired the environment, and pip check was happy again.


📌 Version Specifiers and requirements.txt

A requirements file lists one package per line, with an optional version rule:

LineMeaning
pytest-playwrightAny version (whatever is newest today)
pytest-playwright==0.10.0Exactly this version
playwright>=1.64,<2At least 1.64 but below 2.0
pytest~=9.2Compatible release: 9.2 or newer, but below 10

For test projects, exact pins (==) are the safest choice: everybody, including CI, runs exactly the versions you tested with. Create requirements.txt:

📄 requirements.txt

pytest-playwright==0.10.0
playwright==1.64.1
pytest==9.2.0

Pin the main packages yourself, or generate a complete list (including dependencies such as greenlet) with python -m pip freeze > requirements.txt. Then anyone can recreate the environment:

⌨️ Terminal

python -m venv .venv
.venv\Scripts\python -m pip install -r requirements.txt
.venv\Scripts\playwright install

Separate runtime and development requirements

Larger projects keep tools that only developers need (linters, formatters, type checkers) in a second file that includes the first one:

🖥️ Output

# requirements-dev.txt
-r requirements.txt
mypy
ruff

The line -r requirements.txt pulls in the main file, so developers run pip install -r requirements-dev.txt while CI can install only requirements.txt. Pin the dev tools with == too, using the versions your team has agreed on.


⬆️ Upgrading Playwright Safely

  1. Check what is available: python -m pip index versions playwright.
  2. Upgrade in your environment: python -m pip install --upgrade playwright pytest-playwright.
  3. Download the matching browsers: playwright install (each Playwright version needs its own browser builds).
  4. Run the whole suite. Read the Playwright release notes for behaviour changes.
  5. Update the pinned versions in requirements.txt and commit them, so CI upgrades in the same step.

🩺 Common Errors and Fixes

SymptomLikely causeFix
ModuleNotFoundError: No module named 'playwright'Running a Python without Playwright (environment not active)Activate .venv, or run .venv\Scripts\python -m pytest
pytest runs, but plugins or options are missingThe pytest command belongs to another PythonUse python -m pytest; check sys.executable
“Executable doesn’t exist” when a browser launchesBrowsers not downloaded for this Playwright versionRun playwright install
Works locally, fails in CI after a new releaseUnpinned versionsPin versions in requirements.txt
Activation blocked in PowerShellScript execution policySet-ExecutionPolicy -Scope CurrentUser RemoteSigned

❓ Frequently Asked Questions

🤔 Can I move or rename a virtual environment folder?

Not reliably: the scripts inside contain absolute paths. Delete it and create a new one; with a requirements file that takes a minute.

🤔 Should I use venv, conda, poetry or uv?

All of them create isolated environments. This course uses the built-in venv and pip because they come with Python and work everywhere. If your team already uses another tool, the Playwright steps are the same.

🤔 Does each project need its own browsers?

No. Browsers live in a shared cache per Playwright version, so projects on the same version reuse them.

🤔 Why python -m pip instead of pip?

python -m pip always runs the pip of that exact Python. A bare pip might belong to a different installation on your PATH.


🏁 Summary

  • A virtual environment is a folder with its own interpreter and site-packages; activation only changes PATH.
  • Check sys.prefix and sys.executable whenever imports behave strangely.
  • Use python -m pip for install, show, list, check and index versions.
  • Pin exact versions in requirements.txt, keep dev tools in requirements-dev.txt, and re-run playwright install after every upgrade.

Next, you will add type hints to your test code so your editor can auto-complete Playwright methods and catch mistakes before you run anything.

🎬 Watch the Playwright tutorial series on YouTube!

Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page

Python Modules, Packages and Imports for Playwright Test Projects

As soon as a test project grows beyond one file, you split code into modules: page objects in one place, test data in another, helpers in a third. Python’s import system connects them. In this lesson you will learn how modules and packages work, which import style to use, how to lay out a Playwright project, and how to fix the ModuleNotFoundError that almost every pytest beginner meets.

📖 What You’ll Learn: what a module and a package are, import styles (import, from … import, aliases), what __init__.py does, the if __name__ == “__main__” guard, a clean Playwright project layout, why plain pytest cannot find your pages package, and two reliable fixes (a root conftest.py or pythonpath in pytest.ini).


📦 Modules: One File, One Purpose

A module is simply a .py file. Everything defined in it (variables, functions, classes) can be imported into another file. Create utils/helpers.py:

📄 utils/helpers.py

"""Small helpers shared by many tests."""

BASE_URL = "https://www.saucedemo.com/"


def full_url(path: str) -> str:
    """Join the base URL and a path such as 'inventory.html'."""
    return BASE_URL + path.lstrip("/")


def price_to_float(text: str) -> float:
    """Convert '$29.99' to 29.99."""
    return float(text.replace("$", "").strip())


if __name__ == "__main__":
    # Runs only when this file is executed directly, not when it is imported
    print(full_url("/inventory.html"))
    print(price_to_float("$29.99"))
  • BASE_URL, full_url and price_to_float are now reusable from any test.
  • The text in triple quotes right under a def is a docstring; editors show it when you hover over the function.
  • path.lstrip("/") removes a leading slash so the URL never gets a double slash.

The if __name__ == "__main__" guard

Every module has a built-in variable __name__. When you run a file directly, Python sets it to "__main__"; when the file is imported, it is set to the module’s name (here "utils.helpers"). The guard lets a file contain quick self-checks that never run during an import:

⌨️ Terminal

python -m utils.helpers

🖥️ Output

https://www.saucedemo.com/inventory.html
29.99

python -m utils.helpers runs the module by its import name from the project folder, which is the most reliable way to run a module that lives inside a package.

A second small example shows the two values side by side (main_guard.py):

📄 main_guard.py

import utils.helpers

print("Imported module name:", utils.helpers.__name__)
print("This file's name:", __name__)

🖥️ Output

Imported module name: utils.helpers
This file's name: __main__

📥 Import Styles

There are three common ways to import, and each has its place:

import json                                   # 1. whole module
from utils.helpers import full_url, BASE_URL  # 2. specific names
from playwright.sync_api import Page, expect  # 2. the style you use in every test
import utils.helpers as h                     # 3. module with a short alias
  1. import json brings in the module; you write json.loads(...). This makes it obvious where each name comes from.
  2. from module import name brings names straight into your file; you write full_url(...). Use it for things you use often, such as Page and expect.
  3. as h gives a long module name a short alias.

⚠️ Watch out: Avoid from module import *. It imports every public name, hides where things come from, and can silently overwrite your own names.

Python looks for modules in a list of folders called sys.path: the folder of the script you run (or the current folder with python -m), then the virtual environment’s installed packages, then the standard library. If the folder that contains your package is not on that list, the import fails with ModuleNotFoundError.


🗂️ Packages: Folders of Modules

A package is a folder of modules. Traditionally it contains an __init__.py file, which can be empty. A typical Playwright project looks like this:

🖥️ Output

playwright-python-demo/
├── pytest.ini
├── conftest.py
├── pages/                  # package: page objects
│   ├── __init__.py
│   └── login_page.py
├── utils/                  # package: helpers and test data loaders
│   ├── __init__.py
│   └── helpers.py
└── tests/                  # test files (no imports from here)
    └── test_login.py
  • pages and utils are packages, so you import from them with dots: from pages.login_page import LoginPage.
  • __init__.py marks the folder as a regular package. It runs once when the package is first imported, so it is also a place for package-wide setup (most of the time it stays empty).
  • Since Python 3.3, a folder without __init__.py still works as a *namespace package*. Keeping an empty __init__.py is clearer and avoids surprises with tools, so this course always adds one.

Here is the page object and a test that uses it (pages/login_page.py and tests/test_login.py):

📄 pages/login_page.py

from playwright.sync_api import Page

from utils.helpers import full_url


class LoginPage:
    """Actions for the Sauce Demo login page."""

    def __init__(self, page: Page):
        self.page = page
        self.username = page.get_by_placeholder("Username")
        self.password = page.get_by_placeholder("Password")
        self.login_button = page.get_by_role("button", name="Login")

    def open(self) -> None:
        self.page.goto(full_url(""))

    def login(self, user: str, password: str) -> None:
        self.username.fill(user)
        self.password.fill(password)
        self.login_button.click()

📄 tests/test_login.py

import re

from playwright.sync_api import Page, expect

from pages.login_page import LoginPage
from utils.helpers import full_url, price_to_float


def test_login_with_page_object(page: Page):
    login = LoginPage(page)
    login.open()
    login.login("standard_user", "secret_sauce")
    expect(page).to_have_url(full_url("inventory.html"))


def test_backpack_price(page: Page):
    LoginPage(page).open()
    LoginPage(page).login("standard_user", "secret_sauce")
    price_text = page.locator("[data-test='inventory-item-price']").first.inner_text()
    assert price_to_float(price_text) == 29.99
    expect(page.locator(".title")).to_have_text(re.compile("Products"))
  • Imports are grouped in the order recommended by Python’s style guide (PEP 8): standard library (re), third-party packages (playwright), then your own project (pages, utils), with a blank line between groups.
  • The page object imports a helper from utils, and the test imports from both packages.

🚨 The Classic Error: ModuleNotFoundError: No module named ‘pages’

With exactly the layout above but without conftest.py and pytest.ini, running the pytest command fails during collection:

🖥️ Output

ImportError while importing test module 'C:\pwv\exp8\proj\tests\test_login.py'.
E   ModuleNotFoundError: No module named 'pages'
!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.12s

Why? By default pytest adds the test file’s own folder (tests/) to sys.path, not the project root. The pages folder is in the root, so Python cannot see it.

Interestingly, python -m pytest works in the same situation, because running Python with -m also puts the current folder on sys.path. That difference confuses many people: a suite passes on one machine and fails on another depending on how it was started. Use one of these fixes so both commands always work:

Fix 1 (recommended): tell pytest where your code lives. Add pythonpath to pytest.ini:

📄 pytest.ini

[pytest]
pythonpath = .
testpaths = tests

pythonpath = . adds the folder that contains pytest.ini (the project root) to sys.path before tests are imported. It is explicit and works no matter how pytest is started.

**Fix 2: put a conftest.py in the project root.** pytest adds the folder of each conftest.py to sys.path, so a root conftest.py (even an empty one) also fixes the import. You will add fixtures there anyway:

📄 conftest.py

# Shared fixtures go here (see the pytest fixtures lessons in Module 3)

We tested all the combinations with pytest 9:

Project setuppytestpython -m pytest
No conftest.py, no pythonpathModuleNotFoundErrorPasses
Empty conftest.py in the rootPassesPasses
pythonpath = . in pytest.iniPassesPasses

With the fixes in place, the tests pass:

⌨️ Terminal

pytest -q

🖥️ Output

..                                                                       [100%]
2 passed in 3.35s

💡 Tip: Never add sys.path.append(...) lines at the top of test files to fix imports. They break as soon as files move. pythonpath = . in one place is the clean solution.


↔️ Relative Imports

Inside a package, a module can import a sibling with a leading dot, for example from .login_page import LoginPage inside pages/__init__.py. Relative imports only work for modules that are imported as part of a package, not for files run directly as scripts. In test projects, absolute imports (from pages.login_page import LoginPage) are simpler and easier to read, so prefer them.


⚠️ Common Mistakes

  • Naming a file after a library. A file called playwright.py or pytest.py in your project shadows the real package, because the script’s own folder comes first on sys.path. You then get errors like ModuleNotFoundError: No module named 'playwright.sync_api'; 'playwright' is not a package. Never reuse library names for your files.
  • Circular imports. If a.py imports b.py and b.py imports a.py, one of them sees a half-loaded module. Move shared code into a third module.
  • Importing from test files. Tests should import from pages and utils, never from other test_*.py files. Shared things belong in packages or conftest.py.

❓ Frequently Asked Questions

🤔 Do I need __init__.py in the tests folder?

No. pytest collects tests without it. Some teams add it to allow test files with the same name in different folders; if you do, pytest uses the package name to tell them apart.

🤔 What is the difference between a module and a package?

A module is one .py file. A package is a folder of modules (usually with an __init__.py), imported with dotted names such as pages.login_page.

🤔 Why does my import work in VS Code but fail in the terminal?

VS Code’s Testing view may start pytest differently (for example from a different folder). Adding pythonpath = . to pytest.ini makes the result the same everywhere.

🤔 Where should test data files live?

In their own folder, such as test_data/, loaded by helper functions in utils. You will do this in Module 7.


🏁 Summary

  • A module is a .py file; a package is a folder of modules, usually with __init__.py.
  • Prefer import module or from package.module import Name; avoid import * and sys.path hacks.
  • Plain pytest adds the test file’s folder to sys.path, so from pages... import fails without help.
  • Fix it with pythonpath = . in pytest.ini (or a root conftest.py), and keep library names out of your file names.

Next, you will take a closer look at virtual environments and pip, so every machine runs your tests with exactly the same packages.

🎬 Watch the Playwright tutorial series on YouTube!

Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page

Playwright Codegen for Python: Record pytest Tests and Pick Locators

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:

  1. A browser showing the URL. Everything you do here is recorded.
  2. 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 re is always added, so you can use regular expressions in assertions. Page and expect are imported for the test.
  • def test_example(page: Page) -> None: is a normal pytest test. The -> None type 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-test attributes, 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 than click(): 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:

ToolWhat it does
RecordStart or pause recording actions
Pick locatorHover over any element to see the locator Codegen would use; click to copy it into the Inspector
Assert visibilityClick an element to add expect(...).to_be_visible()
Assert textClick an element to add expect(...).to_contain_text(...), or to_have_text(...) when you keep the whole text
Assert valueClick an input to add expect(...).to_have_value(...)
Assert snapshotClick 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:

  1. Rename test_example to describe the behaviour.
  2. Delete the unnecessary click() before each fill().
  3. Add assertions after each important step (the recorder’s assert tools help).
  4. 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_checked and 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.json saves cookies and local storage when you close the browser; --load-storage auth.json starts the next session with them, so you skip the login steps.
  • Other useful options: -b firefox or -b webkit to record in another engine, --ignore-https-errors for test servers with self-signed certificates, and --save-har to 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 URL records 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-storage and page.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

Configure Playwright Python Tests: pytest.ini, pyproject.toml and conftest.py

Playwright with TypeScript has a single playwright.config.ts file. In Python, configuration is split between two places that every pytest user knows: a settings file (pytest.ini or pyproject.toml) for default command-line options, and conftest.py for Python code that customises the browser, context and timeouts. In this lesson you will build a complete, realistic configuration step by step and verify each setting with a test.

📖 What You’ll Learn: where pytest looks for configuration, addopts, testpaths and base_url in pytest.ini and pyproject.toml, how addopts combines with command-line options, overriding browser_context_args and browser_type_launch_args in conftest.py, per-test context settings with a marker, and setting default timeouts for actions and assertions.


🗺️ Where Configuration Lives

What you want to configureWhere it goesExample
Default command-line optionspytest.ini or pyproject.toml (addopts)--browser chromium --tracing retain-on-failure
Where tests live, base URLpytest.ini or pyproject.tomltestpaths = tests, base_url = https://...
Browser launch settingsconftest.py fixture browser_type_launch_argsextra Chromium flags
Context settings (viewport, locale, timezone, HTTPS errors)conftest.py fixture browser_context_args{"viewport": {"width": 1440, "height": 900}}
Timeoutsconftest.pyexpect.set_options(timeout=10_000)

pytest finds its settings file by looking in the folder you run it from and then in parent folders. Put the settings file and conftest.py in your project’s root folder, next to the tests folder.


📄 Option 1: pytest.ini

Create pytest.ini in the project root:

📄 pytest.ini

[pytest]
# Look for tests only in the tests folder
testpaths = tests

# Options added to every pytest run
addopts = -v --tracing retain-on-failure --screenshot only-on-failure

# Relative URLs in page.goto() are resolved against this
base_url = https://sqatools.in
  • [pytest] is the required section header.
  • testpaths = tests makes a bare pytest command search only the tests folder, which is faster and avoids collecting stray files.
  • addopts is added in front of whatever you type, so every run now uses verbose output and keeps traces and screenshots of failures.
  • base_url lets tests call page.goto("/automation-practice-page/"). A --base-url on the command line takes priority over this value.
  • Lines starting with # are comments.

📄 Option 2: pyproject.toml

Many Python projects keep all tool settings in pyproject.toml. The same configuration looks like this:

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tracing retain-on-failure --screenshot only-on-failure"
base_url = "https://sqatools.in"
  • The section is [tool.pytest.ini_options], and values follow TOML rules: strings in quotes, lists in square brackets.
  • pytest 9 also accepts a native [tool.pytest] table, where addopts is a list: addopts = ["-v", "--tracing", "retain-on-failure"]. The ini_options form works on every pytest version, so it is the safer choice for shared projects.

⚠️ Watch out: Use only one settings file. If both pytest.ini and a [tool.pytest.ini_options] section exist, pytest.ini wins and the pyproject settings are ignored. The only sign is a short note in the run header: configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!). Check the configfile: line whenever settings seem to have no effect.


⚖️ How addopts Combines with the Command Line

addopts does not replace what you type; it is added before it. For options that take one value, the last one wins, so the command line overrides the file. But --browser can be repeated, so the values add up. To see it, imagine addopts = --browser firefox in pytest.ini and this small test:

def test_which_browser(page, browser_name):
    print("RUN", browser_name)

⌨️ Terminal

pytest -q -s --browser webkit

🖥️ Output

RUN firefox
RUN webkit
2 passed in 2.04s

The test ran in both Firefox (from the file) and WebKit (from the command line). If you want to switch browsers freely from the command line, do not put --browser in addopts; Chromium is already the default.


🐍 conftest.py: Python-Powered Configuration

conftest.py is a special pytest file. Fixtures defined in it are available to every test in its folder and sub-folders without importing anything. pytest-playwright builds its browser, context and page fixtures from a few settings fixtures, and you customise them by redefining those settings fixtures in conftest.py.

Create conftest.py in the project root:

📄 conftest.py

import pytest
from playwright.sync_api import Page, expect


@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
    """Settings applied to every new browser context (and so every page)."""
    return {
        **browser_context_args,                        # keep the plugin's own settings
        "viewport": {"width": 1440, "height": 900},    # larger desktop window
        "locale": "en-GB",                             # browser language
        "timezone_id": "Europe/London",                # browser time zone
        "ignore_https_errors": True,                   # accept self-signed test certificates
    }


@pytest.fixture(scope="session")
def browser_type_launch_args(browser_type_launch_args):
    """Settings used when the browser process is launched."""
    return {
        **browser_type_launch_args,
        "slow_mo": 0,                                  # keep 0 in CI; raise it to watch tests
    }


@pytest.fixture(autouse=True)
def default_timeouts(page: Page):
    """Runs before every test: make actions and navigations time out after 15 s."""
    page.set_default_timeout(15_000)
    page.set_default_navigation_timeout(30_000)


# Web-first assertions (expect) wait 5 s by default; give slow pages 10 s
expect.set_options(timeout=10_000)

Line by line:

  1. **browser_context_args** is a session-scoped fixture provided by pytest-playwright. Your version receives the original as a parameter (also named browser_context_args), and returns a new dictionary that starts with **browser_context_args so the plugin’s own settings (such as the --base-url and --device values) are kept, then adds yours.
  2. The keys are the same as the parameters of browser.new_context(): viewport, locale, timezone_id, ignore_https_errors, user_agent, color_scheme, geolocation, permissions, storage_state, and more.
  3. **scope="session"** must match the plugin’s fixture, because the settings are calculated once for the whole run.
  4. **browser_type_launch_args** works the same way for launch() options such as slow_mo, args (extra browser command-line flags) or downloads_path.
  5. **autouse=True** runs the default_timeouts fixture for every test automatically. It asks for page, so it changes the timeouts of the very page your test receives.
  6. **expect.set_options(timeout=10_000)** changes the default wait for every expect() assertion. Because conftest.py is imported before the tests run, it applies everywhere.

💡 Tip: 15_000 is the same number as 15000. Python ignores the underscores; they just make large numbers easier to read.


✅ Prove the Configuration Works

Create tests/test_config.py:

📄 tests/test_config.py

from playwright.sync_api import Page, expect


def test_context_settings_are_applied(page: Page):
    page.goto("/automation-practice-page/")    # relative URL thanks to base_url

    assert page.viewport_size == {"width": 1440, "height": 900}
    assert page.evaluate("navigator.language") == "en-GB"
    assert page.evaluate("Intl.DateTimeFormat().resolvedOptions().timeZone") == "Europe/London"


def test_base_url_is_used(page: Page):
    page.goto("/automation-practice-page/")
    expect(page).to_have_url("https://sqatools.in/automation-practice-page/")

Run pytest with no arguments:

⌨️ Terminal

pytest

🖥️ Output

configfile: pytest.ini
testpaths: tests
plugins: playwright-0.10.0
collecting ... collected 3 items

tests/test_config.py::test_context_settings_are_applied[chromium] PASSED [ 33%]
tests/test_config.py::test_base_url_is_used[chromium] PASSED             [ 66%]
tests/test_markers.py::test_dark_mode_for_one_test[chromium] PASSED      [100%]
  • configfile: pytest.ini and testpaths: tests confirm which settings file pytest used.
  • The -v style output appears without typing -v, because it is in addopts.
  • The plugins line on your machine may list different plugins; that is fine.

The same tests also pass in Firefox, which shows the settings are browser-independent:

⌨️ Terminal

pytest --browser firefox

🎯 Different Settings for One Test

Sometimes one test needs something special, such as dark mode or a different language. pytest-playwright provides a marker for that. Create tests/test_markers.py:

📄 tests/test_markers.py

import pytest
from playwright.sync_api import Page


@pytest.mark.browser_context_args(color_scheme="dark", locale="fr-FR")
def test_dark_mode_for_one_test(page: Page):
    page.goto("/automation-practice-page/")

    assert page.evaluate("matchMedia('(prefers-color-scheme: dark)').matches") is True
    assert page.evaluate("navigator.language") == "fr-FR"
  • @pytest.mark.browser_context_args(...) adds or overrides context settings for this test only.
  • Settings from conftest.py that the marker does not mention (here the 1440×900 viewport) still apply.
  • matchMedia(...) is a browser API; the test asks the page whether it is in dark mode.

🗂️ The Project Structure

🖥️ Output

playwright-python-demo/
├── .venv/
├── conftest.py          # fixtures: context args, launch args, timeouts
├── pytest.ini           # defaults: testpaths, addopts, base_url
├── requirements.txt
└── tests/
    ├── test_config.py
    └── test_markers.py

🔁 TypeScript Config vs Python Config

If you know Playwright with TypeScript, this table maps the familiar playwright.config.ts settings to Python:

playwright.config.tsPython equivalent
testDir: './tests'testpaths = tests
use: { baseURL }base_url in pytest.ini, or --base-url
use: { viewport, locale, timezoneId }browser_context_args fixture in conftest.py
use: { trace: 'retain-on-failure' }--tracing retain-on-failure in addopts
expect: { timeout: 10000 }expect.set_options(timeout=10_000)
use: { actionTimeout }page.set_default_timeout() in an autouse fixture
projects: [...]--browser (repeatable) or --device
retries: 2pytest-rerunfailures plugin: --reruns 2
workers: 4pytest-xdist plugin: -n 4

❓ Frequently Asked Questions

🤔 Can I have more than one conftest.py?

Yes. A conftest.py in a sub-folder adds or overrides fixtures for the tests in that folder only. The root conftest.py applies to everything. You will use this in Sharing Fixtures with conftest.py.

🤔 Why does my browser_context_args override seem to do nothing?

The most common causes are a missing scope="session", forgetting to return the dictionary, or a key misspelled (for example timezone instead of timezone_id). Run a test that prints page.viewport_size or navigator.language to confirm.

🤔 Where do I set the browser headless mode in config?

Headless is the default. Add --headed to addopts if you want windows on your own machine, but do not commit that, because CI servers have no screen.

🤔 Should I put secrets like passwords in pytest.ini?

No. Keep secrets in environment variables or a .env file that is not committed. You will learn this in Environment Variables and .env Files.


🏁 Summary

  • pytest.ini or pyproject.toml holds testpaths, addopts and base_url; use only one settings file.
  • addopts is added before command-line options, and repeatable options like --browser add up.
  • Override browser_context_args and browser_type_launch_args in conftest.py (session scope, keep the original with **).
  • Use an autouse fixture for action timeouts, expect.set_options() for assertion timeouts, and the browser_context_args marker for one-off settings.

Next, you will let Playwright write tests for you with Codegen, and learn how to turn recorded code into clean pytest tests.

🎬 Watch the Playwright tutorial series on YouTube!

Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page

Run Playwright Python Tests: pytest and pytest-playwright CLI Options Explained

You can change how your whole test suite runs without touching a single line of test code. pytest-playwright adds options to the pytest command for picking browsers, watching tests run, emulating phones and capturing screenshots, videos and traces when something fails. In this lesson you will try each option on a small test file and see exactly what it produces.

📖 What You’ll Learn: running tests in Chromium, Firefox and WebKit, using installed Chrome or Edge, headed mode and slow motion, device emulation, capturing screenshots, videos and traces only on failure, the test-results folder, –base-url, and the core pytest flags for selecting and reporting tests.


🧪 The Test File for This Lesson

Create tests/test_cli.py. It has one passing test, one test that fails on purpose (so you can see failure artifacts), one test that prints the viewport size, and one test that uses a relative URL:

📄 tests/test_cli.py

from playwright.sync_api import Page, expect


def test_practice_title(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")
    expect(page).to_have_title("Automation Practice Page - SQA Tools")


def test_broken_heading(page: Page):
    # Fails on purpose: the real heading text is "Text Fields"
    page.goto("https://sqatools.in/automation-practice-page/")
    heading = page.get_by_role("heading", name="Text Fields")
    expect(heading).to_have_text("Text Boxes", timeout=1000)


def test_viewport(page: Page, browser_name: str):
    size = page.viewport_size
    is_mobile = "Mobile" in page.evaluate("navigator.userAgent")
    print(f"\n{browser_name}: viewport {size['width']}x{size['height']}, mobile user agent: {is_mobile}")


def test_relative_url(page: Page):
    # Works only when a base URL is set (see --base-url below)
    page.goto("/automation-practice-page/")
    expect(page).to_have_url("https://sqatools.in/automation-practice-page/")
  • browser_name is another pytest-playwright fixture: a string such as "chromium". Asking for it is as simple as adding a parameter.
  • page.viewport_size returns a dictionary like {'width': 1280, 'height': 720}.
  • timeout=1000 makes the failing assertion give up after 1 second instead of the default 5, so the demo is quick.

🌐 Choose the Browser: –browser and –browser-channel

Without options, tests run in Chromium. Pick another engine with --browser:

⌨️ Terminal

pytest --browser firefox
pytest --browser webkit

Repeat the option to run every test in several browsers in one go:

⌨️ Terminal

pytest --browser chromium --browser firefox --browser webkit -k title

🖥️ Output

...                                                                      [100%]
3 passed, 9 deselected in 9.37s

The single test_practice_title test ran three times, once per browser (that is what the three dots mean). With -v you would see the suffixes [chromium], [firefox] and [webkit].

To run Google Chrome or Microsoft Edge as installed on your machine (instead of Playwright’s own Chromium build), use a channel:

⌨️ Terminal

pytest --browser-channel chrome
pytest --browser-channel msedge

💡 Tip: Testing in branded Chrome or Edge is useful when you must match exactly what your users run. For everyday runs, Playwright’s bundled Chromium is faster to set up and identical across machines.


👀 Watch Tests Run: –headed and –slowmo

⌨️ Terminal

pytest --headed
pytest --headed --slowmo 1000
  • --headed opens visible browser windows. By default tests are headless.
  • --slowmo 1000 waits 1000 milliseconds after every Playwright action, so you can follow each step. Never leave it on in CI: it makes every test much slower.

📱 Emulate a Device: –device

--device applies one of Playwright’s built-in device profiles (screen size, user agent, touch support, pixel ratio) to every test:

⌨️ Terminal

pytest -s --device "iPhone 13" -k viewport

🖥️ Output

chromium: viewport 390x664, mobile user agent: True
1 passed, 3 deselected in 0.49s

Without the option, the same test prints the default desktop viewport:

⌨️ Terminal

pytest -s -k viewport

🖥️ Output

chromium: viewport 1280x720, mobile user agent: False
  • Device names must match Playwright’s list exactly, including capitals and spaces, so put them in quotes. Popular ones include "iPhone 13", "Pixel 7" and "iPad Pro 11".
  • The browser engine is still chosen by --browser (Chromium by default). For the most realistic iPhone emulation, combine --browser webkit --device "iPhone 13".
  • -s turns off pytest’s output capturing so that print() output appears in the terminal.

You will go deeper in Mobile Device Emulation.


📸 Capture Evidence: –screenshot, –video and –tracing

When a test fails in CI, you need evidence. These options record it automatically:

OptionValuesWhat you get
--screenshoton, off, only-on-failureA PNG taken at the end of each test
--full-page-screenshotflagMake those screenshots capture the full scrolling page (use with --screenshot)
--videoon, off, retain-on-failureA .webm recording of each test
--tracingon, off, retain-on-failureA trace.zip you can replay step by step in the Trace Viewer
--outputfolder pathWhere all of these go (default test-results)

The most useful combination keeps evidence only for failed tests:

⌨️ Terminal

pytest -k "not relative" --screenshot only-on-failure --video retain-on-failure --tracing retain-on-failure

🖥️ Output

FAILED tests/test_cli.py::test_broken_heading[chromium] - AssertionError: Loc...
1 failed, 2 passed, 1 deselected in 6.12s

(-k "not relative" skips the relative-URL test for now, because it needs the --base-url option shown later in this lesson.)

The test-results folder now holds one sub-folder, for the failed test only:

🖥️ Output

test-results/
└── tests-test-cli-py-test-broken-heading-chromium/
    ├── test-failed-1.png
    ├── trace.zip
    └── video.webm
  • The folder name is built from the file name, the test name and the browser, so failures in different browsers never overwrite each other.
  • Passing tests left nothing behind, which keeps CI artifacts small.
  • Open the trace with playwright show-trace test-results/tests-test-cli-py-test-broken-heading-chromium/trace.zip. You will learn the Trace Viewer in Module 11.

📝 Note: pytest-playwright clears the output folder at the start of each run, so old artifacts never get mixed with new ones.


🔗 Shorter URLs: –base-url

Typing the full URL in every test is repetitive, and it makes it hard to run the same tests against a staging server. Set a base URL once and use relative paths:

⌨️ Terminal

pytest --base-url https://sqatools.in -k relative

🖥️ Output

.                                                                        [100%]
1 passed, 3 deselected in 1.29s

page.goto("/automation-practice-page/") was resolved against the base URL. In pytest-playwright 0.10 the --base-url option works out of the box. You can also set it with the PYTEST_BASE_URL environment variable or a base_url entry in pytest.ini, which you will do in the next lesson.


🎯 Core pytest Options You Will Use Every Day

These come from pytest itself and combine freely with the Playwright options:

OptionMeaning
-v / -qMore / less output
-k "expression"Run tests whose names match, e.g. -k "login and not locked"
-m markerRun tests with a marker, e.g. -m smoke (Module 3)
-xStop after the first failure
--maxfail=3Stop after three failures
-sShow print() output
--lfRe-run only the tests that failed last time
--collect-onlyList the tests without running them
--durations=5Show the five slowest tests

For example, --collect-only -q lists every test with its browser suffix:

⌨️ Terminal

pytest --collect-only -q

🖥️ Output

tests/test_cli.py::test_practice_title[chromium]
tests/test_cli.py::test_broken_heading[chromium]
tests/test_cli.py::test_viewport[chromium]
tests/test_cli.py::test_relative_url[chromium]

4 tests collected in 0.00s

🧾 Quick Reference

⌨️ Terminal

# All browsers, verbose
pytest --browser chromium --browser firefox --browser webkit -v

# Debug one test visually
pytest tests/test_login.py::test_valid_login_shows_products --headed --slowmo 500

# CI-style run with evidence on failure
pytest --tracing retain-on-failure --screenshot only-on-failure --video retain-on-failure

# Same tests against another environment
pytest --base-url https://staging.example.com

To see every option on your machine, run pytest --help and look for the Playwright section.


❓ Frequently Asked Questions

🤔 How do I avoid typing the same options every time?

Put them in the addopts setting of pytest.ini or pyproject.toml. That is the first thing you will do in the next lesson.

🤔 Can I run tests in parallel?

Yes, with the pytest-xdist plugin (pytest -n auto). It is covered in Parallel Execution with pytest-xdist.

🤔 Do these options affect browsers I create myself?

No. They configure the browser, context and page fixtures from pytest-playwright. A browser you launch yourself with sync_playwright() inside a test is not affected.

🤔 Is there a –retries option?

Not in pytest-playwright. Retries come from the pytest-rerunfailures plugin (pytest --reruns 2), covered in Module 6.


🏁 Summary

  • --browser (repeatable) and --browser-channel choose where tests run; --headed and --slowmo let you watch.
  • --device emulates phones and tablets; -s shows printed output.
  • --screenshot only-on-failure --video retain-on-failure --tracing retain-on-failure keeps evidence for failed tests in test-results.
  • --base-url lets tests use relative URLs; pytest’s own flags (-k, -x, -v, --lf) select and report tests.

Next, you will move these options into configuration files and learn how conftest.py customises the browser and context for the whole suite.

🎬 Watch the Playwright tutorial series on YouTube!

Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page

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.

📖 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