pytest conftest.py Explained: Share and Override Fixtures Across Your Test Suite

Once fixtures are useful in more than one test file, they belong in conftest.py. pytest loads these special files automatically, makes their fixtures available to every test below them, and lets sub-folders override fixtures with their own versions. In this lesson you will build a small Playwright project with a root conftest.py and two sub-folder conftest files, see exactly where each test’s fixtures come from, and learn the rules that keep conftest files tidy.

๐Ÿ“– What You’ll Learn: what conftest.py is and where pytest looks for it, sharing fixtures without imports, how nested conftest files add and override fixtures, overriding a fixture while reusing the parent version, inspecting fixture sources with –fixtures-per-test, putting hooks in conftest.py, and common mistakes such as importing from conftest.


๐Ÿ“„ What Is conftest.py?

conftest.py is a normal Python file with a special name. pytest finds it automatically and:

  • makes every fixture defined in it available to all tests in the same folder and all sub-folders, with no import;
  • runs any hook functions in it (functions with special names such as pytest_addoption) to customise pytest itself;
  • adds its folder to sys.path, which is why a root conftest.py also fixes the import problem from Lesson 19.

You can have one conftest.py per folder. They stack: a test sees the fixtures from every conftest.py between the project root and its own folder.


๐Ÿ—‚๏ธ The Example Project

๐Ÿ–ฅ๏ธ Output

project/
โ”œโ”€โ”€ pytest.ini
โ”œโ”€โ”€ conftest.py                 # shared by every test
โ””โ”€โ”€ tests/
    โ”œโ”€โ”€ test_home.py
    โ”œโ”€โ”€ shop/
    โ”‚   โ”œโ”€โ”€ conftest.py         # adds logged_in_page for shop tests
    โ”‚   โ””โ”€โ”€ test_cart.py
    โ””โ”€โ”€ admin/
        โ”œโ”€โ”€ conftest.py         # overrides the user fixture
        โ””โ”€โ”€ test_admin.py

๐Ÿ“„ pytest.ini

[pytest]
testpaths = tests
pythonpath = .

The root conftest.py

๐Ÿ“„ conftest.py

import pytest

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


@pytest.fixture(scope="session")
def base_url_value() -> str:
    """The application under test (shared by every test)."""
    return BASE_URL


@pytest.fixture
def user() -> dict:
    """The default user for most tests."""
    return {"username": "standard_user", "password": "secret_sauce", "role": "customer"}
  • base_url_value is session-scoped: it never changes during a run.
  • user is function-scoped and returns fresh data each time.

A test in the root tests folder

๐Ÿ“„ tests/test_home.py

from playwright.sync_api import Page, expect


def test_login_page_title(page: Page, base_url_value: str):
    page.goto(base_url_value)
    expect(page).to_have_title("Swag Labs")


def test_default_user(user: dict):
    print(f"\n  tests/test_home.py sees role={user['role']}")
    assert user["username"] == "standard_user"

Neither fixture is imported: pytest finds them in the root conftest.py.


โž• Adding Fixtures in a Sub-folder

Fixtures that only the shop tests need go in tests/shop/conftest.py. They can use fixtures from the root conftest.py and from pytest-playwright:

๐Ÿ“„ tests/shop/conftest.py

import re

import pytest
from playwright.sync_api import Page, expect


@pytest.fixture
def logged_in_page(page: Page, base_url_value: str, user: dict) -> Page:
    """A page that is already logged in as `user`."""
    page.goto(base_url_value)
    page.get_by_placeholder("Username").fill(user["username"])
    page.get_by_placeholder("Password").fill(user["password"])
    page.get_by_role("button", name="Login").click()
    expect(page).to_have_url(re.compile(r".*/inventory\.html"))
    return page

๐Ÿ“„ tests/shop/test_cart.py

from playwright.sync_api import Page, expect


def test_add_backpack(logged_in_page: Page):
    logged_in_page.locator("[data-test='add-to-cart-sauce-labs-backpack']").click()
    expect(logged_in_page.locator("[data-test='shopping-cart-badge']")).to_have_text("1")

logged_in_page is visible to tests in tests/shop/ only. A test in tests/admin/ that asked for it would fail with “fixture ‘logged_in_page’ not found”.


๐Ÿ”„ Overriding a Fixture in a Sub-folder

