Skip to content
SDETRole
SDETRole

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 as list[str] and dict[str, int].
  • Dataclasses generate __init__, __repr__ and __eq__, which makes them ideal for test data and expected values. Use field(default_factory=list) for mutable defaults.
  • **overrides collects 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:

  1. A session-scoped fixture that provides a requests.Session.
  2. A parametrised test that checks /users/<id> returns 200 for ids 1, 5 and 10, and that the response id matches.
  3. A test that /users/999 returns 404.
  4. A test that every post for user 1 (/posts?userId=1) has userId == 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 use is only for None.
  • pytest’s core trio is fixtures (with yield teardown and scopes), parametrize and markers, with shared fixtures in conftest.py.
  • pytest-playwright gives 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.

Type to search across SDETRole.

Full-text search by Pagefind · runs in your browser Open search page →