File Upload and File Download in Playwright Python (set_input_files, expect_download)

Uploading a profile picture, importing a CSV, exporting a report: file transfer is part of many test flows. Playwright handles both directions without touching the operating system’s file dialogs. In this lesson you will upload real and in-memory files to the SQA Tools practice page, use the file chooser event, and capture, save and verify downloads, including files the page generates on the fly.

📖 What You’ll Learn: set_input_files with a path, several files and in-memory content, checking what was selected, clearing a file input, expect_file_chooser for upload buttons that hide the input, expect_download, suggested_filename, save_as and path, verifying the downloaded content, downloads generated by JavaScript, and where Playwright stores downloads.


📤 Uploading Files: set_input_files

A file upload on a web page is an <input type="file">. Instead of opening the operating system’s file dialog (which automation cannot control), Playwright sets the input’s files directly with set_input_files(). The practice page has one: <input type="file" id="fileUpload">.

📄 tests/conftest.py

import pytest
from playwright.sync_api import Page


@pytest.fixture
def practice(page: Page) -> Page:
    page.goto("https://sqatools.in/automation-practice-page/")
    return page

📄 tests/test_upload.py

from pathlib import Path

from playwright.sync_api import Page, expect

SELECTED_NAMES = "el => [...el.files].map(f => f.name + ' (' + f.size + ' bytes)')"


def test_upload_file_from_disk(practice: Page, tmp_path: Path):
    # Create a real file to upload (in a real project, keep test files in a test_data folder)
    report = tmp_path / "report.csv"
    report.write_text("id,name\n1,Deepesh\n")

    upload = practice.locator("#fileUpload")
    upload.set_input_files(report)

    print("\nselected:", upload.evaluate(SELECTED_NAMES))
    expect(upload).to_have_value("C:\\fakepath\\report.csv")


def test_upload_in_memory_file(practice: Page):
    upload = practice.locator("#fileUpload")
    upload.set_input_files({
        "name": "notes.txt",
        "mimeType": "text/plain",
        "buffer": b"created in memory by the test",
    })
    print("\nin memory:", upload.evaluate(SELECTED_NAMES))
    assert upload.evaluate("el => el.files[0].name") == "notes.txt"


def test_clear_selection(practice: Page, tmp_path: Path):
    file = tmp_path / "a.txt"
    file.write_text("a")
    upload = practice.locator("#fileUpload")
    upload.set_input_files(file)
    upload.set_input_files([])                     # an empty list removes the selection
    expect(upload).to_have_value("")

Line by line:

  • set_input_files(path) accepts a path as a string or pathlib.Path. Relative paths are resolved from the folder where you run pytest.
  • To see what was selected, the test reads the input’s files list in the browser with evaluate: name and size of each file.
  • Browsers never reveal the real path of an uploaded file, so the input’s value is always C:\fakepath\<name>, on every operating system. to_have_value checks exactly that.
  • Instead of a file on disk, you can pass a dictionary with name, mimeType and buffer (bytes). This is handy for generated content and avoids test-data files.
  • set_input_files([]) clears the selection.
  • For inputs with the multiple attribute, pass a list of paths to upload several files at once.

🖥️ Output

selected: ['report.csv (20 bytes)']
in memory: ['notes.txt (29 bytes)']

(The CSV is 20 bytes on Windows, where Python’s write_text stores each line break as two characters, \r\n; on Linux and macOS the same file is 18 bytes.)

📝 Note: set_input_files only selects the file, exactly like a user picking it in the dialog. Whatever the page does next (uploading on change, or on clicking an Upload button) you trigger and verify as usual.


🗂️ Upload Buttons That Hide the Input: expect_file_chooser

Many sites hide the real file input behind a styled button. Clicking the button opens the native file dialog, which Playwright intercepts as a file chooser event:

📄 tests/test_upload.py

def test_file_chooser(page: Page, tmp_path: Path):
    page.set_content("""
        <input type="file" id="hidden-input" style="display:none">
        <button onclick="document.getElementById('hidden-input').click()">Choose photo</button>
        <p id="status"></p>
        <script>
          document.getElementById('hidden-input').onchange = e =>
            document.getElementById('status').textContent = 'Selected: ' + e.target.files[0].name;
        </script>
    """)
    photo = tmp_path / "avatar.png"
    photo.write_bytes(b"\x89PNG fake image bytes")

    with page.expect_file_chooser() as chooser_info:
        page.get_by_role("button", name="Choose photo").click()
    chooser = chooser_info.value
    chooser.set_files(photo)

    expect(page.locator("#status")).to_have_text("Selected: avatar.png")
  • page.expect_file_chooser() starts waiting before the click, like expect_popup() in Lesson 34.
  • chooser.set_files(...) accepts the same arguments as set_input_files. The page’s change event fires, so the page reacts exactly as it would for a real user.
  • You could also call set_input_files directly on the hidden input; it works on hidden inputs too. The file chooser approach is useful when you do not know where the input is.

📥 Downloading Files: expect_download

When a click starts a download, wrap it in page.expect_download(). The Download object lets you read the file name, save the file and check its content:

📄 tests/test_download.py

import csv
from pathlib import Path

from playwright.sync_api import Page

PAGE = """
<a id="static" href="data:text/csv;charset=utf-8,id%2Cname%0A1%2CDeepesh%0A2%2CRahul" download="users.csv">Export users</a>
<button id="generate">Generate report</button>
<script>
  document.getElementById('generate').onclick = () => {
    const text = 'Total tests: 42\\nPassed: 40\\nFailed: 2';
    const url = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
    const a = document.createElement('a');
    a.href = url; a.download = 'summary.txt'; a.click();
  };
</script>
"""


def test_download_csv(page: Page, tmp_path: Path):
    page.set_content(PAGE)

    with page.expect_download() as download_info:
        page.get_by_role("link", name="Export users").click()
    download = download_info.value

    print("\nsuggested name:", download.suggested_filename)
    target = tmp_path / download.suggested_filename
    download.save_as(target)

    with open(target, newline="", encoding="utf-8") as f:
        rows = list(csv.DictReader(f))
    print("rows:", rows)
    assert rows == [{"id": "1", "name": "Deepesh"}, {"id": "2", "name": "Rahul"}]


def test_download_generated_by_javascript(page: Page):
    page.set_content(PAGE)

    with page.expect_download() as download_info:
        page.get_by_role("button", name="Generate report").click()
    download = download_info.value

    assert download.failure() is None              # None means the download succeeded
    content = Path(download.path()).read_text()
    print("\n" + download.suggested_filename + ":", content.splitlines())
    assert "Failed: 2" in content

Line by line:

  • page.expect_download() waits for a download that starts during the with block.
  • download.suggested_filename is the name the site proposed (from the download attribute or the server’s headers).
  • download.save_as(path) copies the file to a location you choose. Here it goes to pytest’s tmp_path, which is cleaned up automatically.
  • download.path() returns where Playwright saved the file temporarily; it waits until the download has finished. download.failure() returns None on success or an error message.
  • The second download is generated by JavaScript in the browser (a Blob turned into a temporary URL), the same technique many “Export” buttons use. Playwright captures it the same way.
  • Python’s csv.DictReader turns the CSV into a list of dictionaries, so the test checks the content, not just that a file arrived.

🖥️ Output

suggested name: users.csv
rows: [{'id': '1', 'name': 'Deepesh'}, {'id': '2', 'name': 'Rahul'}]
summary.txt: ['Total tests: 42', 'Passed: 40', 'Failed: 2']

📁 Where Do Downloads Go?

  • By default, Playwright saves downloads in a temporary folder that is deleted when the browser context closes. With pytest-playwright that happens at the end of each test, so call save_as() if you need the file afterwards.
  • Downloads are accepted by default in pytest-playwright contexts (accept_downloads=True).
  • To keep all downloads in a fixed folder, pass downloads_path in browser_type_launch_args (Lesson 6), but save_as() per test is usually clearer.

⌨️ Terminal

pytest -q -s tests

🖥️ Output

suggested name: users.csv
rows: [{'id': '1', 'name': 'Deepesh'}, {'id': '2', 'name': 'Rahul'}]
.
summary.txt: ['Total tests: 42', 'Passed: 40', 'Failed: 2']
.
selected: ['report.csv (20 bytes)']
.
in memory: ['notes.txt (29 bytes)']
...
6 passed, 1 warning in 7.86s

❓ Frequently Asked Questions

🤔 Can Playwright click “Browse” and pick a file in the Windows dialog?

No automation tool should drive the native dialog. Playwright intercepts it instead: use set_input_files() on the input, or expect_file_chooser() around the click.

🤔 Why is the input value C:\fakepath\… on Linux too?

It is a browser security rule: the real path is never revealed to the page, on any operating system.

🤔 How do I check that the download was a valid Excel or PDF file?

Save it, then open it with a Python library: openpyxl for Excel, pypdf for PDF, csv or json for text formats. Module 7 covers reading test data files.

🤔 My download test fails with a timeout. Why?

The click did not start a download (it may open the file in a new tab instead), or the download started before the with page.expect_download() block. Start waiting first, then click inside the block.


🏁 Summary

  • set_input_files() selects files on an <input type="file">: paths, lists, in-memory buffers, or [] to clear.
  • expect_file_chooser() handles upload buttons that open the native dialog.
  • expect_download() captures downloads; use suggested_filename, save_as(), path() and failure(), then verify the content.
  • Downloads live in a temporary folder that disappears with the context, so save what you need.

That completes Module 4. Test yourself with the Module 4 mock interview, then continue to Module 5: assertions, waits and screenshots.

🎬 Watch the Playwright tutorial series on YouTube!

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

Read and Verify Web Tables with Playwright Python (Rows, Columns, Sorting, Pagination)

Tables are everywhere in business applications: orders, users, invoices, test results. Testing them means reading cells reliably, finding the right row, checking a column, verifying sort order and walking through pages. In this lesson you will build reusable helpers that turn any HTML table into Python data, use them on the SQA Tools practice table, and then test sorting and pagination on a richer table.

📖 What You’ll Learn: the structure of an HTML table and its ARIA roles, reading headers, rows and cells with locators, converting a table to a list of dictionaries, finding a row by a cell value, reading a column by header name, asserting a whole table with to_have_text, fast extraction with evaluate, checking sort order, and collecting data across paginated pages.


🧱 Table Structure and Roles

🖥️ Output

<table>
  <thead>
    <tr> <th>ID</th> <th>Name</th> <th>Role</th> </tr>          role=row with role=columnheader
  </thead>
  <tbody>
    <tr> <td>1</td> <td>Deepesh</td> <td>Trainer</td> </tr>  role=row with role=cell
    ...
  </tbody>
</table>

Playwright gives table parts ARIA roles: table, row, columnheader and cell. A row’s accessible name is its cells’ text joined with spaces, which is why get_by_role("row", name="2 Rahul Tester") works. The practice page table has 3 data rows:

IDNameRole
1DeepeshTrainer
2RahulTester
3AnitaDeveloper

🔎 Reading Headers, Rows and Cells

📄 tests/conftest.py

import pytest
from playwright.sync_api import Page


@pytest.fixture
def practice(page: Page) -> Page:
    page.goto("https://sqatools.in/automation-practice-page/")
    return page

📄 tests/test_table_basics.py

from playwright.sync_api import Page, expect


def test_read_table(practice: Page):
    table = practice.get_by_role("table")

    headers = table.get_by_role("columnheader").all_inner_texts()
    print("\nheaders:", headers)
    assert headers == ["ID", "Name", "Role"]

    rows = table.locator("tbody tr")
    expect(rows).to_have_count(3)

    # One cell: 2nd row, 2nd column (0-based)
    expect(rows.nth(1).locator("td").nth(1)).to_have_text("Rahul")

    # A whole column, checked in one assertion
    expect(rows.locator("td:nth-child(2)")).to_have_text(["Deepesh", "Rahul", "Anita"])
  • get_by_role("columnheader") finds the <th> header cells; all_inner_texts() returns their text as a list.
  • tbody tr selects only data rows, skipping the header row.
  • rows.locator("td:nth-child(2)") selects the second cell of every row; passing a list to to_have_text checks all of them in order and waits until they match. This is the most robust way to assert table content.

🗂️ Turn a Table into Python Data

Assertions on single cells get long. A small helper that converts the table into a list of dictionaries makes tests read like requirements. Put it in utils/tables.py:

📄 utils/tables.py

from playwright.sync_api import Locator


def table_to_dicts(table: Locator) -> list[dict[str, str]]:
    """Read an HTML table into [{header: cell_text, ...}, ...]."""
    headers = [h.strip() for h in table.locator("thead th").all_inner_texts()]
    data = []
    for row in table.locator("tbody tr").all():
        cells = [c.strip() for c in row.locator("td").all_inner_texts()]
        data.append(dict(zip(headers, cells)))
    return data


def column(table: Locator, header: str) -> list[str]:
    """All values of the column with this header text."""
    headers = [h.strip() for h in table.locator("thead th").all_inner_texts()]
    index = headers.index(header) + 1                 # CSS nth-child is 1-based
    return [c.strip() for c in table.locator(f"tbody tr td:nth-child({index})").all_inner_texts()]


def row_by_cell(table: Locator, text: str) -> Locator:
    """The data row that contains a cell with exactly this text."""
    return table.locator("tbody tr").filter(has=table.page.get_by_role("cell", name=text, exact=True))
  • table_to_dicts reads the headers once, then zips each row’s cells with them: dict(zip(["ID", "Name"], ["1", "Deepesh"])) gives {"ID": "1", "Name": "Deepesh"}.
  • column finds the header’s position by name, so tests keep working if a column is added or moved.
  • row_by_cell returns a locator for the row, so a test can click a button inside it or assert on it with auto-waiting.
  • .strip() removes stray whitespace that HTML often adds around cell text.

Tests using the helpers (tests/test_table_helpers.py):

📄 tests/test_table_helpers.py

from playwright.sync_api import Page, expect