The admin tests need a different user. Instead of a new fixture name, tests/admin/conftest.py overrides user. It can even receive the parent version by requesting a fixture with the same name:

๐Ÿ“„ tests/admin/conftest.py

import pytest


@pytest.fixture
def user(user: dict) -> dict:
    """Same as the root user, but with the admin role."""
    return {**user, "role": "admin"}

๐Ÿ“„ tests/admin/test_admin.py

def test_admin_role(user: dict):
    print(f"\n  tests/admin/test_admin.py sees role={user['role']}")
    assert user["role"] == "admin"
    assert user["username"] == "standard_user"   # inherited from the root fixture
  • Inside tests/admin/, the nearest user fixture wins.
  • Because the override asks for user as a parameter, pytest passes it the parent fixture’s value (from the root conftest.py). {**user, "role": "admin"} copies it and changes one key.
  • This is exactly the technique you used for browser_context_args in Lesson 6: your conftest.py overrides the plugin’s fixture and extends the original.

Run everything:

โŒจ๏ธ Terminal

pytest -q -s

๐Ÿ–ฅ๏ธ Output

  tests/admin/test_admin.py sees role=admin
...
  tests/test_home.py sees role=customer
.
4 passed, 1 warning in 11.51s

(The warning is pytest-playwright’s harmless base_url deprecation notice from Lesson 2.)

The same fixture name gives admin in one folder and customer in another.


๐Ÿ”Ž Where Does Each Fixture Come From?

When a project has several conftest files, --fixtures-per-test shows which file every fixture of a test comes from:

โŒจ๏ธ Terminal

pytest -q --fixtures-per-test tests/admin/test_admin.py

๐Ÿ–ฅ๏ธ Output

---------------------- fixtures used by test_admin_role -----------------------
------------------------ (tests\admin\test_admin.py:1) ------------------------
delete_output_dir -- ..\_venv0\Lib\site-packages\pytest_playwright\pytest_playwright.py:95
    no docstring available
pytestconfig -- ...\_pytest\fixtures.py:1644
    Session-scoped fixture that returns the session's :class:`pytest.Config`
    object.
user -- tests\admin\conftest.py:4
    Same as the root user, but with the admin role.

user comes from tests\admin\conftest.py line 4, the override. The other two entries are automatic fixtures from pytest-playwright and pytest itself. The docstring of each fixture is shown under it, another reason to write one. Related commands: pytest --fixtures lists every available fixture, and pytest --fixtures -v adds the file each one comes from.


๐Ÿช Hooks Also Live in conftest.py

Besides fixtures, conftest.py can contain hook functions: functions with names pytest recognises, which let you change pytest’s behaviour. For example, adding a custom command-line option:

def pytest_addoption(parser):
    parser.addoption("--env", default="staging", help="Environment to test against")

Option hooks such as pytest_addoption must live in the root conftest.py (or a plugin), because pytest reads them before it collects test folders. You will write hooks in pytest Plugins and Hooks.


๐Ÿงญ A Recommended Layout for Playwright Projects

FilePut here
conftest.py (root)Settings overrides (browser_context_args), command-line options, session fixtures (config, API clients), screenshot-on-failure hooks
tests/<area>/conftest.pyFixtures for one feature area, such as logged_in_page for shop tests or an admin user
pages/, utils/ packagesPage objects and helpers (imported normally, not fixtures)

โš ๏ธ Common Mistakes

  • Importing from conftest.py. Never write from conftest import user. pytest loads conftest files itself; importing them can load a second copy. If you need shared helper functions (not fixtures), put them in a normal module such as utils/helpers.py.
  • One giant root conftest.py. Move area-specific fixtures into the conftest.py of that folder.
  • Duplicate conftest.py at the same level as pytest.ini and in tests/ with conflicting fixtures. Keep one owner for each fixture name and override deliberately.
  • Expecting a sub-folder fixture to be visible higher up. Fixtures flow down the folder tree, never up or sideways.

โ“ Frequently Asked Questions

๐Ÿค” Do I need __init__.py files for conftest.py to work?

No. pytest finds conftest.py files in the folders it collects tests from, with or without __init__.py.

๐Ÿค” Can a test file override a conftest fixture?

Yes. A fixture defined in the test module itself is the closest of all, so it wins for the tests in that file.

๐Ÿค” Why is my fixture “not found”?

The fixture is defined in a conftest.py that is not on the path from the root to the test’s folder (for example in a sibling folder), or the name is misspelled. pytest --fixtures lists what the test can see.

