Type hints are labels that say what kind of value a variable, parameter or return value should hold. Python does not enforce them when the code runs, but your editor and tools like mypy read them to give you accurate auto-complete and to catch mistakes before a test ever opens a browser. Playwright for Python ships complete type information, so a few hints make your test code much easier to write and maintain.
📖 What You’ll Learn: basic hints for str, int, float, bool, lists and dictionaries, optional values with str | None, function signatures, Playwright’s types (Page, Locator, BrowserContext, Response), typed test data with dataclasses and Literal, why hints are not checked at runtime, and how mypy finds bugs before you run tests.
🏷️ The Basics
A type hint goes after a colon for variables and parameters, and after -> for a function’s return value:
browser_name: str = "chromium"
retries: int = 2
timeout_seconds: float = 7.5
headless: bool = True
tags: list[str] = ["smoke", "login"]
expected_counts: dict[str, int] = {"products": 6, "cart": 0}
viewport: tuple[int, int] = (1280, 720)
def full_url(base: str, path: str) -> str:
return base.rstrip("/") + "/" + path.lstrip("/")
list[str]means “a list whose items are strings”;dict[str, int]means “string keys with integer values”. Since Python 3.9 you can use the built-inlist,dictandtupledirectly in hints.-> strdocuments thatfull_urlreturns 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:
| Type | What it is | Where you meet it | |
|---|---|---|---|
Page | A browser tab | The page fixture | |
Locator | A way to find element(s) | page.get_by_role(...), page.locator(...) | |
BrowserContext | An isolated session | The context fixture | |
Browser | A browser process | The browser fixture | |
Response | An HTTP response | page.goto() returns `Response | None` |
APIRequestContext | An HTTP client for API tests | Module 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)createsUser(username, password, can_log_in)and makes instances read-only, so a test cannot accidentally change shared data.can_log_in: bool = Truehas a default value, so most users only need two arguments.Literal["chromium", "firefox", "webkit"]allows only those exact strings.BrowserNameis 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
Pageand storesLocatorobjects.-> Nonesays__init__and the action methods return nothing. login_as(self, user: User)accepts a wholeUser, so a test cannot pass the username and password in the wrong order.error_text()returnsstr | 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:
goto()expects a string URL.- The typo
get_by_rolis caught, with suggestions of similar real methods. get_attribute()can returnNone, so assigning it to a plainstris unsafe. Fix it withstr | Noneand anifcheck.- 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: typeand-> return_type; collections uselist[str],dict[str, int]; missing values usestr | None. - Import Playwright types (
Page,Locator,BrowserContext,Response) fromplaywright.sync_api. - Dataclasses and
Literalgive 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