from utils.tables import column, row_by_cell, table_to_dicts


def test_table_as_dicts(practice: Page):
    table = practice.get_by_role("table")
    expect(table.locator("tbody tr")).to_have_count(3)      # make sure rows are loaded first

    data = table_to_dicts(table)
    print("\n", data)
    assert data[1] == {"ID": "2", "Name": "Rahul", "Role": "Tester"}
    assert [row["Role"] for row in data] == ["Trainer", "Tester", "Developer"]


def test_column_and_row_lookup(practice: Page):
    table = practice.get_by_role("table")
    expect(table.locator("tbody tr")).to_have_count(3)

    assert column(table, "Name") == ["Deepesh", "Rahul", "Anita"]

    anita = row_by_cell(table, "Anita")
    expect(anita.locator("td").last).to_have_text("Developer")
    expect(anita.locator("td").first).to_have_text("3")

🖥️ Output

 [{'ID': '1', 'Name': 'Deepesh', 'Role': 'Trainer'}, {'ID': '2', 'Name': 'Rahul', 'Role': 'Tester'}, {'ID': '3', 'Name': 'Anita', 'Role': 'Developer'}]

⚠️ Watch out: all() and all_inner_texts() read the table immediately, without waiting. Always wait for the table to be ready first, for example with expect(rows).to_have_count(...) or expect(rows.first).to_be_visible(), or you may read a half-loaded table.


⚡ Fast Extraction with evaluate

Reading a large table cell by cell sends one request to the browser per cell. For hundreds of rows, extract everything in a single call with JavaScript running inside the page:

📄 tests/test_table_helpers.py

def test_extract_with_evaluate(practice: Page):
    table = practice.get_by_role("table")
    expect(table.locator("tbody tr")).to_have_count(3)

    rows = table.evaluate(
        "t => [...t.querySelectorAll('tbody tr')].map(r => [...r.cells].map(c => c.innerText.trim()))"
    )
    print("\nrows:", rows)
    assert rows == [["1", "Deepesh", "Trainer"], ["2", "Rahul", "Tester"], ["3", "Anita", "Developer"]]

locator.evaluate(js) runs the function with the matched element as its argument (t) and returns the result to Python as lists and strings. r.cells is the browser’s list of a row’s cells.


↕️ Sorting and Pagination

The practice table is static, so for sorting and paging we build a richer table with set_content(): 7 products, a sortable Price header and pages of 3 rows. The page logic is plain JavaScript, just like a real app.

📄 tests/product_table.py

HTML = """
<table id="products">
  <thead><tr><th>Product</th><th><button id="sort-price">Price</button></th></tr></thead>
  <tbody></tbody>
</table>
<button id="prev">Previous</button> <span id="page-info"></span> <button id="next">Next</button>
<script>
  const items = [["Backpack", 29.99], ["Bike Light", 9.99], ["Bolt T-Shirt", 15.99],
                 ["Fleece Jacket", 49.99], ["Onesie", 7.99], ["Red T-Shirt", 15.99], ["Water Bottle", 12.50]];
  let page = 0, sorted = items.slice();
  function render() {
    const rows = sorted.slice(page * 3, page * 3 + 3);
    document.querySelector("#products tbody").innerHTML =
      rows.map(([n, p]) => `<tr><td>${n}</td><td>$${p.toFixed(2)}</td></tr>`).join("");
    document.querySelector("#page-info").textContent = `Page ${page + 1} of 3`;
    document.querySelector("#prev").disabled = page === 0;
    document.querySelector("#next").disabled = page === 2;
  }
  document.querySelector("#next").onclick = () => { page++; render(); };
  document.querySelector("#prev").onclick = () => { page--; render(); };
  document.querySelector("#sort-price").onclick = () => {
    sorted.sort((a, b) => a[1] - b[1]); page = 0; render();
  };
  render();
</script>
"""

📄 tests/test_sort_and_pages.py

from playwright.sync_api import Page, expect

from tests.product_table import HTML


def prices_on_page(page: Page) -> list[float]:
    texts = page.locator("#products tbody td:nth-child(2)").all_inner_texts()
    return [float(t.replace("$", "")) for t in texts]


def test_sort_by_price(page: Page):
    page.set_content(HTML)
    page.get_by_role("button", name="Price").click()

    expect(page.locator("#products tbody tr").first).to_contain_text("Onesie")   # wait for re-render
    prices = prices_on_page(page)
    print("\nfirst page after sort:", prices)
    assert prices == sorted(prices)


def test_collect_all_pages(page: Page):
    page.set_content(HTML)
    names = []

    while True:
        expect(page.locator("#products tbody tr").first).to_be_visible()
        names += page.locator("#products tbody td:nth-child(1)").all_inner_texts()
        next_button = page.get_by_role("button", name="Next")
        if next_button.is_disabled():
            break
        info = page.locator("#page-info").inner_text()
        next_button.click()
        expect(page.locator("#page-info")).not_to_have_text(info)   # wait until the page changed

    print("all products:", names)
    assert len(names) == 7
    expect(page.locator("#page-info")).to_have_text("Page 3 of 3")

What the tests do:

  • Sorting: click the header, wait until the table re-rendered (the cheapest item, Onesie, is now first), read the prices, and compare them with Python’s sorted() version of the same list.
  • Pagination: loop: read the current page, stop when Next is disabled, otherwise remember the page label, click Next, and wait until the label changes before reading again. Waiting for a visible change is what makes the loop reliable; reading immediately after the click could read the old page twice.
  • is_disabled() returns the button’s state right now; inside the loop the page has already been confirmed as updated, so that is safe.

⌨️ Terminal

pytest -q -s tests

🖥️ Output

first page after sort: [7.99, 9.99, 12.5]
.all products: ['Backpack', 'Bike Light', 'Bolt T-Shirt', 'Fleece Jacket', 'Onesie', 'Red T-Shirt', 'Water Bottle']
.
headers: ['ID', 'Name', 'Role']
.
 [{'ID': '1', 'Name': 'Deepesh', 'Role': 'Trainer'}, {'ID': '2', 'Name': 'Rahul', 'Role': 'Tester'}, {'ID': '3', 'Name': 'Anita', 'Role': 'Developer'}]
..
rows: [['1', 'Deepesh', 'Trainer'], ['2', 'Rahul', 'Tester'], ['3', 'Anita', 'Developer']]
.
6 passed, 1 warning in 10.08s

The pagination loop collected all 7 products across 3 pages, each exactly once.


❓ Frequently Asked Questions

🤔 Should I read tables with locators or with evaluate?

Use locators and expect(...).to_have_text([...]) for assertions, because they wait and retry. Use evaluate to extract large amounts of data quickly once the table is loaded.

🤔 How do I click a button in a specific row?

Find the row first, then search inside it: row_by_cell(table, "Anita").get_by_role("button", name="Edit").click().

🤔 My table has no thead. How do I find the headers?

Use the first row: table.locator("tr").first.locator("th, td").all_inner_texts(), and read data rows with table.locator("tr").nth(i) starting at 1.

🤔 How do I verify that a table is sorted by a date column?

Read the column, convert each text to a datetime with datetime.strptime(text, "%d/%m/%Y"), then compare the list with sorted(...) of itself.


🏁 Summary

  • Tables expose row, columnheader and cell roles; tbody tr selects data rows.
  • Assert columns with to_have_text([...]); convert tables to lists of dicts with small helpers; find rows by cell value and columns by header name.
  • locator.evaluate() extracts large tables in one call; always wait for the table before reading.
  • For sorting, compare the read list with sorted(); for pagination, wait for a visible page change before reading each page.

Next, you will upload and download files.

🎬 Watch the Playwright tutorial series on YouTube!

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

Playwright Python: Handle Alerts, Confirm and Prompt Dialogs, iFrames, Popups and New Tabs

Real applications do not stay on one page. They show alert and confirm dialogs, embed content in iframes, and open links in new tabs. Each needs a slightly different technique in Playwright. In this lesson you will handle all three kinds of JavaScript dialogs, find elements inside iframes (including the lazy-loaded iframe on the SQA Tools practice page), and open, switch between and close tabs and popups.

📖 What You’ll Learn: how Playwright treats dialogs by default, page.on and page.once dialog handlers, dialog.type, message, default_value, accept and dismiss, answering a prompt, frame_locator and content_frame, nested frames, the Frame API, lazy-loaded iframes, expect_popup and context.expect_page, listing and switching tabs, and closing pages.


💬 JavaScript Dialogs: alert, confirm and prompt

The practice page has three buttons that open the three kinds of native browser dialogs:

ButtonJavaScriptDialog typeUser can
Simple Alertalert("This is a simple alert!")alertClick OK
Confirm Alertconfirm("Do you want to continue?")confirmOK or Cancel
Prompt Alertprompt("Enter your name:")promptType text, then OK or Cancel

The default: dialogs are dismissed automatically

If your test has no dialog handler, Playwright automatically dismisses every dialog, so the test never hangs. For an alert that is harmless; for a confirm it means “Cancel”. The first test proves that clicking “Simple Alert” without a handler does not block anything.

Handling dialogs with page.on / page.once

To inspect or answer a dialog, register a handler before the action that opens it:

📄 tests/conftest.py

import pytest
from playwright.sync_api import Page


@pytest.fixture
def practice(page: Page) -> Page:
    page.goto("https://sqatools.in/automation-practice-page/")
    return page

📄 tests/test_dialogs.py

from playwright.sync_api import Dialog, Page, expect


def test_alert_without_handler_is_dismissed(practice: Page):
    practice.get_by_role("button", name="Simple Alert").click()
    # The test continues: Playwright dismissed the alert automatically
    expect(practice.get_by_role("button", name="Simple Alert")).to_be_visible()


def test_read_and_accept_alert(practice: Page):
    messages = []

    def handle(dialog: Dialog):
        messages.append((dialog.type, dialog.message))
        dialog.accept()

    practice.once("dialog", handle)                       # handle only the next dialog
    practice.get_by_role("button", name="Simple Alert").click()
    print("\nalert:", messages)
    assert messages == [("alert", "This is a simple alert!")]


def test_confirm_ok_and_cancel(practice: Page):
    seen = []
    practice.once("dialog", lambda d: (seen.append(d.message), d.accept()))    # OK
    practice.get_by_role("button", name="Confirm Alert").click()
    practice.once("dialog", lambda d: (seen.append(d.type), d.dismiss()))      # Cancel
    practice.get_by_role("button", name="Confirm Alert").click()
    assert seen == ["Do you want to continue?", "confirm"]


def test_answer_prompt(practice: Page):
    answer = {}

    def handle(dialog: Dialog):
        answer["type"] = dialog.type
        answer["default"] = dialog.default_value
        dialog.accept("Deepesh")                           # type text and press OK

    practice.once("dialog", handle)
    # The page discards the answer, so we call prompt() ourselves to see what it returns
    result = practice.evaluate("() => prompt('Enter your name:')")
    print("\nprompt:", answer, "->", result)
    assert result == "Deepesh"

Line by line:

  • page.once("dialog", handler) calls the handler for the next dialog only; page.on("dialog", handler) calls it for every dialog until you remove it with page.remove_listener("dialog", handler).
  • The handler receives a Dialog object: dialog.type (“alert”, “confirm”, “prompt” or “beforeunload”), dialog.message, and for prompts dialog.default_value.
  • dialog.accept() clicks OK; dialog.accept("text") also types the answer into a prompt; dialog.dismiss() clicks Cancel.
  • Your handler must call accept() or dismiss(). If it does neither, the dialog stays open and the page is stuck, so the click never completes.
  • The practice page’s showPrompt() throws the answer away, so the last test calls prompt() through page.evaluate() to show the value the page would receive.

🖥️ Output

alert: [('alert', 'This is a simple alert!')]
prompt: {'type': 'prompt', 'default': ''} -> Deepesh

🪟 iFrames

An iframe embeds a separate HTML document inside the page. Elements inside it are invisible to normal page locators; you must enter the frame first with frame_locator() (or content_frame on a locator pointing at the iframe).

A self-contained example

page.set_content() with the srcdoc attribute builds a page with an iframe, so the example works offline and never changes:

📄 tests/test_frames.py

from playwright.sync_api import Page, expect

HTML = """
<h1>Main page</h1>
<iframe id="payment" srcdoc="
    <h2>Card details</h2>
    <label>Card number <input id='card'></label>
    <iframe id='inner' srcdoc='<button>Pay now</button>'></iframe>
"></iframe>
"""


def test_frame_locator(page: Page):
    page.set_content(HTML)

    # A normal page locator cannot see inside the iframe
    expect(page.locator("#card")).to_have_count(0)

    # frame_locator() enters the iframe; then use any locator inside it
    payment = page.frame_locator("#payment")
    payment.get_by_label("Card number").fill("4111 1111 1111 1111")
    expect(payment.get_by_label("Card number")).to_have_value("4111 1111 1111 1111")

    # Nested iframe: chain frame_locator calls
    expect(payment.frame_locator("#inner").get_by_role("button", name="Pay now")).to_be_visible()

    # content_frame turns a locator for the <iframe> element into a frame locator
    expect(page.locator("#payment").content_frame.get_by_role("heading")).to_have_text("Card details")
  • page.frame_locator("#payment") takes a selector for the <iframe> element and returns a FrameLocator. Locators created from it search inside that frame.
  • Frames inside frames are reached by chaining frame_locator() again.
  • locator("#payment").content_frame does the same from a normal locator, which is useful when you already have one.
  • Auto-waiting works inside frames too: Playwright waits for the frame and the element to be ready.

The practice page’s lazy-loaded iframe

