Type hints in Python are optional — the interpreter ignores them at runtime. That's exactly why so many test codebases skip them entirely. It's also a big mistake.
When your test suite grows to hundreds of files, the question isn't "will I remember what this function returns?" It's "will the person reading this six months from now understand it?" Type hints are documentation that your editor can actually verify.
Here's what I actually use in production.
The Basics You Actually Need
You don't need to learn every corner of the typing module. These cover 90% of test code:
# Basic types
name: str = "standard_user"
timeout: int = 5000
rate: float = 0.05
is_logged_in: bool = False
# Collections
tags: list[str] = ["smoke", "login"]
config: dict[str, str] = {"browser": "chromium"}
# Optional — the value might be None
user_id: str | None = None # Python 3.10+
# or the older style:
from typing import Optional
user_id: Optional[str] = None
Annotating Functions — The Most Important Part
The biggest payoff comes from annotating your function signatures. Your editor can then tell you exactly what a helper returns and what it expects.
def get_auth_token(username: str, password: str) -> str:
"""Returns a bearer token for API authentication."""
response = requests.post("/api/login", json={
"username": username,
"password": password
})
return response.json()["token"]
Now when you call get_auth_token(...), your IDE shows the return type and catches bugs before they run:
token = get_auth_token("admin", "secret123")
token.split() # Fine — str has split()
token.keys() # ❌ Editor warns: str has no attribute 'keys'
Page Objects with Type Hints
This is where it really pays off. A typed page object makes every single method self-documenting:
from playwright.sync_api import Page, Locator
class LoginPage:
def __init__(self, page: Page) -> None:
self.page = page
self.username_input: Locator = page.locator("#user-name")
self.password_input: Locator = page.locator("#password")
self.login_button: Locator = page.locator("#login-button")
self.error_message: Locator = page.locator('[data-test="error"]')
def login(self, username: str, password: str) -> None:
self.username_input.fill(username)
self.password_input.fill(password)
self.login_button.click()
def get_error_text(self) -> str | None:
if self.error_message.is_visible():
return self.error_message.text_content()
return None
When someone new reads this, get_error_text() -> str | None immediately tells them: "this might not return anything, check for None." No comments needed.
TypedDict for Test Data
Instead of throwing around plain dicts for test data, use TypedDict:
from typing import TypedDict
class UserCredentials(TypedDict):
username: str
password: str
role: str
USERS: dict[str, UserCredentials] = {
"standard": {
"username": "standard_user",
"password": "secret_sauce",
"role": "buyer"
},
"admin": {
"username": "admin_user",
"password": "secret_sauce",
"role": "admin"
}
}
Now USERS["standard"] gives you full autocomplete on username, password, and role. No more typos in your test data keys.
Dataclasses for Complex Test Data
When TypedDict isn't enough, switch to dataclasses:
from dataclasses import dataclass, field
@dataclass
class ProductTestData:
name: str
price: float
category: str
in_stock: bool = True
tags: list[str] = field(default_factory=list)
backpack = ProductTestData(
name="Sauce Labs Backpack",
price=29.99,
category="bags"
)
Dataclasses give you __repr__ for free (which is amazing for test failure messages), type checking, and rock-solid IDE support.
Typing Fixtures in pytest
pytest fixtures can also be deeply typed:
import pytest
from playwright.sync_api import Page, Browser
@pytest.fixture
def authenticated_page(page: Page) -> Page:
"""Returns a Page instance already logged in as standard_user."""
page.goto("/")
page.fill("#user-name", "standard_user")
page.fill("#password", "secret_sauce")
page.click("#login-button")
page.wait_for_url("**/inventory.html")
return page
The return type -> Page tells pytest and your IDE exactly what this fixture yields, enabling proper autocomplete in every single test that uses it.
Running mypy in CI
Having type hints without checking them is like writing tests and never running them. Put mypy in your pre-commit or CI:
pip install mypy
mypy tests/ --ignore-missing-imports
Or add it to your pyproject.toml:
[tool.mypy]
python_version = "3.12"
strict = false
ignore_missing_imports = true
Start with strict = false and tighten it up gradually. Trying to type everything strictly on day one is a trap, don't do it.
What Not to Over-Type
Type hints add value when they communicate non-obvious things. Skip them when they're pure noise:
# Noise — the return type is obvious
def add(a: int, b: int) -> int:
return a + b
# Useful — the return type isn't obvious
def parse_response(data: dict[str, object]) -> list[ProductTestData]:
...
Also: don't type-annotate everything inside the test functions themselves. Type the infrastructure (fixtures, helpers, page objects) and keep the test body readable.
The Payoff
On a team project, I introduced TypedDict for our test data and typed all our page object methods. Three months later a new engineer joined, set up the project, and literally said "I can actually read this codebase." That's the metric that matters.
Type hints aren't about being clever. They're about writing code that survives the test of time (and survives new team members).