๐Ÿค” Can I override pytest-playwright fixtures such as page?

Yes, the same way, but prefer overriding the settings fixtures (browser_context_args, browser_type_launch_args) or writing new fixtures on top of page. Replacing page itself is rarely necessary.


๐Ÿ Summary

  • conftest.py fixtures are available to every test in its folder and below, without imports.
  • Nested conftest files add fixtures and override names; an override can request the parent version by name.
  • --fixtures-per-test and pytest --fixtures show where fixtures come from.
  • Keep option hooks in the root conftest.py, area fixtures in sub-folders, and helpers in normal modules.

Next, you will run the same test with many inputs using @pytest.mark.parametrize.

๐ŸŽฌ Watch the Playwright tutorial series on YouTube!

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

pytest Fixtures Explained: Setup, Teardown, yield, Scope and autouse

Fixtures are pytest’s way of preparing everything a test needs and cleaning up afterwards. The page fixture you have used since Lesson 4 is one, provided by pytest-playwright. In this lesson you will write your own: simple data fixtures, setup and teardown with yield, fixtures that depend on other fixtures, scopes that control how often a fixture runs, autouse fixtures, factories, and a logged-in page fixture for Playwright tests.

๐Ÿ“– What You’ll Learn: what a fixture is and how tests request it by name, return vs yield, teardown that runs even after a failure, fixture scopes (function, class, module, package, session) and how to see them with –setup-show, fixtures that use other fixtures, autouse, the request object, factory fixtures, useful built-in fixtures, and a reusable logged-in Playwright page.


๐Ÿงฉ What Is a Fixture?

A fixture is a function decorated with @pytest.fixture. A test asks for it by adding a parameter with the same name; pytest runs the fixture first and passes its result in. This is called *dependency injection*: the test declares what it needs, and pytest provides it.

๐Ÿ“„ tests/test_data_fixtures.py

import pytest


@pytest.fixture
def user():
    """Test data for a standard user."""
    return {"username": "standard_user", "password": "secret_sauce"}


@pytest.fixture
def cart_items():
    return ["Sauce Labs Backpack", "Sauce Labs Onesie"]


def test_user_has_password(user):
    assert user["password"] == "secret_sauce"


def test_cart_count(cart_items):
    assert len(cart_items) == 2


def test_both_fixtures(user, cart_items):
    assert user["username"].startswith("standard")
    assert "Sauce Labs Backpack" in cart_items
  • Each test lists the fixtures it needs as parameters, in any order.
  • The fixture runs once per test that uses it by default, so every test gets a fresh dictionary or list. One test changing its data can never affect another.
  • The docstring appears in pytest --fixtures, which lists every fixture available to your tests.

๐Ÿ–ฅ๏ธ Output

...                                                                      [100%]
3 passed in 0.01s

๐Ÿ” Setup and Teardown with yield

Many resources must be cleaned up: files, test users, database rows, browser contexts. Put the setup before yield, give the test its value with yield, and put the cleanup after it (tests/test_yield.py):

๐Ÿ“„ tests/test_yield.py

import pytest


@pytest.fixture
def temp_user():
    print("\n  [setup] create user qa_temp")
    user = {"name": "qa_temp", "active": True}
    yield user                                  # the test runs here
    print("  [teardown] delete user qa_temp")   # runs after the test, pass or fail


def test_user_is_active(temp_user):
    print("  [test] checking user")
    assert temp_user["active"]


def test_failure_still_tears_down(temp_user):
    print("  [test] about to fail on purpose")
    with pytest.raises(AssertionError):
        assert temp_user["name"] == "someone_else"

โŒจ๏ธ Terminal

pytest -q -s tests/test_yield.py

๐Ÿ–ฅ๏ธ Output

  [setup] create user qa_temp
  [test] checking user
.  [teardown] delete user qa_temp

  [setup] create user qa_temp
  [test] about to fail on purpose
.  [teardown] delete user qa_temp

2 passed in 0.01s
  • The order is always setup, test, teardown. (The dot that marks a passed test is printed between the test and its teardown.)
  • Teardown code after yield runs even when the test fails, because pytest resumes the fixture after the test finishes, whatever the result.
  • If the setup part raises an exception, the test is reported as an *error* and the teardown part does not run (there was nothing set up to clean).

