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

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

Advertisement

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


🏷️ The Basics

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

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

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


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

Values that might be missing: str | None

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

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

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


🎭 Playwright’s Own Types

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

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

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


🧱 A Typed Page Object and Typed Test Data

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

📄 data.py

from dataclasses import dataclass
from typing import Literal

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


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


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


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

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

📄 pages/login_page.py

from playwright.sync_api import Locator, Page

from data import User


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

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

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

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

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

And the tests (tests/test_typed_login.py):

📄 tests/test_typed_login.py

from playwright.sync_api import Page, expect

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


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


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

🖥️ Output

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

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

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


⚠️ Hints Are Not Checked When Code Runs

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

📄 runtime_demo.py

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


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

🖥️ Output

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

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


🔎 Catch Bugs Early with mypy

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

⌨️ Terminal

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

🖥️ Output

Success: no issues found in 4 source files

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

📄 broken.py

from playwright.sync_api import Page

from data import STANDARD_USER


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

⌨️ Terminal

python -m mypy --strict broken.py

🖥️ Output

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

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

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

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


❓ Frequently Asked Questions

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

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

🤔 Do I have to add hints everywhere?

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

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

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

🤔 Should test data be a dict or a dataclass?

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


🏁 Summary

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

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

🎬 Watch the Playwright tutorial series on YouTube!

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

Leave a Comment