Lesson 9 of 9 · Programming for Testers
Python for Testers
Python 3.12 for test automation: virtual environments, core syntax, comprehensions, dataclasses and type hints, then pytest fixtures, parametrisation and markers, plus Playwright for Python.
intermediate 7 min read Updated
You will learn to
- Set up a Python test project with a virtual environment and pytest
- Use lists, dicts, comprehensions, f-strings, dataclasses and type hints in test code
- Write pytest tests with fixtures, parametrize, markers and conftest.py
- Write a basic browser test with Playwright for Python
Prerequisites
- Programming Fundamentals for Testers
- Data Structures You Will Use
Python shows up in test roles in three main places: pytest suites for APIs and services, browser automation with Playwright for Python or Selenium, and data-heavy testing such as ETL pipelines and machine learning systems. Its syntax is compact and readable, and pytest is one of the most pleasant test frameworks in any language.
This lesson covers the Python 3.12 you need for automation and the pytest features that matter most. It assumes you know basic programming concepts from Programming Fundamentals for Testers.
Setting up a project
Always work in a virtual environment so each project has its own dependencies:
python -m venv .venv
# macOS/Linux
source .venv/bin/activate
# Windows (PowerShell)
.venv\Scripts\Activate.ps1
pip install pytest requests
Record dependencies in a requirements.txt or, in newer projects, in pyproject.toml. A typical layout:
my-tests/
pyproject.toml
conftest.py (shared fixtures)
tests/
test_users_api.py
test_checkout_ui.py
data/
users.json
pytest discovers files named test_*.py or *_test.py, functions named test_*, and classes named Test* without an __init__ method. Run everything with pytest, one file with pytest tests/test_users_api.py, and a single test with pytest tests/test_users_api.py::test_create_user.
Core syntax in five minutes
Python uses indentation to define blocks; there are no braces or semicolons.
retries = 3 # int
price = 19.99 # float
name = "Ana" # str
active = True # bool (capitalised)
nothing = None # the absence of a value
if price > 10 and active:
print(f"{name} pays {price:.2f}") # f-string: Ana pays 19.99
elif price == 0:
print("free")
else:
print("cheap")
for attempt in range(1, retries + 1): # 1, 2, 3
print("attempt", attempt)
Note and, or, not instead of &&, ||, !, and that == compares values (including lists and dicts) while is checks identity. Use is only for None: if value is None:.
Collections and comprehensions
browsers = ["chromium", "firefox", "webkit"] # list
user = {"name": "Ana", "roles": ["admin"]} # dict
tags = {"smoke", "checkout"} # set
point = (10, 20) # tuple (immutable)
user["email"] = "ana@example.com" # add a key
email = user.get("phone", "not set") # safe lookup with default
Comprehensions build collections concisely and are idiomatic in test code:
products = [
{"sku": "A1", "price": 39.99, "in_stock": True},
{"sku": "B2", "price": 24.50, "in_stock": False},
]
skus = [p["sku"] for p in products] # ['A1', 'B2']
available = [p for p in products if p["in_stock"]]
price_by_sku = {p["sku"]: p["price"] for p in products} # dict comprehension
all_priced = all(p["price"] > 0 for p in products) # True
Accessing a missing key with user["phone"] raises KeyError; .get() returns a default instead. Choose deliberately: in a test, a KeyError that names the missing field is often exactly the failure you want.
Functions, type hints and dataclasses
from dataclasses import dataclass, field
@dataclass
class User:
name: str
email: str
roles: list[str] = field(default_factory=list)
def build_user(role: str = "viewer", **overrides: str) -> User:
base = {"name": "Test User", "email": "test.user@example.com"}
base.update(overrides)
return User(**base, roles=[role])
admin = build_user("admin", email="admin@example.com")
print(admin) # User(name='Test User', email='admin@example.com', roles=['admin'])
- Type hints (
name: str,-> User) are not enforced at runtime, but tools such as mypy or Pyright check them and editors use them for autocompletion. Python 3.9+ allows built-in generics such aslist[str]anddict[str, int]. - Dataclasses generate
__init__,__repr__and__eq__, which makes them ideal for test data and expected values. Usefield(default_factory=list)for mutable defaults. **overridescollects extra keyword arguments into a dict.
Exceptions in brief
try:
value = int("abc")
except ValueError as e:
raise AssertionError("Expected a numeric order count, got 'abc'") from e
Catch specific exceptions, chain with raise ... from e, and use with blocks (context managers) to close files and connections. More in Exception Handling.
pytest essentials
Plain assert
pytest uses Python’s own assert statement and rewrites it to show detailed failure output:
def test_total_includes_tax():
subtotal, tax_rate = 100.0, 0.2
assert subtotal * (1 + tax_rate) == pytest.approx(120.0)
pytest.approx handles floating-point comparisons. (Import pytest at the top of the file: import pytest.)
Fixtures
Fixtures provide setup and teardown, injected by parameter name:
import pytest
import requests
BASE_URL = "https://jsonplaceholder.typicode.com"
@pytest.fixture
def api():
session = requests.Session()
session.headers.update({"Accept": "application/json"})
yield session # the test runs here
session.close() # teardown runs after the test, even if it failed
def test_get_user(api):
res = api.get(f"{BASE_URL}/users/1", timeout=10)
assert res.status_code == 200
body = res.json()
assert body["id"] == 1
assert "@" in body["email"]
Code before yield is setup, code after is teardown. Fixtures have a scope: function (default, fresh for every test), class, module, package or session. Expensive resources such as a logged-in API token can use scope="session"; anything a test might modify should stay function-scoped.
Put shared fixtures in conftest.py. pytest loads it automatically for every test in that directory and below, with no import needed.
Parametrize
@pytest.mark.parametrize(
("quantity", "valid"),
[
(0, False),
(1, True),
(99, True),
(100, False),
],
)
def test_quantity_limits(quantity, valid):
assert is_valid_quantity(quantity) is valid
Each tuple becomes a separate test with its own pass or fail result. Add ids=[...] for readable names. is_valid_quantity stands in for the function under test.
Markers
@pytest.mark.smoke
def test_homepage_loads(api): ...
@pytest.mark.skip(reason="feature flag off in QA")
def test_new_checkout(): ...
@pytest.mark.xfail(reason="BUG-231: discount ignored for multi-item carts")
def test_discount_multi_item(): ...
Run a subset with pytest -m smoke or pytest -m "not slow". Register custom markers in pyproject.toml to avoid warnings:
[tool.pytest.ini_options]
markers = [
"smoke: fast checks run on every deployment",
"slow: long-running tests",
]
Other everyday options: -k "login and not admin" selects by name, -x stops at the first failure, -v is verbose, and --lf reruns only the last failures.
Playwright for Python
pip install pytest-playwright
playwright install
The plugin provides a page fixture using Playwright’s synchronous API, so there is no async/await in your tests:
import re
from playwright.sync_api import Page, expect
def test_search_shows_results(page: Page):
page.goto("https://shop.example.test")
page.get_by_role("searchbox", name="Search").fill("kettle")
page.keyboard.press("Enter")
expect(page.get_by_role("heading", level=1)).to_have_text(re.compile("kettle", re.I))
expect(page.get_by_test_id("product-card")).not_to_have_count(0)
The URL is a placeholder. Method names are snake_case versions of the JavaScript API (get_by_role, to_have_text), and expect assertions auto-retry just like in TypeScript. Run headed with pytest --headed and choose browsers with --browser firefox. The concepts in Playwright Locators apply unchanged.
Python vs JavaScript, quick reference
| Concept | Python | JavaScript |
|---|---|---|
| Block structure | Indentation | Braces |
| Missing value | None |
null / undefined |
| String formatting | f"Hi {name}" |
`Hi ${name}` |
| List transform | [x * 2 for x in xs] |
xs.map((x) => x * 2) |
| Dictionary / object lookup with default | d.get("k", 0) |
obj.k ?? 0 |
| Deep equality | == on lists and dicts |
Framework toEqual |
| Test runner | pytest | Playwright Test, Jest, Vitest |
Practice exercise
Using pytest and requests, write tests for https://jsonplaceholder.typicode.com:
- A session-scoped fixture that provides a
requests.Session. - A parametrised test that checks
/users/<id>returns 200 for ids 1, 5 and 10, and that the responseidmatches. - A test that
/users/999returns 404. - A test that every post for user 1 (
/posts?userId=1) hasuserId == 1, with a failure message listing offending post ids.
Show a model answer
import pytest
import requests
BASE_URL = "https://jsonplaceholder.typicode.com"
@pytest.fixture(scope="session")
def api():
with requests.Session() as session:
session.headers.update({"Accept": "application/json"})
yield session
@pytest.mark.parametrize("user_id", [1, 5, 10])
def test_get_user(api, user_id):
res = api.get(f"{BASE_URL}/users/{user_id}", timeout=10)
assert res.status_code == 200
assert res.json()["id"] == user_id
def test_unknown_user_returns_404(api):
res = api.get(f"{BASE_URL}/users/999", timeout=10)
assert res.status_code == 404
def test_posts_filtered_by_user(api):
res = api.get(f"{BASE_URL}/posts", params={"userId": 1}, timeout=10)
assert res.status_code == 200
posts = res.json()
assert posts, "expected at least one post for user 1"
wrong = [p["id"] for p in posts if p["userId"] != 1]
assert not wrong, f"posts with wrong userId: {wrong}"The with block inside the fixture closes the session after the whole run. A session scope is safe here because the tests only read data. Passing params= lets requests build and encode the query string. Every request has a timeout, because requests waits indefinitely by default. The final assertion reports exactly which posts are wrong rather than just “False”.
Key takeaways
- Use a virtual environment per project and run tests with pytest.
- Comprehensions, f-strings,
dict.get, dataclasses and type hints cover most test-data needs. - Avoid mutable default arguments; compare with
==, and useisonly forNone. - pytest’s core trio is fixtures (with
yieldteardown and scopes), parametrize and markers, with shared fixtures inconftest.py. pytest-playwrightgives you a synchronous Playwright API with the same locators and auto-retrying assertions as TypeScript.
Key terms
Finished this lesson?
Progress is saved in this browser. No account needed.