This is exactly how pytest-playwright’s context and page fixtures work: create a context and page, yield the page to your test, close them afterwards.


๐Ÿ“ Fixture Scope: How Often Does It Run?

scope= decides how long a fixture’s value lives and how often it is created:

ScopeCreated once perTypical use
function (default)testPages, test data, anything a test may change
classtest classShared state for a group of related tests
moduletest fileExpensive setup used by one file
packagepackage (folder with __init__.py)Shared setup for a folder
sessionwhole test runBrowsers, API clients, config loaded once

pytest can show exactly when each fixture is set up and torn down with --setup-show (tests/test_scopes.py):

๐Ÿ“„ tests/test_scopes.py

import pytest


@pytest.fixture(scope="session")
def config():
    return {"base_url": "https://www.saucedemo.com/"}


@pytest.fixture(scope="module")
def api_token(config):
    return "token-for-" + config["base_url"]


@pytest.fixture
def cart():
    return []


def test_first(config, api_token, cart):
    cart.append("Backpack")
    assert len(cart) == 1


def test_second(config, api_token, cart):
    assert cart == []                   # a fresh cart: function scope

These tests do not use a browser, so the command below adds -p no:playwright to switch the pytest-playwright plugin off for this run and keep the output short:

โŒจ๏ธ Terminal

pytest -q --setup-show -p no:playwright tests/test_scopes.py

๐Ÿ–ฅ๏ธ Output

SETUP    S config
    SETUP    M api_token (fixtures used: config)
        SETUP    F cart
        tests/test_scopes.py::test_first (fixtures used: api_token, cart, config) .
        TEARDOWN F cart
        SETUP    F cart
        tests/test_scopes.py::test_second (fixtures used: api_token, cart, config) .
        TEARDOWN F cart
    TEARDOWN M api_token
TEARDOWN S config
2 passed in 0.00s

With the plugin active, the same command also lists pytest-playwright’s own automatic fixtures, such as SETUP S delete_output_dir (which clears test-results once per run) and a function-scoped _pw_trace_api_requests. Fixtures whose names start with _ are the plugin’s internals; you never request them yourself.

Reading it:

  • S, M and F mark session, module and function scope.
  • config and api_token were created once and reused by both tests; cart was created fresh for each test, which is why the second test sees an empty list.
  • Teardown happens in reverse order: function first, then module, then session.
  • api_token asks for config: fixtures can use other fixtures, and pytest resolves the whole chain for you.

โš ๏ธ Watch out: A fixture can only use fixtures of the same or wider scope. A session fixture that asks for a function-scoped fixture (such as page) fails with ScopeMismatch: You tried to access the function scoped fixture page with a session scoped request object., because the session value would outlive the page.

Scope in pytest-playwright

pytest-playwright uses these scopes to make tests fast and isolated at the same time:

FixtureScopeWhy
playwright, browser_type, browser, browser_namesessionLaunching a browser is slow, so it happens once per run
browser_context_argssessionSettings are calculated once
context, pagefunctionEvery test gets a clean, isolated session

๐Ÿค– autouse Fixtures

A fixture with autouse=True runs for every test in its scope without being requested. Use it for things every test needs, such as resetting state or setting timeouts:

@pytest.fixture(autouse=True)
def short_action_timeout(page):
    page.set_default_timeout(10_000)

Use autouse sparingly: hidden setup makes tests harder to understand. If only some tests need something, request it explicitly.


๐Ÿญ Factory Fixtures and the request Object

Sometimes a test needs several objects of the same kind, with different settings. Return a function (a *factory*) instead of a value. And when a fixture needs information about the test that uses it, ask for the built-in request fixture (tests/test_factory.py):

๐Ÿ“„ tests/test_factory.py

import pytest


@pytest.fixture
def make_user():
    created = []

    def _make_user(name: str, role: str = "viewer") -> dict:
        user = {"name": name, "role": role}
        created.append(user)
        return user

    yield _make_user
    print(f"\n  cleaning up {len(created)} users: {[u['name'] for u in created]}")


def test_two_users(make_user, request):
    admin = make_user("ana", role="admin")
    viewer = make_user("ben")
    print(f"\n  running {request.node.name}")
    assert admin["role"] == "admin" and viewer["role"] == "viewer"
  • make_user yields the inner function _make_user; the test calls it as many times as it wants.
  • The fixture remembers what it created and cleans everything up after the test.
  • request.node.name is the current test’s name. The request object also gives access to markers, the config and parametrized values.