The iframe on the practice page is lazy-loaded: a performance plugin on the site starts it as about:blank and only loads the real page (https://example.com) when the iframe scrolls into view. A test must scroll to it first:

📄 tests/test_frames.py

def test_practice_page_iframe(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")
    iframe = page.locator("#sampleIframe")

    iframe.scroll_into_view_if_needed()                 # triggers the lazy loading
    expect(iframe).to_have_attribute("src", "https://example.com")

    frame = page.frame_locator("#sampleIframe")
    expect(frame.get_by_text("This domain is for use in documentation examples")).to_be_visible()


def test_frame_objects(page: Page):
    page.set_content(HTML)
    names = [f.name for f in page.frames]
    print("\nframes:", names)
    payment = page.frame(name="payment")
    assert payment is not None and payment.parent_frame == page.main_frame
  • The lazy loading is real-world behaviour worth knowing: if an element inside an iframe “never appears”, check whether the iframe has loaded at all (src and the network tab in the Trace Viewer).
  • page.frames lists every frame, starting with the main frame. page.frame(name=...) or page.frame(url=...) returns a Frame object, an older, lower-level API. Prefer frame_locator() for test steps, because it auto-waits and re-resolves the frame.

🗂️ Popups and New Tabs

A link with target="_blank" or a window.open() call opens a new page. Start waiting for it before the click with page.expect_popup():

📄 tests/test_tabs.py

import re

from playwright.sync_api import BrowserContext, Page, expect


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

    with page.expect_popup() as popup_info:
        page.get_by_role("link", name="Dummy Page").click()
    new_tab = popup_info.value
    new_tab.wait_for_load_state()

    expect(new_tab).to_have_url(re.compile(r"dummy-booking-website"))
    print("\nnew tab title:", new_tab.title())

    # Switch back and forth simply by using the right Page object
    expect(page).to_have_url("https://sqatools.in/automation-practice-page/")
    new_tab.close()
    expect(page.get_by_role("link", name="Dummy Page")).to_be_visible()


def test_tabs_in_context(page: Page, context: BrowserContext):
    page.goto("https://sqatools.in/automation-practice-page/")

    with context.expect_page() as new_page_info:
        page.get_by_role("link", name="Login Page").first.click()
    login_tab = new_page_info.value

    login_tab.wait_for_load_state()
    print("\npages open:", len(context.pages), [p.url for p in context.pages])
    expect(login_tab.get_by_role("button", name="Log in")).to_be_visible()

    login_tab.bring_to_front()                         # make it the active tab (visible in headed mode)
    login_tab.close()
    assert len(context.pages) == 1
  • page.expect_popup() waits for a popup opened by that page; context.expect_page() waits for any new page in the context. Both return the new Page in .value.
  • wait_for_load_state() waits until the new tab has loaded (the load event by default).
  • There is no “switch to” command: each tab is its own Page object, and you simply call methods on the one you want. bring_to_front() only changes which tab is visible in headed mode.
  • context.pages lists all open pages in the context; closing a page removes it.
  • The practice page has two “Login Page” links, so .first picks the one at the top (strictness, Lesson 31).

⌨️ Terminal

pytest -q -s tests

🖥️ Output

.
alert: [('alert', 'This is a simple alert!')]
..
prompt: {'type': 'prompt', 'default': ''} -> Deepesh
...
frames: ['', 'payment', 'inner']
.
new tab title: Dummy Booking Website - SQA Tools
.
pages open: 2 ['https://sqatools.in/automation-practice-page/', 'https://sqatools.in/login-page/']
.
9 passed, 1 warning in 17.59s

The main frame has an empty name (''); the two iframes are named after their id attributes.


❓ Frequently Asked Questions

🤔 My test hangs after clicking a button that opens an alert. Why?

Your dialog handler neither accepted nor dismissed the dialog. Every handler must call dialog.accept() or dialog.dismiss(). Without any handler, Playwright dismisses dialogs for you.

🤔 Why can’t Playwright find an element that I can see on screen?

It is probably inside an iframe. Use page.frame_locator(...) (or content_frame) to enter the frame first, and check whether the iframe has loaded.

🤔 How do I handle a “Leave site?” (beforeunload) dialog?

It arrives as a dialog with type == "beforeunload", but only when you close the page with page.close(run_before_unload=True). Handle it like any other dialog.

🤔 Can I test new tabs in Firefox and WebKit too?

Yes. Popups, tabs, dialogs and frames work the same in all three engines; run with --browser firefox --browser webkit.


🏁 Summary

  • Without a handler, Playwright dismisses dialogs; register page.once("dialog", ...) or page.on(...) before the action to read, accept (optionally with prompt text) or dismiss them.
  • Use frame_locator() (or content_frame) to work inside iframes, chain it for nested frames, and remember that iframes may be lazy-loaded.
  • Wrap clicks that open tabs in page.expect_popup() or context.expect_page(); each tab is a separate Page object.

Next, you will read and verify data in web tables.

🎬 Watch the Playwright tutorial series on YouTube!

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

Playwright Python Actions: click, fill, press, check, select_option, hover and drag_to

Locators find elements; actions use them. Playwright’s actions behave like a real user: they scroll the element into view, wait until it is ready, and then click, type or drag. In this lesson you will perform every common action on the SQA Tools practice page, learn the difference between fill and typing key by key, handle dropdowns, date fields, hover and drag and drop, and understand the actionability checks that make actions reliable.

📖 What You’ll Learn: click, double click, right click and modifier keys, fill, clear and press_sequentially, keyboard keys and shortcuts with press, check, uncheck and set_checked, select_option by value, label, index and multiple values, date and time inputs, hover, drag_to, focus and scrolling, the page.mouse and page.keyboard APIs, actionability checks, force and trial, and reading the “element is not enabled” error.


⚙️ What Happens When You Call an Action

Before any action, Playwright performs actionability checks and waits until they pass (up to the action timeout, 30 seconds by default):

CheckMeans
AttachedThe element is in the page
VisibleIt has a size and is not hidden
StableIt is not moving (for example, not animating)
Receives eventsNo other element (such as an overlay) covers it
EnabledIt is not disabled
EditableFor fill: it is not read-only

Then it scrolls the element into view and performs the action with real mouse and keyboard events. This is why Playwright tests rarely need waits.

Every example below uses this fixture to open the practice page:

📄 tests/conftest.py

import pytest
from playwright.sync_api import Page


@pytest.fixture
def practice(page: Page) -> Page:
    page.goto("https://sqatools.in/automation-practice-page/")
    return page

⌨️ Typing: fill, clear and press_sequentially

📄 tests/test_typing.py

from playwright.sync_api import Page, expect


def test_fill_and_clear(practice: Page):
    username = practice.get_by_placeholder("Enter username")

    username.fill("qa_learner")                 # replaces any existing text
    expect(username).to_have_value("qa_learner")

    username.fill("second_value")               # fill always replaces, never appends
    expect(username).to_have_value("second_value")

    username.clear()                            # empty the field
    expect(username).to_have_value("")

    # Multi-line text in a <textarea>
    address = practice.locator("#address")
    address.fill("Line 1\nLine 2")
    expect(address).to_have_value("Line 1\nLine 2")


def test_type_key_by_key(practice: Page):
    search = practice.get_by_placeholder("Search...")

    # The page shows suggestions on every key press (onkeyup), so fill() is not enough
    search.press_sequentially("pla", delay=100)
    expect(practice.locator("#suggestions div")).to_have_text(["Playwright"])

    practice.locator("#suggestions div").first.click()
    expect(search).to_have_value("Playwright")
  • fill(text) sets the value in one step and fires an input event. It is fast and right for most fields.
  • press_sequentially(text, delay=100) types one character at a time, firing key events for each, with an optional delay in milliseconds. Use it when the page reacts to individual key presses, such as this autocomplete box, which only listens for keyup events.
  • to_have_text([...]) with a list checks the texts of all matched elements, in order: exactly one suggestion, “Playwright”.

🎹 Keyboard Keys and Shortcuts: press

📄 tests/test_typing.py

def test_press_keys(practice: Page):
    key_box = practice.get_by_placeholder("Press any key")
    key_box.press("Enter")
    expect(practice.locator("#keyOutput")).to_have_text("Key Pressed: Enter")

    username = practice.get_by_placeholder("Enter username")
    username.fill("delete me")
    username.press("Control+A")                 # select all
    username.press("Backspace")                 # delete the selection
    expect(username).to_have_value("")
  • press(key) presses one key or a combination. Key names include Enter, Tab, Escape, Backspace, Delete, ArrowDown, PageDown, Home, F1 to F12, and single characters such as a.
  • Combine keys with +: Control+A, Shift+Tab. ControlOrMeta+A uses Control on Windows and Linux and Command on macOS, which is handy for cross-platform shortcuts.

🖱️ Clicking

📄 tests/test_clicks.py

from playwright.sync_api import Page, expect


def test_click_variants(practice: Page):
    button = practice.get_by_role("button", name="Normal Button")

    button.click()                              # left click
    button.dblclick()                           # double click
    button.click(button="right")                # right click (context menu)
    button.click(modifiers=["Shift"])           # Shift + click
    button.click(position={"x": 5, "y": 5})     # click at an offset inside the element
    expect(button).to_be_visible()


def test_link_inside_page(practice: Page):
    practice.get_by_role("link", name="Go to Bottom").click()
    expect(practice).to_have_url("https://sqatools.in/automation-practice-page/#bottom")
  • click() takes options: button= (“left”, “right”, “middle”), click_count=, modifiers= ([“Shift”], [“Control”], [“Alt”], [“Meta”]), position= and timeout=.
  • dblclick() is a double click.
  • Clicking the “Go to Bottom” link (href="#bottom") changes the URL fragment, which to_have_url verifies.

☑️ Checkboxes, Radios and Dropdowns

📄 tests/test_forms.py

from playwright.sync_api import Page, expect


def test_checkboxes_and_radios(practice: Page):
    java = practice.get_by_role("checkbox", name="Java")
    java.check()
    expect(java).to_be_checked()
    java.uncheck()
    expect(java).not_to_be_checked()
    java.set_checked(True)                      # handy when the state comes from test data
    expect(java).to_be_checked()

    practice.get_by_role("radio", name="Other").check()
    expect(practice.get_by_role("radio", name="Other")).to_be_checked()


def test_dropdowns(practice: Page):
    country = practice.locator("#country")

    print("\nby value:", country.select_option("usa"))
    expect(country).to_have_value("usa")

    print("by label:", country.select_option(label="UK"))
    print("by index:", country.select_option(index=1))
    expect(country).to_have_value("india")

    skills = practice.locator("#skills")         # <select multiple>
    print("multiple:", skills.select_option(["Python", "Playwright"]))
    expect(skills).to_have_values(["Python", "Playwright"])
  • check() / uncheck() set the state and verify it changed; clicking an already checked checkbox with check() does nothing, which keeps tests predictable.
  • set_checked(True/False) picks the state from a value.
  • select_option(value) matches an option by its value or its label. Use label= or index= to be explicit. It returns the list of selected values, which the test prints.
  • For a <select multiple>, pass a list; to_have_values([...]) checks all selected values.

🖥️ Output

by value: ['usa']
by label: ['uk']
by index: ['india']
multiple: ['Python', 'Playwright']

📅 Date and Time Inputs

Native date and time pickers are just inputs with a fixed value format, so use fill() with that format instead of clicking through a calendar:

📄 tests/test_forms.py

def test_date_and_time(practice: Page):
    practice.locator("#datePicker").fill("2026-11-01")              # YYYY-MM-DD
    practice.locator("#timePicker").fill("13:45")                   # HH:MM (24-hour)
    practice.locator("#dateTimePicker").fill("2026-11-01T09:30")    # YYYY-MM-DDTHH:MM

    expect(practice.locator("#datePicker")).to_have_value("2026-11-01")
    expect(practice.locator("#timePicker")).to_have_value("13:45")
    expect(practice.locator("#dateTimePicker")).to_have_value("2026-11-01T09:30")

The browser displays the date in the user’s local format, but the input’s value always uses this ISO-style format. Custom JavaScript date pickers (not native inputs) are different: you click their buttons like any other element.


🖐️ Hover, Drag and Drop, Focus and Scroll

📄 tests/test_mouse.py

from playwright.sync_api import Page, expect


def test_hover(practice: Page):
    button = practice.get_by_role("button", name="Hover Over Me")
    button.hover()
    expect(button).to_have_attribute("title", "Hover text")   # the tooltip text


def test_drag_and_drop(practice: Page):
    source = practice.locator("#drag1")
    target = practice.locator(".drop")
    source.drag_to(target)
    expect(target.locator("#drag1")).to_be_visible()          # the box is now inside the target


def test_focus_and_scroll(practice: Page):
    bottom = practice.get_by_role("heading", name="Bottom of Page")
    bottom.scroll_into_view_if_needed()
    expect(bottom).to_be_in_viewport()

    username = practice.get_by_placeholder("Enter username")
    username.focus()
    expect(username).to_be_focused()
  • hover() moves the mouse over the element, which triggers CSS :hover styles and menus that open on hover. The practice button only shows a browser tooltip from its title attribute, so the test checks that attribute.
  • drag_to(target) presses the mouse on the source, moves it to the target and releases it. After the drop, the page moved the box into the drop zone, so the chained locator target.locator("#drag1") finds it there.
  • Actions scroll automatically; scroll_into_view_if_needed() is for when you only want to bring something into view, and to_be_in_viewport() checks that it is visible on screen.
  • focus() puts the keyboard focus on an element; to_be_focused() checks it.

Low-level mouse and keyboard

For drawing, sliders or custom gestures, page.mouse and page.keyboard give raw control: page.mouse.move(x, y), page.mouse.down(), page.mouse.up(), page.mouse.wheel(0, 500), page.keyboard.type("text"), page.keyboard.press("Tab"). Prefer locator actions whenever possible, because they include the actionability checks.


🚫 When an Action Cannot Happen

The practice page has a disabled input. Trying to fill it waits for the “enabled” check and then times out:

📄 tests/test_mouse.py

import pytest
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError


def test_disabled_field(practice: Page):
    disabled = practice.locator("#disabledInput")
    expect(disabled).to_be_disabled()
    with pytest.raises(PlaywrightTimeoutError) as info:
        disabled.fill("x", timeout=2000)
    print("\n" + str(info.value))

🖥️ Output

Locator.fill: Timeout 2000ms exceeded.
Call log:
  - waiting for locator("#disabledInput")
    - locator resolved to <input disabled type="text" value="Disabled" id="disabledInput"/>
    - fill("x")
  - attempting fill action
    2 × waiting for element to be visible, enabled and editable
      - element is not enabled
    - retrying fill action
    - waiting 20ms
    2 × waiting for element to be visible, enabled and editable
      - element is not enabled
    - retrying fill action
      - waiting 100ms
    ...

Playwright kept retrying with growing pauses (20 ms, 100 ms, 500 ms…) until the 2-second timeout ran out. The call log tells you exactly which check failed: element is not enabled. When a real test times out, this is the first place to look: “not visible”, “not stable” or “element intercepts pointer events” (something is covering it) each point to a different cause.

force and trial

  • click(force=True) skips the actionability checks. Use it only when you are sure the check is wrong for your case; it can hide real bugs such as a button covered by a popup.
  • click(trial=True) performs the checks without clicking, which is a way to wait until an element is actionable.

▶️ Run Everything

⌨️ Terminal

pytest -q -s tests

🖥️ Output

12 passed, 1 warning in 28.29s

❓ Frequently Asked Questions

🤔 fill or press_sequentially?

Use fill() by default; it is faster and reliable. Use press_sequentially() only when the page reacts to individual key events, such as autocomplete, input masks or character counters.

🤔 Why did my click “time out” although the button is visible?

Read the call log: usually another element intercepts the click (a cookie banner, a loading overlay) or the button is disabled or still animating. Fix the cause rather than using force=True.

🤔 How do I select an option whose value I do not know?

Use select_option(label="Visible text"), or index= if the order is fixed.

🤔 Is type() still available?

locator.type() is deprecated. Use fill() or, for key-by-key typing, press_sequentially().


🏁 Summary

  • Actions wait for actionability (attached, visible, stable, receives events, enabled, editable) and act like real users.
  • fill replaces text; press_sequentially types key by key; press handles keys and shortcuts.
  • check, uncheck, set_checked and select_option (value, label, index, multiple) cover form controls; date inputs take ISO-style values.
  • hover, drag_to, focus and scrolling cover the rest; the call log explains any action that cannot happen.

Next, you will handle JavaScript dialogs, iframes, popups and new tabs.

🎬 Watch the Playwright tutorial series on YouTube!

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

CSS and XPath Selectors in Playwright Python (with Playwright Pseudo-classes)

The get_by_* locators cover most needs, but sometimes you need a CSS or XPath selector: an element with no accessible name, a structure-based relationship, or an existing Selenium suite you are migrating. page.locator() accepts both. In this lesson you will write CSS selectors from simple to advanced, use Playwright’s extra pseudo-classes, write XPath with axes, and learn the trade-offs that decide which to use.

📖 What You’ll Learn: page.locator with CSS and XPath, CSS ids, classes, attributes and attribute operators, descendant and child combinators, :nth-child and :checked, Playwright’s :has-text, :text, :has and :visible pseudo-classes, XPath paths, predicates, text() and contains(), axes such as following-sibling and ancestor, explicit css= and xpath= prefixes, and a checklist for choosing selectors.


🧭 page.locator(): CSS or XPath

page.locator(selector) accepts a CSS selector or an XPath expression and returns a normal Locator, with the same auto-waiting, strictness and filtering as the get_by_* locators:

  • A selector starting with // or .. is treated as XPath.
  • Everything else is treated as CSS (Playwright also understands a few extra pseudo-classes, shown below).
  • You can be explicit with a prefix: css=... or xpath=....

All examples in this lesson run on the SQA Tools practice page. Each test receives the opened page from this fixture:

📄 tests/conftest.py

import pytest
from playwright.sync_api import Page


@pytest.fixture
def practice(page: Page) -> Page:
    page.goto("https://sqatools.in/automation-practice-page/")
    return page

🎨 CSS Selectors from Simple to Advanced

📄 tests/test_css.py

from playwright.sync_api import Page, expect


def test_css_basics(practice: Page):
    # By id: #id
    practice.locator("#username").fill("qa_learner")

    # By tag + attribute: tag[attr='value']
    practice.locator("input[name='password']").fill("Secret@123")

    # By class: .class  (the drag box has class "drag")
    expect(practice.locator(".drag")).to_contain_text("Drag Me")

    # Attribute only, and tag + id
    expect(practice.locator("[placeholder='Search...']")).to_be_visible()
    expect(practice.locator("select#country")).to_have_value("")

    # Several attributes at once
    expect(practice.locator("input[type='radio'][value='female']")).to_have_id("female")
PatternMeaningExample
#idElement with this id#username
.classElement with this class.drag
tagElements of this tagselect
[attr]Has the attribute[disabled]
[attr='v']Attribute equals[name='password']
tag#id, tag.classCombinedselect#country
  • to_contain_text("Drag Me") is used instead of to_have_text on purpose: on the live site, an advertising script inserts a “Try Cloud Tools” link inside the drag box, so its full text is not just “Drag Me”. Real pages often contain injected content; check for the text that matters to your test.
  • The empty value of select#country is the “–Select Country–” placeholder option, which has value="".
  • to_have_id() checks the element’s id attribute.

Attribute operators

📄 tests/test_css.py

def test_css_attribute_operators(practice: Page):
    # ^= starts with, $= ends with, *= contains
    expect(practice.locator("a[href^='https://www.google']")).to_have_id("googleLink")
    expect(practice.locator("input[id$='Picker']")).to_have_count(3)     # date, time, datetime
    expect(practice.locator("input[placeholder*='username']")).to_have_count(1)
OperatorMeaning
[attr^='x']Value starts with x
[attr$='x']Value ends with x
[attr*='x']Value contains x

Relationships: descendant, child, sibling and position

📄 tests/test_css.py

def test_css_relationships(practice: Page):
    # Descendant (space): any depth.   Child (>): direct children only.
    expect(practice.locator("table tbody tr")).to_have_count(3)
    expect(practice.locator("table > tbody > tr > td")).to_have_count(9)

    # :nth-child(n) is 1-based: the 2nd row, 2nd cell
    expect(practice.locator("table tbody tr:nth-child(2) td:nth-child(2)")).to_have_text("Rahul")

    # Adjacent sibling (+): the label right after the Python checkbox
    expect(practice.locator("#python + label")).to_have_text("Python")

    # State pseudo-classes: :checked, :disabled
    practice.locator("#java").check()
    expect(practice.locator("input[type='checkbox']:checked")).to_have_count(1)
    expect(practice.locator("input:disabled")).to_have_id("disabledInput")
  • A space means descendant (inside, at any depth); > means direct child.
  • :nth-child(2) counts from 1 in CSS, unlike Playwright’s nth(), which counts from 0.
  • + selects the element immediately after another one at the same level.
  • :checked and :disabled select elements by their current state.

✨ Playwright’s Extra Pseudo-classes

Playwright adds pseudo-classes that plain CSS does not have:

📄 tests/test_css.py

def test_playwright_pseudo_classes(practice: Page):
    # :has-text("...") - element whose text contains the string (case-insensitive)
    expect(practice.locator("tr:has-text('Rahul')")).to_contain_text("Tester")

    # :text("...") - the smallest element containing the text
    expect(practice.locator("td:text('Anita')")).to_have_count(1)

    # :has(selector) - element containing a matching element (standard CSS in modern browsers)
    expect(practice.locator("tr:has(td:text-is('Developer'))")).to_contain_text("Anita")

    # :visible - only visible elements
    expect(practice.locator("input[type='text']:visible")).to_have_count(6)
  • :has-text("x") matches any element whose text contains x, including parents; combine it with a tag (tr:has-text(...)) to avoid matching the whole page.
  • :text("x") matches the smallest element that contains x; :text-is("x") requires the exact full text.
  • :has(...) keeps elements that contain a match.
  • :visible drops hidden matches (here the hidden input), like filter(visible=True).

🛤️ XPath

XPath navigates the document as a tree. It can do one thing CSS cannot: move up to parents and ancestors.

📄 tests/test_xpath.py

from playwright.sync_api import Page, expect


def test_xpath_basics(practice: Page):
    # Absolute-anywhere path with an attribute predicate
    practice.locator("//input[@id='username']").fill("xpath_user")
    expect(practice.locator("#username")).to_have_value("xpath_user")

    # Exact text and partial text
    expect(practice.locator("//button[text()='Submit']")).to_be_visible()
    expect(practice.locator("//button[contains(text(), 'Alert')]")).to_have_count(3)

    # Positions are 1-based; (...)[n] picks the nth match overall
    expect(practice.locator("//table/tbody/tr[2]/td[2]")).to_have_text("Rahul")
    expect(practice.locator("(//table//td)[last()]")).to_have_text("Developer")


def test_xpath_axes(practice: Page):
    # following-sibling: the cell after "Rahul" in the same row
    expect(practice.locator("//td[text()='Rahul']/following-sibling::td")).to_have_text("Tester")

    # preceding-sibling: the cell before "Rahul"
    expect(practice.locator("//td[text()='Rahul']/preceding-sibling::td")).to_have_text("2")

    # Going UP: the row (ancestor) that contains "Anita"
    expect(practice.locator("//td[text()='Anita']/ancestor::tr")).to_contain_text("Developer")

    # .. is the parent: the section around the "Iframe" heading
    expect(practice.locator("//h2[text()='Iframe']/..").locator("iframe")).to_have_id("sampleIframe")
XPathMeaning
//tagtag anywhere in the page
[@attr='v']Attribute equals
[text()='x'] / [contains(text(), 'x')]Exact / partial text
[2], [last()]Position (1-based)
/following-sibling::tdSiblings after the element
/preceding-sibling::tdSiblings before the element
/ancestor::trAn ancestor of the element
/..The parent element
  • A path in parentheses with an index, (//table//td)[last()], picks from all matches in the page, while //td[last()] would pick the last cell of each row.
  • Chaining .locator("iframe") after an XPath locator searches inside it, just like with CSS.

⚠️ Watch out: XPath does not pierce shadow DOM, and long absolute paths such as /html/body/div[3]/div/main/... break whenever the layout changes. Keep XPath short and anchored on stable text or attributes.


🔀 Mixing Selectors with Locators

You do not have to choose one style per test. Chain whatever reads best:

📄 tests/test_xpath.py

def test_mixing(practice: Page):
    row = practice.get_by_role("row").filter(has_text="Deepesh")       # role + filter
    expect(row.locator("td").nth(2)).to_have_text("Trainer")           # CSS inside it
    expect(row.locator("xpath=./td[1]")).to_have_text("1")             # XPath relative to the row
    expect(practice.locator("css=#country option")).to_have_count(5)   # explicit css= prefix
  • xpath=./td[1] uses an explicit prefix and a relative path (./), so it searches from the row, not from the page root.
  • css= makes the selector type explicit; it is optional for CSS.

⌨️ Terminal

pytest -q -s tests

🖥️ Output

.......                                                                  [100%]
7 passed, 1 warning in 14.25s

✅ Choosing a Selector: Checklist

  1. Can you use get_by_role with a name? Do it.
  2. Is it a form field with a label or placeholder? Use get_by_label or get_by_placeholder.
  3. Does the element have a stable id or test attribute? #id or get_by_test_id is fine.
  4. Need structure (a cell next to another cell, a parent)? Use a short CSS or XPath anchored on text or attributes.
  5. Avoid: auto-generated classes (.css-1x2y3z), long absolute paths, and indexes that depend on layout.

❓ Frequently Asked Questions

🤔 CSS or XPath: which is faster?

The difference is negligible in Playwright tests. Choose for readability and stability; CSS is usually shorter, XPath is needed to move up the tree.

🤔 Why does my CSS :nth-child(1) not match the first row?

:nth-child counts all sibling elements of any type, starting at 1. Check that the parent has no other children before the row, or use Playwright’s .nth(0) / .first on the locator instead.

🤔 Can I reuse my Selenium XPath locators?

Yes, page.locator("//...") accepts them. It is a good migration path; then gradually replace fragile ones with role-based locators.

🤔 Is :has-text case-sensitive?

No. :has-text() and :text() are case-insensitive and ignore extra whitespace; :text-is() needs the exact text.


🏁 Summary

  • page.locator() takes CSS by default and XPath for selectors starting with //; prefixes css= and xpath= make it explicit.
  • CSS: ids, classes, attributes and ^=, $=, *=, combinators (space, >, +), :nth-child, :checked, :disabled.
  • Playwright extras: :has-text, :text, :text-is, :has, :visible.
  • XPath adds axes (following-sibling, preceding-sibling, ancestor, ..); keep selectors short and anchored, and prefer get_by_role where possible.

Next, you will perform every kind of user action: clicks, typing, keyboard keys, hover, drag and drop, dropdowns and more.

🎬 Watch the Playwright tutorial series on YouTube!

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

Playwright Python Locators: get_by_role, get_by_label, Filtering and Strictness

Locators are how Playwright finds elements, and choosing good ones is the single biggest factor in whether your tests are stable or flaky. In this lesson you will learn the built-in get_by_* locators and the order to prefer them in, how to narrow results with filter, nth and chaining, why Playwright refuses ambiguous locators (strictness), and how locators reach into shadow DOM, all on the SQA Tools Automation Practice Page.

📖 What You’ll Learn: what a locator is and why it is lazy, the recommended locator priority, get_by_role with name, exact and state options, get_by_label, get_by_placeholder, get_by_text, get_by_alt_text, get_by_title and get_by_test_id, strictness and the real strict mode violation error, first, last and nth, filter with has_text, has and visible, chaining and combining with and_ and or_, counting and reading many elements, and shadow DOM.


🎯 What Is a Locator?

A locator describes how to find element(s) on the page. Creating one does not search the page; the search happens each time you act on it or assert with it. Two consequences make tests stable:

  • If the page re-renders an element, the locator simply finds the new one; you never hold a “stale” reference.
  • Every action and expect() waits until the locator matches an element in the right state.
username = page.get_by_placeholder("Enter username")   # nothing searched yet
username.fill("qa_learner")                            # found, waited for, filled

🏅 Which Locator Should I Use?

Playwright recommends locators that reflect what users see, because they survive changes to CSS classes and page structure:

PriorityLocatorFinds elements by
1get_by_role(role, name=...)ARIA role and accessible name: buttons, links, checkboxes, headings, rows…
2get_by_label(text)The text of the <label> linked to a form field
3get_by_placeholder(text)An input’s placeholder
4get_by_text(text)Visible text (for non-interactive elements)
5get_by_alt_text(text) / get_by_title(text)An image’s alt text / a title attribute
6get_by_test_id(id)A dedicated test attribute such as data-testid
7locator("css or xpath")CSS or XPath selectors (next lesson)

The rest of this lesson tests each of them on the practice page. All examples live in tests/test_locators.py and share one fixture that opens the page:

📄 tests/conftest.py

import pytest
from playwright.sync_api import Page

URL = "https://sqatools.in/automation-practice-page/"


@pytest.fixture
def practice(page: Page) -> Page:
    page.goto(URL)
    return page

🧭 get_by_role: The First Choice

📄 tests/test_locators.py

import re

from playwright.sync_api import Page, expect


def test_get_by_role(practice: Page):
    # Buttons, matched by their accessible name (the visible text)
    expect(practice.get_by_role("button", name="Submit")).to_be_visible()

    # Headings, optionally by level (h2 = level 2)
    expect(practice.get_by_role("heading", name="Web Table", level=2)).to_be_visible()

    # Radio buttons and checkboxes, by their label
    practice.get_by_role("radio", name="Female").check()
    expect(practice.get_by_role("radio", name="Female")).to_be_checked()

    # State filters: only checkboxes that are currently checked
    practice.get_by_role("checkbox", name="Python").check()
    expect(practice.get_by_role("checkbox", checked=True)).to_have_count(1)

    # Links and table rows
    expect(practice.get_by_role("link", name="Open Google")).to_have_attribute("target", "_blank")
    expect(practice.get_by_role("row", name="2 Rahul Tester")).to_be_visible()
  • The first argument is the role: button, link, textbox, checkbox, radio, combobox (a dropdown), heading, row, cell, img and many more. HTML elements have implicit roles: <button> is a button, <a href> is a link, <input type="checkbox"> is a checkbox.
  • name= matches the accessible name: the button text, the link text, the label of a field. By default the match is case-insensitive and also matches a substring. Pass exact=True for an exact, case-sensitive match, or a compiled regex for patterns.
  • Extra options narrow the match: level= for headings, checked=, disabled=, expanded=, selected= and pressed= for states.
  • A table row’s accessible name is the text of its cells joined with spaces, which is why "2 Rahul Tester" matches the second data row.

💡 Tip: Not sure what role or name an element has? Use Codegen’s Pick locator (Lesson 7), or print page.locator("body").aria_snapshot() to see the page the way get_by_role sees it.


🏷️ Placeholder, Label, Text, Alt Text and Title

📄 tests/test_locators.py

def test_other_get_by_locators(practice: Page):
    # Placeholder text of an input
    practice.get_by_placeholder("Enter username").fill("qa_learner")
    expect(practice.get_by_placeholder("Enter username")).to_have_value("qa_learner")

    # The label next to a checkbox: <input id="python"> <label for="python">Python</label>
    expect(practice.get_by_label("Python", exact=True)).not_to_be_checked()

    # Visible text of a non-interactive element
    expect(practice.get_by_text("This is the bottom of the page for scrolling practice.")).to_be_attached()

    # An image's alt text, and an element's title attribute
    expect(practice.get_by_alt_text("Sample Image")).to_be_visible()
    expect(practice.get_by_title("Hover text")).to_have_text("Hover Over Me")
  • get_by_label finds the form control that a <label> points to (or wraps). It is the best choice for forms with proper labels.
  • get_by_text is for content such as paragraphs and messages. For buttons and links, prefer get_by_role(..., name=...), which also checks the element type.
  • exact=True matters here: without it, get_by_label("Python") would also match any label that merely contains “Python”.
  • to_be_attached() checks that the element exists in the page; to_be_visible() additionally needs it to be visible.

🧪 get_by_test_id and a Custom Test Attribute

Many teams add attributes purely for testing, such as data-testid="login-button". get_by_test_id("login-button") finds them. By default Playwright looks at data-testid; if your app uses another name, change it once, for example in a session fixture in conftest.py:

import pytest


@pytest.fixture(scope="session", autouse=True)
def configure_test_id(playwright):
    playwright.selectors.set_test_id_attribute("data-test")

Sauce Demo uses data-test, so after this setting page.get_by_test_id("login-button") finds its login button. Test IDs are very stable, but users do not see them, so role-based locators are still preferred where they work.

📄 tests/test_test_id.py

import pytest
from playwright.sync_api import Page, expect


@pytest.fixture(scope="session", autouse=True)
def configure_test_id(playwright):
    playwright.selectors.set_test_id_attribute("data-test")


def test_get_by_test_id(page: Page):
    page.goto("https://www.saucedemo.com/")
    page.get_by_test_id("username").fill("standard_user")
    page.get_by_test_id("password").fill("secret_sauce")
    page.get_by_test_id("login-button").click()
    expect(page.get_by_test_id("title")).to_have_text("Products")

⚠️ Strictness: One Locator, One Element

When you act on a locator (click, fill, check…), it must match exactly one element. If it matches several, Playwright refuses to guess and raises a *strict mode violation*. The practice page has a perfect example: its “Click to Replace Me” button adds a second button with the same id, which is invalid HTML but common in real apps:

📄 tests/test_strictness.py

import pytest
from playwright.sync_api import Error, Page, expect


def test_strict_mode_violation(practice: Page):
    practice.get_by_role("button", name="Click to Replace Me").click()

    with pytest.raises(Error) as info:
        practice.locator("#staleBtn").click(timeout=2000)
    print("\n" + str(info.value).splitlines()[0])

    # Fix 1: a more specific locator
    expect(practice.get_by_role("button", name="New Button (Old is Stale)")).to_be_visible()
    # Fix 2: say which match you want
    expect(practice.locator("#staleBtn").last).to_have_text("New Button (Old is Stale)")
    # Counting is allowed: assertions on many elements are not strict
    expect(practice.locator("#staleBtn")).to_have_count(2)

🖥️ Output

Locator.click: Error: strict mode violation: locator("#staleBtn") resolved to 2 elements:

The full error message lists both elements and even suggests better locators:

🖥️ Output

1) <button id="staleBtn" onclick="replaceElement()">Click to Replace Me</button> aka get_by_role("button", name="Click to Replace Me")
2) <button id="staleBtn">New Button (Old is Stale)</button> aka get_by_role("button", name="New Button (Old is Stale)")

Strictness is a feature: without it, a test could click the wrong one of two “Delete” buttons and still pass. Fix violations by making the locator more specific, or by choosing explicitly with .first, .last or .nth(index).


🔬 Narrowing Down: nth, filter and Chaining

📄 tests/test_locators.py

def test_narrowing(practice: Page):
    rows = practice.get_by_role("row")                     # header + 3 data rows

    # Position: first, last, nth (0-based)
    expect(rows.first).to_contain_text("ID Name Role")
    expect(rows.nth(1)).to_contain_text("Deepesh")
    expect(rows.last).to_contain_text("Anita")

    # filter(has_text=...): keep elements that contain the text
    tester_row = rows.filter(has_text="Tester")
    expect(tester_row).to_have_count(1)

    # Chaining: search inside another locator
    expect(tester_row.get_by_role("cell").first).to_have_text("2")

    # filter(has=...): keep elements that contain a matching child
    trainer = practice.get_by_role("row").filter(has=practice.get_by_role("cell", name="Trainer"))
    expect(trainer).to_contain_text("Deepesh")

    # Data rows contain "cell"s (the header row has "columnheader"s); has_not_text excludes
    data_rows = rows.filter(has=practice.get_by_role("cell"))
    expect(data_rows).to_have_count(3)
    expect(data_rows.filter(has_not_text="Anita")).to_have_count(2)

    # has_text also accepts a regular expression
    expect(rows.filter(has_text=re.compile(r"Rahul|Anita"))).to_have_count(2)
  • .first, .last and .nth(i) pick by position. Use them deliberately: position-based locators break when the order changes.
  • filter(has_text=...) keeps matches containing the text (or matching a regex); has_not_text= does the opposite.
  • filter(has=locator) keeps elements that contain an element matching the inner locator; has_not= excludes them.
  • Calling a locator method on another locator (tester_row.get_by_role("cell")) searches inside it. This is called chaining and is the cleanest way to scope a search to one card, row or dialog.
  • filter(has=practice.get_by_role("cell")) keeps only data rows, because the header row’s cells have the role columnheader, not cell. This is more robust than matching text.
  • re.compile(r"Rahul|Anita") keeps rows whose text contains either name. Regex filters match anywhere in the element’s text, which includes surrounding whitespace, so avoid anchors like ^ unless you account for it.

Visible elements only, and combining locators

📄 tests/test_locators.py

def test_visible_and_combinations(practice: Page):
    # The hidden input exists but is not visible; filter(visible=True) skips it
    text_inputs = practice.locator("input[type='text']")
    print(f"\ntext inputs: {text_inputs.count()}, visible: {text_inputs.filter(visible=True).count()}")
    expect(practice.locator("#hiddenField")).to_be_hidden()

    # and_: both conditions must match the same element
    submit = practice.get_by_role("button").and_(practice.locator("#submitButton"))
    expect(submit).to_have_text("Submit")

    # or_: either locator (handy when a page shows one of two variants)
    alert_or_confirm = practice.get_by_role("button", name="Simple Alert").or_(
        practice.get_by_role("button", name="Confirm Alert"))
    expect(alert_or_confirm).to_have_count(2)
  • filter(visible=True) keeps only visible matches.
  • and_() requires both locators to match the same element; or_() matches elements of either.

📋 Reading Many Elements

📄 tests/test_locators.py

def test_reading_lists(practice: Page):
    options = practice.locator("#country option")
    print("\ncountries:", options.all_inner_texts())

    names = [row.get_by_role("cell").nth(1).inner_text()
             for row in practice.locator("table tbody tr").all()]
    print("names:", names)
    assert names == ["Deepesh", "Rahul", "Anita"]
  • count() returns how many elements match right now.
  • all_inner_texts() returns a list of the visible texts.
  • all() returns a list of locators, one per match, to loop over. Like count(), it does not wait; make sure the list has loaded (for example with an expect) before calling it.

🌘 Shadow DOM

Web components hide their inner elements in a shadow root. Playwright’s role, text, label and CSS locators pierce open shadow roots automatically, even nested ones. The practice page has a button two shadow roots deep:

📄 tests/test_locators.py

def test_shadow_dom(practice: Page):
    practice.get_by_placeholder("Nested Shadow Input").fill("inside the shadow DOM")
    expect(practice.get_by_placeholder("Nested Shadow Input")).to_have_value("inside the shadow DOM")
    expect(practice.locator("#shadowBtn")).to_have_text("Shadow Button")
    expect(practice.get_by_text("Nested Shadow DOM Text")).to_be_visible()

No special syntax is needed. (XPath is the exception: it does not pierce shadow roots, one more reason to prefer the other locators.)


▶️ Run All Examples

⌨️ Terminal

pytest -q -s tests

🖥️ Output

...
text inputs: 7, visible: 6
.
countries: ['–Select Country–', 'India', 'USA', 'UK', 'Australia']
names: ['Deepesh', 'Rahul', 'Anita']
..
Locator.click: Error: strict mode violation: locator("#staleBtn") resolved to 2 elements:
..
8 passed, 1 warning in 16.91s

Seven text inputs exist but only six are visible: the seventh is the hidden field. The warning is the pytest-playwright notice you can silence with filterwarnings (Lesson 29).


❓ Frequently Asked Questions

🤔 Why does get_by_role(“button”, name=”Submit”) also match “Submit order”?

Name matching is a case-insensitive substring match by default. Use exact=True or a regex such as re.compile(r"^Submit$") when other buttons contain the same word.

🤔 Is get_by_text fine for buttons?

It works, but get_by_role("button", name=...) is better: it also checks that the element really is a button and ignores matching text elsewhere.

🤔 Do locators wait for elements?

Actions and expect() wait (up to their timeouts). Methods that return information immediately, such as count(), all() and is_visible(), do not.

🤔 What does “resolved to 2 elements” mean?

The locator matched two elements while an action needed exactly one. Make it more specific, scope it by chaining, or use .first, .last or .nth() on purpose.


🏁 Summary

  • Prefer user-facing locators: get_by_role first, then label, placeholder, text, alt text, title and test id; CSS/XPath last.
  • Locators are lazy and re-evaluated on every use; actions and expect wait for them.
  • Strictness rejects ambiguous locators; fix with specificity, chaining, filter, or explicit first/last/nth.
  • filter(has_text=, has=, visible=), and_, or_, all() and all_inner_texts() cover almost every real page, and locators pierce open shadow DOM.

Next, you will learn CSS and XPath selectors for the cases where the get_by locators are not enough.

🎬 Watch the Playwright tutorial series on YouTube!

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

pytest Plugins and Hooks: pytest_addoption, Custom Options and Screenshot on Failure

pytest is built from plugins. Even its core features, such as assertion rewriting and the terminal report, are plugins, and pytest-playwright is one too. In this lesson you will install and control third-party plugins, then write your own hooks in conftest.py: a custom –env command-line option, automatic markers based on folder names, a header line with run information, and a screenshot-on-failure hook for Playwright tests that attaches the image to the test report.

📖 What You’ll Learn: what plugins and hooks are, installing and listing plugins, three plugins every Playwright team uses (rerunfailures, xdist, pytest-html), disabling plugins with -p no:, adding command-line options with pytest_addoption, reading them in fixtures, pytest_report_header, pytest_collection_modifyitems, and a pytest_runtest_makereport hook wrapper that saves a screenshot when a test fails.


🔌 Plugins: pytest’s Building Blocks

A plugin is a Python package (or a conftest.py file) that adds behaviour to pytest by implementing hook functions: functions with special names, starting with pytest_, that pytest calls at defined moments. pytest itself is assembled from dozens of internal plugins, and the ecosystem has well over a thousand external ones.

Installed plugins activate automatically. The run header lists them:

🖥️ Output

plugins: html-4.2.0, metadata-3.1.1, playwright-0.10.0, rerunfailures-16.7, xdist-3.8.0

(pytest-metadata is installed automatically as a dependency of pytest-html.)

Three plugins every Playwright Python team uses

PluginInstallWhat it adds
pytest-rerunfailurespip install pytest-rerunfailures--reruns N re-runs failed tests (Module 6)
pytest-xdistpip install pytest-xdist-n auto runs tests in parallel processes (Module 12)
pytest-htmlpip install pytest-html--html=report.html writes an HTML report (Module 11)

A quick taste of the first two. This test fails on its first attempt only (plugins_demo/test_flaky.py):

📄 plugins_demo/test_flaky.py

import pathlib


def test_sometimes_fails(tmp_path_factory):
    marker = tmp_path_factory.getbasetemp() / "attempted"
    if not marker.exists():
        marker.touch()
        assert False, "first attempt fails"
    assert True

⌨️ Terminal

pytest -q --reruns 2 plugins_demo/test_flaky.py

🖥️ Output

R.                                                                       [100%]
1 passed, 1 rerun in 0.03s

R marks the rerun: the first attempt failed, the second passed. And pytest-xdist spreads tests over worker processes (plugins_demo/test_parallel.py):

📄 plugins_demo/test_parallel.py

import os
import time

import pytest


@pytest.mark.parametrize("n", range(4))
def test_slow_step(n):
    time.sleep(1)
    assert os.environ.get("PYTEST_XDIST_WORKER", "").startswith("gw")

⌨️ Terminal

pytest -q -n 2 plugins_demo/test_parallel.py

🖥️ Output

bringing up nodes...
bringing up nodes...
....                                                                     [100%]
4 passed in 2.98s

Four tests of one second each finished in under 3 seconds with two workers (gw0 and gw1, including the time to start them), instead of 4+ seconds in one process. Each worker sets the PYTEST_XDIST_WORKER environment variable, which the test checks.

Disabling plugins

  • -p no:NAME disables one plugin for a run, for example -p no:playwright (you used it in earlier lessons) or -p no:cacheprovider.
  • The environment variable PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 disables all automatically loaded plugins; you then enable the ones you need with -p NAME.

🪝 Hooks in conftest.py

You do not need to publish a package to write a plugin: hook functions in conftest.py are a local plugin. All the hooks below live in the project’s root conftest.py.

1. A custom command-line option: pytest_addoption

Real suites run against several environments. Add an --env option and turn it into a fixture:

📄 conftest.py

import pathlib

import pytest

ENVIRONMENTS = {
    "qa": "https://qa.example.com",
    "staging": "https://staging.example.com",
    "prod": "https://sqatools.in",
}


def pytest_addoption(parser):
    parser.addoption(
        "--env",
        action="store",
        default="staging",
        choices=sorted(ENVIRONMENTS),
        help="environment to test against: qa, staging or prod",
    )


@pytest.fixture(scope="session")
def env_name(pytestconfig) -> str:
    return pytestconfig.getoption("--env")


@pytest.fixture(scope="session")
def app_url(env_name: str) -> str:
    return ENVIRONMENTS[env_name]


# 2. Show the environment in the report header
def pytest_report_header(config):
    return f"environment: {config.getoption('--env')}"


# 3. Mark every test in tests/smoke automatically
def pytest_collection_modifyitems(config, items):
    for item in items:
        if "smoke" in item.path.parts:
            item.add_marker(pytest.mark.smoke)


# 4. Screenshot on failure, attached to the report
@pytest.hookimpl(wrapper=True)
def pytest_runtest_makereport(item, call):
    report = yield                                   # let pytest build the report first
    page = item.funcargs.get("page")
    if report.when == "call" and report.failed and page is not None:
        folder = pathlib.Path("test-results") / "failures"
        folder.mkdir(parents=True, exist_ok=True)
        path = folder / f"{item.name}.png".replace("[", "_").replace("]", "")
        page.screenshot(path=str(path), full_page=True)
        report.sections.append(("screenshot", str(path)))
    return report

Let us go through each hook.

**pytest_addoption(parser)** registers new options. parser.addoption accepts the same arguments as Python’s argparse: action, default, choices (only these values are accepted) and help (shown in pytest --help). Option hooks must be in the root conftest.py or a plugin, because pytest reads them before collecting tests.

Reading the option: the env_name fixture uses the built-in pytestconfig fixture and getoption("--env"). app_url builds on it, so tests simply ask for app_url.

📄 pytest.ini

[pytest]
testpaths = tests
markers =
    smoke: quick checks of the most important flows
filterwarnings =
    ignore:Session-scoped fixtures defined as instance methods:pytest.PytestRemovedIn10Warning

📄 tests/test_env.py

from playwright.sync_api import Page, expect


def test_env_url(env_name: str, app_url: str):
    print(f"\n  testing {env_name} at {app_url}")
    assert app_url.startswith("https://")


def test_env_home_page(page: Page, env_name: str, app_url: str):
    if env_name != "prod":
        return                                   # the example qa/staging hosts are placeholders
    page.goto(app_url + "/automation-practice-page/")
    expect(page).to_have_title("Automation Practice Page - SQA Tools")

⌨️ Terminal

pytest --env prod -k env

🖥️ Output

============================= test session starts =============================
platform win32 -- Python 3.15.1, pytest-9.2.0, pluggy-1.7.0
environment: prod
rootdir: C:\pwv\pytest-plugins-hooks
configfile: pytest.ini
testpaths: tests
plugins: html-4.2.0, metadata-3.1.1, playwright-0.10.0, rerunfailures-16.7, xdist-3.8.0
collected 4 items / 2 deselected / 2 selected

tests\test_env.py ..                                                     [100%]

======================= 2 passed, 2 deselected in 2.81s =======================

The line environment: prod in the header comes from **pytest_report_header**, which returns text (or a list of lines) to show at the top of a run. It records which environment a CI log belongs to. The header is hidden in quiet mode (-q), so run without -q when you need it.

An invalid value is rejected before any test runs, thanks to choices:

⌨️ Terminal

python -m pytest -q --env qaa

🖥️ Output

ERROR: usage: python -m pytest [options] [file_or_dir] [file_or_dir] [...]
python -m pytest: error: argument --env: invalid choice: 'qaa', maybe you meant 'qa'? (choose from 'prod', 'qa', 'staging')

pytest exits with code 4 (usage error), and Python’s argparse even suggests the closest valid value.

(With --env qa the tests run against the placeholder URL https://qa.example.com, which is just an example host here.)

3. Changing collected tests: pytest_collection_modifyitems

This hook receives the list of collected test items after collection, and may add markers, reorder or deselect them. The version above adds the smoke marker to every test inside a tests/smoke/ folder, so nobody can forget the decorator:

📄 tests/smoke/test_login_smoke.py

from playwright.sync_api import Page, expect


def test_login_button_visible(page: Page):
    page.goto("https://sqatools.in/login-page/")
    expect(page.get_by_role("button", name="Log in")).to_be_visible()

⌨️ Terminal

pytest -q --collect-only -m smoke

🖥️ Output

tests/smoke/test_login_smoke.py::test_login_button_visible[chromium]

1/4 tests collected (3 deselected) in 0.01s

The test has no @pytest.mark.smoke decorator, yet -m smoke selected it, because the hook marked it by folder. (item.path is the test file’s path as a pathlib.Path, and .parts splits it into folder names.)

4. Screenshot on failure: pytest_runtest_makereport

pytest-playwright already has --screenshot only-on-failure. Writing the hook yourself shows how such features work, and lets you customise them, for example by attaching the path to the report or uploading to a test-management tool.

  • @pytest.hookimpl(wrapper=True) makes the hook a wrapper: code before yield runs before pytest’s own implementation, yield returns the finished report, and the wrapper must return it.
  • pytest calls this hook three times per test: for setup, call (the test body) and teardown. report.when == "call" and report.failed selects a failure in the test body.
  • item.funcargs holds the fixture values of the test; if it used page, we take a full-page screenshot.
  • report.sections.append(...) adds a section to the failure report, so the path is printed with the failure details.

A deliberately failing test (tests/test_broken.py):

📄 tests/test_broken.py

from playwright.sync_api import Page, expect


def test_broken_heading(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")
    expect(page.get_by_role("heading", name="Web Table")).to_have_text("Web Tables", timeout=1000)

⌨️ Terminal

pytest -q -k broken

🖥️ Output

E       AssertionError: Locator expected to have text 'Web Tables'
E       Actual value: Web Table
...
tests\test_broken.py:6: AssertionError
--------------------------------- screenshot ----------------------------------
test-results\failures\test_broken_heading_chromium.png
=========================== short test summary info ===========================
FAILED tests/test_broken.py::test_broken_heading[chromium] - AssertionError: ...
1 failed, 3 deselected in 3.93s

The “screenshot” section printed under the failure is the one the hook appended to report.sections.

The screenshot file exists next to the report:

🖥️ Output

test_broken_heading_chromium.png

⚠️ Watch out: pytest-playwright clears test-results at the start of every run (Lesson 5). Save custom artifacts in a sub-folder you create during the run, as here, or in a different folder.


📚 Other Useful Hooks

HookCalled whenTypical use
pytest_configure(config)After options are parsedRegister markers in code, set global state
pytest_sessionstart(session) / pytest_sessionfinish(session, exitstatus)Start / end of the whole runStart or stop services, write a summary file
pytest_runtest_setup(item)Before each test’s setupSkip tests based on markers or options
pytest_terminal_summary(terminalreporter, exitstatus, config)At the end of the terminal outputPrint custom statistics

❓ Frequently Asked Questions

🤔 Should I write hooks in conftest.py or make a plugin package?

Start in conftest.py. When several projects need the same hooks, move them into a package and install it; the code stays almost the same.

🤔 Why do I see two versions of hook wrappers online?

Older examples use @pytest.hookimpl(hookwrapper=True) with outcome = yield and outcome.get_result(). The newer form shown here, @pytest.hookimpl(wrapper=True) with report = yield and return report, is the recommended style in current pytest and pluggy.

🤔 Can I make my custom option required?

You can set required=True in parser.addoption, but a sensible default is usually friendlier, so a plain pytest still works.

🤔 How do I find all available hooks?

The pytest documentation’s “API Reference” lists every hook with its arguments. Plugins such as pytest-xdist add their own hooks too.


🏁 Summary

  • Plugins add features through hook functions; installed plugins load automatically, -p no:NAME disables one.
  • rerunfailures (--reruns), xdist (-n) and pytest-html (--html) are standard in Playwright projects.
  • pytest_addoption plus pytestconfig.getoption() gives you options like --env; pytest_report_header labels runs.
  • pytest_collection_modifyitems changes collected tests; a pytest_runtest_makereport wrapper can screenshot failures.

That completes Module 3. Test yourself with the Module 3 mock interview, then move on to Module 4: locators and interactions.

🎬 Watch the Playwright tutorial series on YouTube!

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

pytest Configuration Guide: pytest.ini, pyproject.toml, addopts and Key Settings

In Lesson 6 you used pytest.ini for a few Playwright options. This lesson is the complete reference for the settings that matter in real test projects: which file pytest reads and how it picks the project root, changing discovery rules, silencing known warnings, live logging, enforcing plugin and pytest versions, and overriding settings from the environment in CI. Every setting shown here was tested with pytest 9.2.

📖 What You’ll Learn: which configuration files pytest reads and their precedence, rootdir and the configfile header, testpaths and norecursedirs, custom discovery with python_files, python_classes and python_functions, filterwarnings, live logging with log_cli, required_plugins and minversion, the PYTEST_ADDOPTS environment variable, and a complete recommended pytest.ini for Playwright.


📄 Which File Does pytest Read?

pytest looks for configuration in the folder of your test paths and then in its parent folders, and uses the first match it finds. In each folder it checks, in this order:

FileSectionNotes
pytest.ini[pytest]Always wins, even if empty. Recommended for test projects.
pyproject.toml[tool.pytest.ini_options] (or [tool.pytest] in pytest 9)Common when the project already uses pyproject.toml.
tox.ini[pytest]Used by projects that also use tox.
setup.cfg[tool:pytest]Older projects; not recommended for new ones.

The folder containing the chosen file becomes the rootdir, the base for test IDs and relative paths. The first lines of every run tell you what pytest decided:

🖥️ Output

rootdir: C:\pwv\pytest-configuration
configfile: pytest.ini
testpaths: tests

Check these lines first whenever settings seem to be ignored. You can also force a specific file with -c path/to/pytest.ini.


🗂️ Where to Look for Tests

testpaths = tests
norecursedirs = .venv node_modules test-results
  • testpaths lists the folders that a bare pytest command searches. It makes collection faster and avoids picking up stray files.
  • norecursedirs lists folder names (glob patterns) that pytest never enters. pytest already skips common ones such as .*, venv and build by default; setting this option replaces that default list, so include anything you still need to skip.

🔍 Custom Discovery Rules

If your team names files differently, change the patterns. This project collects check_*.py files, Verify* classes and check_* functions in addition to the defaults (pytest.ini below, used for the whole lesson):

📄 pytest.ini

[pytest]
minversion = 9.0
testpaths = tests
pythonpath = .
python_files = test_*.py check_*.py
python_classes = Test* Verify*
python_functions = test_* check_*
addopts = --strict-markers -ra
markers =
    smoke: quick checks of the most important flows
filterwarnings =
    ignore:Session-scoped fixtures defined as instance methods:pytest.PytestRemovedIn10Warning
log_cli = true
log_cli_level = INFO
required_plugins = pytest-playwright>=0.10.0

📄 tests/check_login.py

import logging

import pytest
from playwright.sync_api import Page, expect

log = logging.getLogger(__name__)


@pytest.mark.smoke
def check_login_page(page: Page):
    log.info("opening the SQA Tools login page")
    page.goto("https://sqatools.in/login-page/")
    expect(page.get_by_role("button", name="Log in")).to_be_visible()
    log.info("login button is visible")


class VerifyPracticePage:
    def check_table_rows(self, page: Page):
        page.goto("https://sqatools.in/automation-practice-page/")
        expect(page.locator("table tbody tr")).to_have_count(3)
  • python_files = test_*.py check_*.py adds check_*.py files to the default test_*.py.
  • python_classes and python_functions work the same way for classes and functions. With these settings, check_login_page and VerifyPracticePage.check_table_rows are real tests.
  • Patterns are separated by spaces.

⚠️ Watch out: Changing discovery rules surprises new team members, who expect test_ names. Only do it when you have a strong reason, such as an existing naming convention.


🔇 filterwarnings: Silence Known Warnings

Since Lesson 2 you have seen the same harmless PytestRemovedIn10Warning from pytest-playwright’s base_url fixture. filterwarnings hides warnings you have reviewed and accepted, so new, important warnings stand out:

filterwarnings =
    ignore:Session-scoped fixtures defined as instance methods:pytest.PytestRemovedIn10Warning

The format is action:message:category:

  • action: ignore, error (turn the warning into a test failure), default, always and others;
  • message: the start of the warning text (a regular expression);
  • category: the warning class, here pytest.PytestRemovedIn10Warning.

error is useful too: filterwarnings = error::DeprecationWarning makes your suite fail as soon as your own code uses something deprecated.

💡 Tip: Keep filters narrow. Ignoring a whole category like DeprecationWarning hides warnings you would want to see.


📝 Live Logging with log_cli

Tests and page objects should use Python’s logging module rather than print. With log_cli = true, pytest shows log messages live as each test runs, at or above log_cli_level:

⌨️ Terminal

pytest -v

🖥️ Output

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

tests/check_login.py::check_login_page[chromium]
-------------------------------- live log call --------------------------------
INFO     check_login:check_login.py:11 opening the SQA Tools login page
INFO     check_login:check_login.py:14 login button is visible
PASSED                                                                   [ 50%]
tests/check_login.py::VerifyPracticePage::check_table_rows[chromium] PASSED [100%]

============================== 2 passed in 4.76s ==============================

Notice three things in this real output:

  1. Both custom-named tests were collected (check_login_page and VerifyPracticePage::check_table_rows).
  2. The log lines appear under “live log call”, with level, logger name, file and line.
  3. There is no warnings summary: the filter silenced the known plugin warning.

Related settings: log_cli_format and log_cli_date_format change the line format, and log_file = logs/pytest.log also writes logs to a file.


🛡️ Guard Rails: minversion and required_plugins

  • minversion = 9.0 stops the run if pytest is older than 9.0.
  • required_plugins = pytest-playwright>=0.10.0 stops the run if a plugin is missing or too old.

Both turn “works on my machine” problems into a clear error message. Here is what happens when a required plugin is missing (strict/pytest.ini asks for a plugin that is not installed):

📄 strict/pytest.ini

[pytest]
required_plugins = pytest-rerunfailures>=14.0

📄 strict/test_nothing.py

def test_placeholder():
    assert True

⌨️ Terminal

pytest -q -c strict/pytest.ini strict

🖥️ Output

ERROR: Missing required plugins: pytest-rerunfailures>=14.0

pytest refuses to run any test until the environment is fixed, and exits with code 4 (a usage error), so CI fails clearly.


🌍 Override Settings from the Environment: PYTEST_ADDOPTS

CI systems often need extra options without editing files. pytest reads the PYTEST_ADDOPTS environment variable and adds its contents to the command line, after addopts from the config file. This script proves it by running the same tests twice (addopts_env.py):

📄 addopts_env.py

import os
import subprocess

PY = r"C:\pwv\_venv0\Scripts\python"
base = [PY, "-m", "pytest", "-p", "no:cacheprovider", "-q", "--collect-only"]

normal = subprocess.run(base, capture_output=True, text=True).stdout
print("normal run:", normal.strip().splitlines()[-1])

env = dict(os.environ, PYTEST_ADDOPTS="-m smoke")
smoke = subprocess.run(base, capture_output=True, text=True, env=env).stdout
print("with PYTEST_ADDOPTS='-m smoke':", smoke.strip().splitlines()[-1])

🖥️ Output

normal run: 2 tests collected in 0.02s
with PYTEST_ADDOPTS='-m smoke': 1/2 tests collected (1 deselected) in 0.02s

In a CI pipeline you would set PYTEST_ADDOPTS="-m smoke --browser firefox" for one job and something else for another, with the same pytest.ini.


✅ A Recommended pytest.ini for Playwright Projects

[pytest]
minversion = 9.0
testpaths = tests
pythonpath = .
addopts =
    --strict-markers
    -ra
    --tracing retain-on-failure
    --screenshot only-on-failure
markers =
    smoke: quick checks of the most important flows
    regression: full functional coverage
    slow: tests that take longer than a few seconds
xfail_strict = true
filterwarnings =
    ignore:Session-scoped fixtures defined as instance methods:pytest.PytestRemovedIn10Warning
log_cli = true
log_cli_level = INFO
required_plugins = pytest-playwright>=0.10.0
  • A multi-line addopts is allowed: indent the continuation lines.
  • -ra prints a short summary of every test that did not pass (failures, errors, skips, xfails), which is ideal in CI logs.
  • Browser choice stays on the command line or in PYTEST_ADDOPTS, for the reason explained in Lesson 6: --browser values add up.

❓ Frequently Asked Questions

🤔 pytest.ini or pyproject.toml?

Either works. pytest.ini is explicit and always takes priority; pyproject.toml keeps all tool settings in one file. Pick one and use only one, since pytest.ini silently wins when both exist (apart from a note in the header).

🤔 Can command-line options override the config file?

Yes. addopts comes first, then PYTEST_ADDOPTS, then what you type, so later single-value options win. Ini settings that are not command-line options (such as testpaths) can be overridden with -o name=value, for example pytest -o log_cli=false.

🤔 Should log_cli be on in CI?

It is helpful in CI logs because you see progress live. Locally, some people prefer it off and use -s or the captured log sections shown for failed tests.

🤔 Where do I set the default expect timeout?

Not in pytest.ini: it is a Playwright setting. Call expect.set_options(timeout=10_000) in conftest.py, as shown in Lesson 6.


🏁 Summary

  • pytest uses the first of pytest.ini, pyproject.toml, tox.ini or setup.cfg it finds; check the rootdir and configfile header.
  • Use testpaths, pythonpath, addopts, markers and xfail_strict in almost every project; change discovery patterns only with good reason.
  • filterwarnings silences reviewed warnings, log_cli shows logs live, minversion and required_plugins guard the environment.
  • PYTEST_ADDOPTS and -o name=value adjust a run without editing files.

Next, you will extend pytest itself with plugins and hook functions, including a screenshot-on-failure hook for Playwright.

🎬 Watch the Playwright tutorial series on YouTube!

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

pytest Markers: skip, skipif, xfail and Custom Markers (smoke, regression)

Markers are labels you attach to tests with @pytest.mark. Some built-in markers change how a test runs: skip it, skip it on certain systems, or expect it to fail. Custom markers such as smoke or regression let you run exactly the group of tests you need, for example a quick smoke suite on every commit and the full regression suite at night. In this lesson you will use all of them, register your own markers properly and select tests with -m expressions.

📖 What You’ll Learn: skip and skipif with reasons, skipping from inside a test, xfail and the difference between xfailed and xpassed, strict xfail, custom markers like smoke and regression, registering markers in pytest.ini, –strict-markers, selecting tests with -m and boolean expressions, listing markers, and marking whole classes and modules.


🏷️ What Is a Marker?

A marker is a decorator of the form @pytest.mark.<name> placed above a test (or a class). pytest uses markers in two ways:

  1. Built-in markers change execution: skip, skipif, xfail, parametrize, usefixtures, filterwarnings.
  2. Custom markers are labels you invent, such as smoke, regression or slow, used to select tests with -m.

⏭️ skip, skipif and xfail

Create tests/test_skip_xfail.py:

📄 tests/test_skip_xfail.py

import sys

import pytest


@pytest.mark.skip(reason="payment page is being redesigned")
def test_payment_form():
    assert False                       # never runs


@pytest.mark.skipif(sys.platform == "win32", reason="uses a Linux-only path")
def test_linux_path():
    assert sys.platform != "win32"


def test_skip_from_inside():
    feature_enabled = False            # imagine reading a feature flag
    if not feature_enabled:
        pytest.skip("feature flag 'new-cart' is off")
    assert True


@pytest.mark.xfail(reason="bug #42: total ignores discounts")
def test_discount_total():
    assert 100 - 10 == 100             # fails: expected while the bug exists


@pytest.mark.xfail(reason="bug #57: badge count off by one")
def test_badge_count():
    assert 1 + 1 == 2                  # passes unexpectedly: XPASS
MarkerEffect
@pytest.mark.skip(reason=...)Never runs the test. Always give a reason.
@pytest.mark.skipif(condition, reason=...)Skips only when the condition is true, e.g. a platform or Python version.
pytest.skip("...") inside a testSkips at runtime, after checking something such as a feature flag or environment.
@pytest.mark.xfail(reason=...)Runs the test but expects it to fail. A failure is reported as xfailed; an unexpected pass as xpassed.

Run it with -rsxX to see a summary of skips (s), xfails (x) and xpasses (X):

⌨️ Terminal

pytest -q -rsxX tests/test_skip_xfail.py

🖥️ Output

sssxX                                                                    [100%]
=========================== short test summary info ===========================
SKIPPED [1] tests\test_skip_xfail.py:6: payment page is being redesigned
SKIPPED [1] tests\test_skip_xfail.py:11: uses a Linux-only path
SKIPPED [1] tests\test_skip_xfail.py:19: feature flag 'new-cart' is off
XFAIL tests/test_skip_xfail.py::test_discount_total - bug #42: total ignores discounts
XPASS tests/test_skip_xfail.py::test_badge_count - bug #57: badge count off by one
3 skipped, 1 xfailed, 1 xpassed in 0.05s

(This run was on Windows, so test_linux_path was skipped; on Linux it would pass.)

💡 Tip: Use xfail for known bugs rather than skip. The test keeps running, so the day the bug is fixed you see XPASS and know to remove the marker. A skipped test tells you nothing.


🔒 Strict xfail

By default an XPASS does not fail the run, so a fixed bug can go unnoticed. With strict=True, an unexpected pass fails the test (tests/test_strict_xfail.py):

📄 tests/test_strict_xfail.py

import pytest


@pytest.mark.xfail(reason="bug #57: badge count off by one", strict=True)
def test_badge_count_strict():
    assert 1 + 1 == 2

⌨️ Terminal

pytest -q -rX tests/test_strict_xfail.py

🖥️ Output

F                                                                        [100%]
================================== FAILURES ===================================
___________________________ test_badge_count_strict ___________________________
[XPASS(strict)] bug #57: badge count off by one
1 failed in 0.02s

To make every xfail strict, set xfail_strict = true in pytest.ini. Other useful xfail options: raises=SomeError (only that exception counts as the expected failure) and run=False (do not even run it, for example when it would hang).


🏷️ Custom Markers: smoke, regression, slow

Custom markers group tests by purpose. First register them in pytest.ini, with a short description each:

📄 pytest.ini

[pytest]
markers =
    smoke: quick checks of the most important flows (run on every commit)
    regression: full functional coverage (run nightly)
    slow: tests that take longer than a few seconds

Then label the tests (tests/test_suite.py):

📄 tests/test_suite.py

import pytest
from playwright.sync_api import Page, expect


@pytest.mark.smoke
@pytest.mark.regression
def test_login_page_loads(page: Page):
    page.goto("https://sqatools.in/login-page/")
    expect(page.get_by_role("button", name="Log in")).to_be_visible()


@pytest.mark.regression
def test_practice_page_table(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")
    expect(page.locator("table tbody tr")).to_have_count(3)


@pytest.mark.regression
@pytest.mark.slow
def test_practice_page_all_headings(page: Page):
    page.goto("https://sqatools.in/automation-practice-page/")
    expect(page.get_by_role("heading", name="Bottom of Page")).to_be_visible()

A test can carry several markers. Now select groups with -m:

⌨️ Terminal

pytest -q -m smoke tests/test_suite.py

🖥️ Output

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

(The warning is the pytest-playwright base_url notice from Lesson 2.)

-m accepts boolean expressions with and, or, not and parentheses:

⌨️ Terminal

pytest -q -m "regression and not slow" tests/test_suite.py

🖥️ Output

..                                                                       [100%]
2 passed, 1 deselected, 1 warning in 5.02s
CommandRuns
pytest -m smokeTests marked smoke
pytest -m "smoke or regression"Tests with either marker
pytest -m "regression and not slow"Regression tests except slow ones
pytest -m "not slow"Everything except slow tests

📝 Note: -m selects by marker, -k selects by name. You can combine them: pytest -m regression -k login.


⚠️ Typos in Marker Names: Register and Use –strict-markers

pytest does not know whether @pytest.mark.smoek is a typo or a new marker. Without registration it only warns (unregistered/test_typo.py):

📄 unregistered/test_typo.py

import pytest


@pytest.mark.smoek            # typo of "smoke"
def test_typo():
    assert True

⌨️ Terminal

pytest -q unregistered/test_typo.py

🖥️ Output

  C:\pwv\pytest-markers\unregistered\test_typo.py:4: PytestUnknownMarkWarning: Unknown pytest.mark.smoek - is this a typo?  You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
    @pytest.mark.smoek            # typo of "smoke"

1 passed, 1 warning in 0.01s

The test still ran, but pytest -m smoke would silently skip it. With --strict-markers, an unregistered marker is an error instead:

⌨️ Terminal

pytest -q --strict-markers unregistered/test_typo.py

🖥️ Output

=================================== ERRORS ====================================
'smoek' not found in `markers` configuration option
=========================== short test summary info ===========================
ERROR unregistered/test_typo.py - Failed: 'smoek' not found in `markers` conf...
!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.07s

The run stops during collection with exit code 2, so the typo cannot go unnoticed.

Add --strict-markers to addopts in pytest.ini so typos can never slip through:

[pytest]
addopts = --strict-markers
markers =
    smoke: quick checks of the most important flows

📋 Listing Markers

pytest --markers lists every marker available: your registered ones (with their descriptions) first, then those added by plugins, then pytest’s built-in ones. With pytest-playwright installed the list starts like this:

🖥️ Output

@pytest.mark.smoke: quick checks of the most important flows (run on every commit)

@pytest.mark.regression: full functional coverage (run nightly)

@pytest.mark.slow: tests that take longer than a few seconds

@pytest.mark.skip_browser(name): mark test to be skipped a specific browser

@pytest.mark.only_browser(name): mark test to run only on a specific browser

@pytest.mark.browser_context_args(**kwargs): provide additional arguments to browser.new_context()

@pytest.mark.skip(reason=None): skip the given test function with an optional reason. ...

You will use skip_browser, only_browser and the browser_context_args marker in Module 6.


📦 Marking a Whole Class or Module

  • A class: put the marker above the class and every test method inherits it.
  • A whole file: set a module-level variable pytestmark = pytest.mark.regression (or a list of markers) at the top of the test file.
import pytest

pytestmark = [pytest.mark.regression]       # applies to every test in this file


@pytest.mark.smoke
class TestCheckout:
    def test_shipping_form(self, page):      # marked regression AND smoke
        ...

❓ Frequently Asked Questions

🤔 Should I delete skipped tests?

Not immediately, but review them regularly. A test skipped for months is usually dead code. Prefer xfail for known bugs so you notice when they are fixed.

🤔 What is the difference between -m and -k?

-m selects tests by marker expression; -k selects by test name (including parametrize IDs). Both accept and, or and not.

🤔 Can I skip a test only in one browser?

Yes, with pytest-playwright’s @pytest.mark.skip_browser("webkit"), or check the browser_name fixture inside the test and call pytest.skip(). Module 6 covers both.

🤔 Why use reason= everywhere?

Reasons appear in reports and in the -rs summary, so whoever reads the results knows why a test did not run without opening the code.


🏁 Summary

  • skip, skipif and pytest.skip() stop tests from running, with a reason; xfail expects failure and reports xfailed or xpassed.
  • strict=True (or xfail_strict = true) turns an unexpected pass into a failure.
  • Register custom markers in pytest.ini, add --strict-markers, and select groups with -m expressions.
  • Mark whole classes with a decorator and whole files with pytestmark.

Next, you will put all of pytest’s settings together in pytest.ini or pyproject.toml.

🎬 Watch the Playwright tutorial series on YouTube!

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

pytest parametrize: Run One Test with Many Inputs (with Playwright Examples)

Good test suites check the same behaviour with many inputs: several users, several prices, several pages. Copying a test for each input creates duplicate code that is painful to maintain. pytest’s parametrize marker runs one test function once per set of values, each reported as its own test. In this lesson you will parametrize plain functions and Playwright login tests, give every case a readable name, mark individual cases, combine parameters, and parametrize fixtures.

📖 What You’ll Learn: @pytest.mark.parametrize with one and several arguments, how pytest builds test IDs, custom ids, pytest.param with id and marks, stacking decorators for combinations, a data-driven Playwright login test, parametrized fixtures with params and request.param, and how parametrize combines with pytest-playwright’s browser parameter.


🔁 One Test, Many Inputs

@pytest.mark.parametrize(names, values) takes:

  1. a string with the argument names, separated by commas;
  2. a list of values: single values for one argument, or tuples for several.

pytest then generates one test per item. Create tests/test_basic_params.py:

📄 tests/test_basic_params.py

import pytest


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


@pytest.mark.parametrize("text", ["$29.99", "$9.99", "$7.99"])
def test_price_is_positive(text):
    assert to_price(text) > 0


@pytest.mark.parametrize(
    "text, expected",
    [
        ("$29.99", 29.99),
        ("$1,299.00", 1299.0),
        ("15", 15.0),
    ],
)
def test_price_parsing(text, expected):
    assert to_price(text) == expected
  • The first test has one argument, so the list holds plain strings.
  • The second has two arguments ("text, expected"), so each item is a tuple in the same order.
  • The test function’s parameters must match the names exactly.

⌨️ Terminal

pytest -v tests/test_basic_params.py

🖥️ Output

tests/test_basic_params.py::test_price_is_positive[$29.99] PASSED        [ 16%]
tests/test_basic_params.py::test_price_is_positive[$9.99] PASSED         [ 33%]
tests/test_basic_params.py::test_price_is_positive[$7.99] PASSED         [ 50%]
tests/test_basic_params.py::test_price_parsing[$29.99-29.99] PASSED      [ 66%]
tests/test_basic_params.py::test_price_parsing[$1,299.00-1299.0] PASSED  [ 83%]
tests/test_basic_params.py::test_price_parsing[15-15.0] PASSED           [100%]

Each case is a separate test with an ID in square brackets. pytest builds the ID from the values, joined with -. If one case fails, the others still run and you see exactly which input broke.


🏷️ Readable IDs and Per-Case Marks

Auto-generated IDs work for simple values but become unreadable for dictionaries or long strings. Use ids= for a list of names, or pytest.param(..., id=...) to name one case and attach markers to it (tests/test_param_marks.py):

📄 tests/test_param_marks.py

import pytest


@pytest.mark.parametrize(
    "a, b, expected",
    [(2, 3, 5), (10, -4, 6), (0, 0, 0)],
    ids=["small", "negative", "zeros"],
)
def test_add(a, b, expected):
    assert a + b == expected


@pytest.mark.parametrize(
    "username, can_log_in",
    [
        pytest.param("standard_user", True, id="standard"),
        pytest.param("locked_out_user", False, id="locked"),
        pytest.param("glitch_user", True, id="glitch",
                     marks=pytest.mark.xfail(reason="no such user on the demo site")),
    ],
)
def test_known_users(username, can_log_in):
    known = {"standard_user": True, "locked_out_user": False}
    assert known[username] == can_log_in
  • ids=[...] gives the cases names in order: test_add[small], test_add[negative], test_add[zeros].
  • pytest.param(values..., id="...") names a single case right next to its data, which is easier to keep in sync than a separate list.
  • marks=pytest.mark.xfail(...) applies a marker to that case only. You can also use pytest.mark.skip this way.

⌨️ Terminal

pytest -q -rx tests/test_param_marks.py

🖥️ Output

.....x                                                                   [100%]
=========================== short test summary info ===========================
XFAIL tests/test_param_marks.py::test_known_users[glitch] - no such user on the demo site
5 passed, 1 xfailed in 0.02s

-rx adds the summary line for xfailed tests, showing the case’s ID and reason.


✖️ Stacking: Every Combination

Stacking two parametrize decorators runs every combination of their values (a Cartesian product). Use --collect-only to see the generated tests without running them (tests/test_stacked.py):

📄 tests/test_stacked.py

import pytest


@pytest.mark.parametrize("width", [375, 768, 1280])
@pytest.mark.parametrize("theme", ["light", "dark"])
def test_layout(width, theme):
    assert width > 0 and theme in ("light", "dark")

⌨️ Terminal

pytest --collect-only -q tests/test_stacked.py

🖥️ Output

tests/test_stacked.py::test_layout[light-375]
tests/test_stacked.py::test_layout[light-768]
tests/test_stacked.py::test_layout[light-1280]
tests/test_stacked.py::test_layout[dark-375]
tests/test_stacked.py::test_layout[dark-768]
tests/test_stacked.py::test_layout[dark-1280]

6 tests collected in 0.01s

2 themes × 3 widths = 6 tests. Combinations grow fast, so stack only when every combination is worth testing.


🎭 Data-Driven Playwright Login Tests

Now the real use case: one login test, many users, each with its expected result (tests/test_login_data.py):

📄 tests/test_login_data.py

import pytest
from playwright.sync_api import Page, expect

LOGIN_CASES = [
    pytest.param("standard_user", "secret_sauce", None, id="valid-user"),
    pytest.param("locked_out_user", "secret_sauce", "Sorry, this user has been locked out.", id="locked-user"),
    pytest.param("standard_user", "wrong", "Username and password do not match", id="wrong-password"),
    pytest.param("", "secret_sauce", "Username is required", id="empty-username"),
]


@pytest.mark.parametrize("username, password, error", LOGIN_CASES)
def test_login(page: Page, username: str, password: str, error: str | None):
    page.goto("https://www.saucedemo.com/")
    page.get_by_placeholder("Username").fill(username)
    page.get_by_placeholder("Password").fill(password)
    page.get_by_role("button", name="Login").click()

    if error is None:
        expect(page.locator(".title")).to_have_text("Products")
    else:
        expect(page.locator("[data-test='error']")).to_contain_text(error)
  • The test data lives in one list at the top of the file, separate from the test logic. In Module 7 you will load it from JSON and CSV files instead.
  • error=None means “login should succeed”; otherwise the test checks the error message.
  • Parametrize arguments and fixtures mix freely: page comes from pytest-playwright, the other three from the parameters.

⌨️ Terminal

pytest -v tests/test_login_data.py

🖥️ Output

tests/test_login_data.py::test_login[chromium-valid-user] PASSED         [ 25%]
tests/test_login_data.py::test_login[chromium-locked-user] PASSED        [ 50%]
tests/test_login_data.py::test_login[chromium-wrong-password] PASSED     [ 75%]
tests/test_login_data.py::test_login[chromium-empty-username] PASSED     [100%]

Notice the ID: chromium-valid-user. pytest-playwright parametrizes every test that uses page by browser, and pytest combines that with your parameters. Run in two browsers and the cases double:

⌨️ Terminal

pytest -q --collect-only --browser chromium --browser firefox tests/test_login_data.py

🖥️ Output

tests/test_login_data.py::test_login[chromium-valid-user]
tests/test_login_data.py::test_login[chromium-locked-user]
tests/test_login_data.py::test_login[chromium-wrong-password]
tests/test_login_data.py::test_login[chromium-empty-username]
tests/test_login_data.py::test_login[firefox-valid-user]
tests/test_login_data.py::test_login[firefox-locked-user]
tests/test_login_data.py::test_login[firefox-wrong-password]
tests/test_login_data.py::test_login[firefox-empty-username]

8 tests collected in 0.02s

💡 Tip: Select single cases by ID with -k: pytest -k "locked-user" runs only that case, in every browser.


🧪 Parametrized Fixtures

Sometimes it is the fixture that should vary, for example a viewport size used by many tests. Give the fixture params=[...]; every test that uses it runs once per value, and the fixture reads the current value from request.param (tests/test_fixture_params.py):

📄 tests/test_fixture_params.py

import pytest
from playwright.sync_api import Page


@pytest.fixture(params=[(375, 812), (1280, 720)], ids=["mobile", "desktop"])
def sized_page(page: Page, request) -> Page:
    width, height = request.param
    page.set_viewport_size({"width": width, "height": height})
    return page


def test_practice_page_fits(sized_page: Page):
    sized_page.goto("https://sqatools.in/automation-practice-page/")
    width = sized_page.viewport_size["width"]
    assert sized_page.evaluate("document.documentElement.clientWidth") <= width
  • params lists the values; ids names them in reports.
  • request.param holds the value for the current run.
  • page.set_viewport_size() resizes the page’s viewport, and the test checks that the page is not wider than the screen.

🖥️ Output

tests/test_fixture_params.py::test_practice_page_fits[mobile-chromium] PASSED [ 50%]
tests/test_fixture_params.py::test_practice_page_fits[desktop-chromium] PASSED [100%]

Here the fixture’s ID comes first and the browser second (mobile-chromium), while in the login example the browser came first (chromium-valid-user). The order follows how pytest resolves the parameters, so do not rely on it when filtering; -k mobile matches either way.

Choose parametrize for test data that belongs to one test, and fixture params when a whole group of tests should run in several configurations.


❓ Frequently Asked Questions

🤔 Why do my IDs show names like user0 and user1?

pytest builds IDs only from simple values (numbers, strings, booleans, None); for other objects such as dictionaries it uses the argument name plus an index (user0, user1).

🤔 Can I parametrize a test class?

Yes. Put @pytest.mark.parametrize on the class and every test method receives the parameters.

🤔 How do I load parameters from a JSON or CSV file?

Read the file into a list at module level (or in a helper function) and pass that list to parametrize. Module 7 shows this step by step.

🤔 Is it a problem if one parametrized case fails?

No. Each case is an independent test, so the others still run, and the report shows exactly which input failed.


🏁 Summary

  • @pytest.mark.parametrize("a, b", [(1, 2), ...]) runs a test once per value set, each with its own ID.
  • Use ids= or pytest.param(..., id=..., marks=...) for readable names and per-case markers.
  • Stacked decorators produce every combination; pytest-playwright adds the browser to every ID.
  • Fixture params with request.param vary a fixture for all tests that use it.

Next, you will use markers to skip, expect failures, and tag tests so you can run exactly the group you need.

🎬 Watch the Playwright tutorial series on YouTube!

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