๐Ÿงฐ Useful Built-in Fixtures

pytest itself provides fixtures you can request at any time (tests/test_builtins.py):

๐Ÿ“„ tests/test_builtins.py

import os


def test_tmp_path(tmp_path):
    report = tmp_path / "report.txt"           # a fresh folder for this test
    report.write_text("2 passed")
    assert report.read_text() == "2 passed"


def test_monkeypatch_env(monkeypatch):
    monkeypatch.setenv("BASE_URL", "https://staging.example.com")
    assert os.environ["BASE_URL"] == "https://staging.example.com"
    # the variable is restored automatically after the test


def test_capsys(capsys):
    print("Products")
    captured = capsys.readouterr()
    assert captured.out == "Products\n"
FixtureWhat it gives you
tmp_pathA unique temporary folder (pathlib.Path) per test
tmp_path_factoryTemporary folders for session-scoped fixtures
monkeypatchTemporarily change environment variables, attributes or dict items; undone after the test
capsysCapture what the test printed
requestInformation about the requesting test (name, markers, params, config)
pytestconfigAccess to command-line options and ini values

โŒจ๏ธ Terminal

pytest -q -s tests/test_factory.py tests/test_builtins.py

๐Ÿ–ฅ๏ธ Output

  running test_two_users
.
  cleaning up 2 users: ['ana', 'ben']
...
4 passed in 0.02s

๐ŸŽญ A Logged-in Page Fixture for Playwright

Here is the pattern you will use constantly: a fixture that builds on page, logs in, and hands the test a page that is already on the products screen (tests/test_logged_in.py):

๐Ÿ“„ tests/test_logged_in.py

import re

import pytest
from playwright.sync_api import Page, expect


@pytest.fixture
def logged_in_page(page: Page) -> Page:
    page.goto("https://www.saucedemo.com/")
    page.get_by_placeholder("Username").fill("standard_user")
    page.get_by_placeholder("Password").fill("secret_sauce")
    page.get_by_role("button", name="Login").click()
    expect(page).to_have_url(re.compile(r".*/inventory\.html"))
    return page


def test_six_products_listed(logged_in_page: Page):
    expect(logged_in_page.locator(".inventory_item")).to_have_count(6)


def test_add_to_cart(logged_in_page: Page):
    logged_in_page.locator("[data-test='add-to-cart-sauce-labs-backpack']").click()
    expect(logged_in_page.locator("[data-test='shopping-cart-badge']")).to_have_text("1")
  • logged_in_page requests page (function scope), so it is function-scoped too, and every test gets its own fresh, logged-in session.
  • The expect inside the fixture makes the setup fail loudly (as an *error*) if login is broken, instead of producing confusing failures later.
  • The tests are now about behaviour, not login steps.

๐Ÿ–ฅ๏ธ Output

..                                                                       [100%]
2 passed in 3.73s

In Module 8 you will make this even faster by logging in once and reusing the saved session with storage_state.


โ“ Frequently Asked Questions

๐Ÿค” Where should I put fixtures that many test files use?

In a conftest.py file. Fixtures defined there are available to all tests in that folder and below without imports. That is the next lesson.

๐Ÿค” Can a fixture return nothing?

Yes. A fixture that only does setup (such as setting a timeout) can return None; tests request it, or it is autouse=True.

๐Ÿค” What happens if two fixtures have the same name?

The one closest to the test wins: a fixture in the test file overrides one in conftest.py, which overrides one from a plugin. pytest-playwright’s browser_context_args customisation in Lesson 6 relies on exactly this.

๐Ÿค” Should I use a session-scoped page to make tests faster?

No. Sharing a page lets tests leak state into each other and makes failures depend on test order. Share the browser (pytest-playwright already does), not the page.


๐Ÿ Summary

  • Fixtures are requested by parameter name; pytest runs them and injects the result.
  • yield separates setup from teardown, and teardown runs even after a failed test.
  • Scope controls lifetime: function (default), class, module, package, session; --setup-show reveals the order.
  • Fixtures can use other fixtures of the same or wider scope; factories create many objects; built-ins include tmp_path, monkeypatch, capsys and request.

Next, you will organise fixtures across your project with conftest.py files and learn how pytest overrides them.

๐ŸŽฌ Watch the Playwright tutorial series on YouTube!

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

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