DEV Community

Cover image for Python for Test Automation: The Only Parts That Actually Matter
Himanshu Agarwal
Himanshu Agarwal

Posted on

Python for Test Automation: The Only Parts That Actually Matter

A complete, honest guide to the Python a test automation engineer really uses — and the 80% you can safely ignore.


The problem with learning Python as a tester

Let me describe a scenario you might recognise.

You decide to move into test automation. Everyone says learn Python. So you find a Python course — a good one, highly rated, forty hours long. You start.

Week one: variables, loops, functions. Fine. Useful. Week two: you're building a number-guessing game. Week three: object-oriented programming through a Dog class that barks. Week four: matplotlib charts. Week five: pandas dataframes. Week six: a Flask web app.

By week eight you've written more code than ever before in your life, and you still have absolutely no idea how to structure test data, or why your automated test keeps failing intermittently, or what a fixture is.

You didn't learn the wrong thing. You learned the right thing in the wrong order, aimed at the wrong target.

Python for data science, Python for web development, and Python for test automation share a syntax and almost nothing else. The 20% of Python a tester lives in every day is not the 20% a data scientist lives in. Generic courses teach breadth. Test automation demands a very specific depth.

So this article does something different. It walks the Python that test automation actually uses — every concept aimed squarely at the job of writing clean, reliable, maintainable automated tests. No number-guessing games. No barking dogs. Just the Python that makes you dangerous with Playwright and pytest.

It's long. Bookmark it. Let's go.


Part 1: Why Python won test automation

Before the syntax, a quick honest answer to "why Python?" — because knowing why your tool won tells you how to use it.

It reads like intent. This matters more in testing than anywhere else. A test is documentation that executes. When a test fails at 2am, someone has to read it and instantly understand what it was checking. Python's readability isn't an aesthetic preference — it's a maintenance feature.

Compare:

def test_user_can_checkout_with_valid_card(page):
    login(page, user="test@example.com")
    add_to_cart(page, product="Backpack")
    checkout(page, card="4111111111111111")
    expect(page.get_by_text("Order confirmed")).to_be_visible()
Enter fullscreen mode Exit fullscreen mode

You knew what that did before you knew any Python. That's the whole point.

The ecosystem is unmatched for testing. pytest is arguably the best test framework in any language. Playwright's Python bindings are first-class. requests, faker, pydantic, allure — the tools you need already exist and are mature.

The write-run cycle is instant. No compile step. Change a line, run the test, see the result. When you're debugging a flaky test at 11pm, that loop speed is the difference between fixing it and giving up.

It's the industry standard (alongside JavaScript). Which means your skills transfer between jobs, the answers to your problems are already on Stack Overflow, and every automation job posting you'll want lists it.

That's the case. Now the craft.


Part 2: Variables and types — through a tester's eyes

Every tutorial starts with variables. Most of them start badly:

x = 5
y = "hello"
Enter fullscreen mode Exit fullscreen mode

This teaches syntax and nothing else. Here's the same concept, taught for the job you actually want:

BASE_URL = "https://shop.example.com"
TIMEOUT_MS = 30_000
HEADLESS = True
TEST_USER_EMAIL = "qa+automation@example.com"
Enter fullscreen mode Exit fullscreen mode

Notice what just happened. Those are the same four types (str, int, bool, str), but now you've learned something real: configuration lives in named constants, not scattered magic values.

The _ in 30_000 is a readability separator — Python ignores it. Useful for timeouts, thresholds, and any number where zeros blur together.

The types that matter, and why

Type Where a tester meets it
str URLs, selectors, expected text, test data, file paths
int Timeouts, counts, status codes, retry limits
float Thresholds, durations, tolerances in visual comparison
bool Flags — headless, should_succeed, feature toggles
None "No value yet" — an optional field, a missing config

That's it. That's the list. You will not need complex numbers.

The None trap that bites every beginner

None means "nothing here." It is not 0, not "", not False. And this distinction causes a specific bug testers hit constantly:

discount = get_discount_from_config()   # returns None if not set

if not discount:               # ❌ Bug: also True when discount is 0
    apply_default_discount()

if discount is None:           # ✅ Correct: only when actually unset
    apply_default_discount()
Enter fullscreen mode Exit fullscreen mode

A legitimate discount of 0 would incorrectly trigger the default in the first version, because 0 is falsy. Use is None when you mean "unset." Use truthiness when you mean "empty or zero or missing, don't care which."

This is a real bug that ships. It's also a classic interview question.

== vs is — the one you'll be asked

a = "hello"
b = "hello"
a == b      # True  — same value
a is b      # True... but only by accident (string interning)

x = [1, 2]
y = [1, 2]
x == y      # True  — same contents
x is y      # False — different objects in memory
Enter fullscreen mode Exit fullscreen mode

Rule for testers: use == for everything in assertions. Use is only with None, True, False. If you find yourself using is for anything else, you probably want ==.


Part 3: Strings — you'll live here

Testers manipulate strings constantly. Selectors, expected messages, URLs, test data, file paths, log output. Get fluent.

f-strings: the only formatting you need

user_id = 42
env = "staging"

url = f"https://{env}.example.com/users/{user_id}"
# 'https://staging.example.com/users/42'
Enter fullscreen mode Exit fullscreen mode

Clean, readable, fast. Everything else (%-formatting, .format()) is legacy — you'll see it in old code, you shouldn't write it.

The killer feature for debugging — the = specifier:

actual_count = 3
expected_count = 5
print(f"{actual_count=}, {expected_count=}")
# actual_count=3, expected_count=5
Enter fullscreen mode Exit fullscreen mode

That prints the variable name and value. When you're debugging a test at 1am, this saves real time.

The string methods testers actually use

text = "  Order Confirmed  "

text.strip()              # 'Order Confirmed'  — kill whitespace from scraped text
text.lower()              # '  order confirmed  '  — case-insensitive comparison
text.strip().lower()      # 'order confirmed'  — chained, the common idiom

"confirmed" in text.lower()      # True — substring check, your bread and butter
text.startswith("  Order")       # True
text.replace("Order", "Payment") # '  Payment Confirmed  '
"a,b,c".split(",")               # ['a', 'b', 'c'] — parsing CSV-ish data
",".join(["a", "b", "c"])        # 'a,b,c' — building it back
Enter fullscreen mode Exit fullscreen mode

Why .strip() matters so much in testing: text scraped from a web page frequently carries invisible whitespace and newlines from HTML formatting. An assertion that looks obviously correct fails, and you lose twenty minutes:

actual = page.get_by_test_id("status").inner_text()   # '\n  Confirmed \n'
assert actual == "Confirmed"          # ❌ Fails. Invisible whitespace.
assert actual.strip() == "Confirmed"  # ✅ Passes.
Enter fullscreen mode Exit fullscreen mode

(Better still, use Playwright's expect(locator).to_have_text("Confirmed"), which normalises whitespace and auto-retries. But when you're comparing raw strings, .strip() is your friend.)

Raw strings for paths and regex

path = "C:\Users\test\new_file.txt"    # ❌ \t and \n become tab and newline!
path = r"C:\Users\test\new_file.txt"   # ✅ raw string — backslashes are literal

pattern = r"\d{4}-\d{2}-\d{2}"         # ✅ regex always uses raw strings
Enter fullscreen mode Exit fullscreen mode

The r prefix means "treat backslashes literally." Non-negotiable for Windows paths and regex.


Part 4: Lists and tuples — order matters

Lists: mutable, your workhorse

browsers = ["chromium", "firefox", "webkit"]

browsers.append("edge")        # add to end
browsers.remove("firefox")     # remove by value
len(browsers)                  # 3
browsers[0]                    # 'chromium'  — first
browsers[-1]                   # 'edge'      — last (very Pythonic)
"chromium" in browsers         # True        — membership check
Enter fullscreen mode Exit fullscreen mode

Where you'll actually use them: collections of test data, lists of elements scraped from a page, browsers to run against, files to clean up.

Slicing — free and useful

items = ["a", "b", "c", "d", "e"]

items[:3]     # ['a', 'b', 'c']   — first three
items[2:]     # ['c', 'd', 'e']   — from index 2
items[-2:]    # ['d', 'e']        — last two
items[::-1]   # ['e', 'd', ...]   — reversed
Enter fullscreen mode Exit fullscreen mode

Real use: results[-5:] to look at the last five test runs, or products[:10] to test only the first page of results.

Tuples: immutable, and that's the point

credentials = ("test@example.com", "password123")
email, password = credentials      # unpacking — clean and readable
Enter fullscreen mode Exit fullscreen mode

Why testers should care: a tuple says "this will not change." That's a strong signal in test data. A test case defined as a tuple can't be accidentally mutated by one test and break the next.

This matters enormously in parametrised testing:

CHECKOUT_CASES = [
    ("valid card",    "4111111111111111", True),
    ("expired card",  "4000000000000069", False),
    ("declined card", "4000000000000002", False),
]
Enter fullscreen mode Exit fullscreen mode

Each case is a tuple. Fixed. Safe. And this exact structure feeds straight into pytest's parametrize — which is where data-driven testing begins.

Rule of thumb: if the collection is a fixed record (a test case, a coordinate, a credential pair) → tuple. If it's a growing collection → list.


Part 5: Dictionaries — the tester's most important structure

If I could make you master one data structure for test automation, it would be the dictionary.

test_user = {
    "email": "qa@example.com",
    "password": "SecurePass123",
    "role": "admin",
    "verified": True,
}

test_user["email"]              # 'qa@example.com'
test_user["mfa"]                # ❌ KeyError — crashes
test_user.get("mfa")            # None — safe
test_user.get("mfa", False)     # False — safe with a default
Enter fullscreen mode Exit fullscreen mode

.get() with a default is the tester's habit. Test data is often incomplete. Config might not have every key. .get() means a missing key degrades gracefully instead of exploding.

Why dictionaries dominate testing

Because everything in modern testing is a dictionary:

# API responses are dictionaries
response = api.get("/users/42").json()
assert response["status"] == "active"
assert response["profile"]["email"] == "qa@example.com"

# Config is a dictionary
config = {"base_url": "https://staging.example.com", "timeout": 30_000}

# Test data is a dictionary
checkout_data = {"card": "4111111111111111", "cvv": "123", "expiry": "12/28"}

# Even HTTP headers
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
Enter fullscreen mode Exit fullscreen mode

Learn dictionaries deeply and API testing becomes natural, because a JSON response is a Python dictionary the moment you call .json().

Iterating dictionaries

for key, value in test_user.items():
    print(f"{key}: {value}")

for key in test_user.keys():      # just keys
for value in test_user.values():  # just values
Enter fullscreen mode Exit fullscreen mode

Nested access — and the safe way

API responses nest. Deeply. And nested access is where tests crash:

data = {"user": {"profile": {"email": "qa@example.com"}}}

data["user"]["profile"]["email"]       # works
data["user"]["settings"]["theme"]      # ❌ KeyError on 'settings'

# Safe chaining:
data.get("user", {}).get("settings", {}).get("theme", "default")   # 'default'
Enter fullscreen mode Exit fullscreen mode

That chained .get() with {} defaults never crashes. In tests that touch real API responses, this pattern is worth memorising — it turns a hard crash into a graceful default.


Part 6: Sets — small but genuinely useful

Sets are unordered collections of unique items. Testers underuse them.

expected_ids = {"user-1", "user-2", "user-3"}
actual_ids   = {"user-1", "user-3", "user-4"}

actual_ids - expected_ids       # {'user-4'}  — unexpected extras
expected_ids - actual_ids       # {'user-2'}  — missing items
expected_ids & actual_ids       # {'user-1', 'user-3'}  — intersection
expected_ids == actual_ids      # False — order-independent comparison
Enter fullscreen mode Exit fullscreen mode

The killer use case: comparing collections where order doesn't matter. A list of items returned by an API might come back in any order. Comparing lists fails on ordering. Comparing sets tests what you actually care about — the contents.

# ❌ Fragile — fails if the API reorders
assert response["tags"] == ["python", "testing", "automation"]

# ✅ Robust — tests membership, not order
assert set(response["tags"]) == {"python", "testing", "automation"}
Enter fullscreen mode Exit fullscreen mode

That's one line that eliminates a whole category of false failures.

Also: set(items) deduplicates instantly. len(set(emails)) == len(emails) checks for duplicates in one line.


Part 7: Control flow — small surface, big impact

Conditionals

if response.status == 200:
    validate_success(response)
elif response.status == 429:
    handle_rate_limit(response)
else:
    fail(f"Unexpected status: {response.status}")
Enter fullscreen mode Exit fullscreen mode

Straightforward. But one testing-specific note: be very careful using conditionals inside tests.

def test_checkout(page):
    if page.get_by_text("Sale banner").is_visible():   # ⚠️ Danger
        apply_discount(page)
    complete_checkout(page)
Enter fullscreen mode Exit fullscreen mode

This test now does different things on different runs. Which means when it fails, you don't know which path it took. Conditional logic inside tests creates non-deterministic tests — the thing you're trying to eliminate.

Rule: conditionals belong in helpers, fixtures, and page objects. Tests themselves should be a straight line. If you genuinely need two paths, write two tests.

Loops

for browser in ["chromium", "firefox", "webkit"]:
    run_suite(browser)

for index, item in enumerate(cart_items):        # index + value
    print(f"{index}: {item}")

for name, price in zip(product_names, prices):   # parallel iteration
    verify_price(name, price)
Enter fullscreen mode Exit fullscreen mode

enumerate and zip are the two loop helpers testers reach for most. enumerate when you need position, zip when you're walking two related lists together.

The loop anti-pattern in tests

def test_all_products(page):
    for product in ALL_100_PRODUCTS:      # ❌ One test, 100 checks
        verify_product_page(page, product)
Enter fullscreen mode Exit fullscreen mode

If product #47 fails, the test stops. You never learn about 48–100. And the report says "1 test failed" instead of "1 of 100 products is broken."

The fix is parametrisation (pytest's @pytest.mark.parametrize), which turns one test into 100 independent tests. That's Volume 12 territory — but the Python instinct starts here: a loop inside a test usually wants to be a parametrised test instead.


Part 8: Functions — where test code becomes maintainable

def login(page, email: str, password: str) -> None:
    page.goto("/login")
    page.get_by_label("Email").fill(email)
    page.get_by_label("Password").fill(password)
    page.get_by_role("button", name="Sign in").click()
Enter fullscreen mode Exit fullscreen mode

Functions are how repeated test steps stop being repeated. This is the seed of the Page Object Model — it's just this idea, organised into classes.

Default arguments — and the mutable default trap

def create_user(name, role="viewer"):     # ✅ sensible default
    ...

def add_test_data(item, collection=[]):   # ❌ THE CLASSIC BUG
    collection.append(item)
    return collection
Enter fullscreen mode Exit fullscreen mode

That second one is a genuine Python landmine. The default [] is created once, when the function is defined — not on each call. So:

add_test_data("a")    # ['a']
add_test_data("b")    # ['a', 'b']  ← the list persisted!
Enter fullscreen mode Exit fullscreen mode

Test data leaking between calls. In a test suite, that's a debugging nightmare. The fix:

def add_test_data(item, collection=None):
    if collection is None:
        collection = []
    collection.append(item)
    return collection
Enter fullscreen mode Exit fullscreen mode

This is also one of the most common Python interview questions in existence. Know it cold.

Return values that make assertions clean

def get_cart_total(page) -> float:
    text = page.get_by_test_id("cart-total").inner_text()   # '$42.50'
    return float(text.replace("$", ""))

# Now the test reads beautifully:
assert get_cart_total(page) == 42.50
Enter fullscreen mode Exit fullscreen mode

The helper does the messy parsing. The test states the intent. That separation is the whole game in test code.

*args and **kwargs

def run_tests(*test_names, **options):
    print(test_names)   # ('test_a', 'test_b')  — a tuple
    print(options)      # {'browser': 'firefox', 'headed': True}  — a dict

run_tests("test_a", "test_b", browser="firefox", headed=True)
Enter fullscreen mode Exit fullscreen mode

Where testers meet this: writing wrappers and decorators that pass arguments through without caring what they are:

def with_retry(func):
    def wrapper(*args, **kwargs):        # accept anything
        for attempt in range(3):
            try:
                return func(*args, **kwargs)   # pass it all through
            except AssertionError:
                if attempt == 2:
                    raise
    return wrapper
Enter fullscreen mode Exit fullscreen mode

That wrapper works on any function, because *args, **kwargs means "whatever you were given, hand it along."


Part 9: Comprehensions — Pythonic and everywhere

# List comprehension
prices = [float(p.replace("$", "")) for p in price_strings]

# With a filter
failed = [t for t in results if t.status == "failed"]

# Dict comprehension
lookup = {user["id"]: user["email"] for user in users}

# Set comprehension
unique_domains = {email.split("@")[1] for email in emails}
Enter fullscreen mode Exit fullscreen mode

Read it as: [what I want, for each item, from where, optionally if condition].

Real testing usage:

# All visible link texts on a page
links = [el.inner_text() for el in page.get_by_role("link").all()]

# Just the failing test names
failures = [t["name"] for t in results if not t["passed"]]

# Map product name → price
catalog = {p["name"]: p["price"] for p in api_response["products"]}
Enter fullscreen mode Exit fullscreen mode

When to stop: if a comprehension needs more than one condition and a transform, write the loop. Comprehensions are for clarity. A comprehension nobody can read has defeated its own purpose.

# ❌ Nobody can read this
result = [f(x) if c(x) else g(x) for sub in data for x in sub if h(x) and j(x)]
Enter fullscreen mode Exit fullscreen mode

That's not clever. That's a code review rejection.


Part 10: Exception handling — and why testers must be careful

try:
    response = api.get("/users/42")
except ConnectionError:
    pytest.fail("API unreachable")
except TimeoutError:
    pytest.fail("API timed out")
finally:
    cleanup()          # always runs
Enter fullscreen mode Exit fullscreen mode

The mechanics are simple. The judgement is what matters, and this is where testers go wrong.

The cardinal sin

def test_checkout(page):
    try:
        complete_checkout(page)
        assert page.get_by_text("Confirmed").is_visible()
    except Exception:
        pass                # ❌❌❌
Enter fullscreen mode Exit fullscreen mode

This test can never fail. It will be green forever. It tests nothing. It is worse than having no test at all — because it creates the belief that checkout is covered.

In application code, catching exceptions is defensive programming. In test code, it's usually a way of hiding failures.

The default in a test should be: let it fail loudly. A test's job is to fail when something is wrong. Swallowing exceptions removes its only purpose.

When exception handling is legitimate in tests

1. Testing that something should raise:

import pytest

def test_invalid_card_rejected():
    with pytest.raises(ValidationError):
        process_payment(card="0000")
Enter fullscreen mode Exit fullscreen mode

That's an assertion, not a hiding place. It fails if the exception doesn't happen.

2. Cleanup that must run:

try:
    run_test_scenario()
finally:
    delete_test_user(user_id)     # runs even if the test failed
Enter fullscreen mode Exit fullscreen mode

3. Genuinely optional operations in helpers:

def dismiss_cookie_banner(page):
    try:
        page.get_by_role("button", name="Accept").click(timeout=2000)
    except TimeoutError:
        pass          # banner didn't appear — genuinely fine
Enter fullscreen mode Exit fullscreen mode

Note this is in a helper, not a test, and it's narrow — it catches one specific exception for one specific reason.

Never catch bare Exception in a test

except Exception:      # ❌ catches everything, including your own bugs
except TimeoutError:   # ✅ catches the one thing you expected
Enter fullscreen mode Exit fullscreen mode

A bare except Exception will swallow your typo, your AttributeError, your KeyError — bugs in the test itself — and report success. Be specific about what you expect.


Part 11: Context managers and with

with open("test_data.json") as f:
    data = json.load(f)
# file is closed automatically, even if json.load raised
Enter fullscreen mode Exit fullscreen mode

The with statement guarantees cleanup. Testers meet it constantly:

# Playwright browser contexts
with sync_playwright() as p:
    browser = p.chromium.launch()
    ...
# browser resources released

# Expecting a download
with page.expect_download() as download_info:
    page.get_by_role("button", name="Export").click()
download = download_info.value

# Expecting a network response
with page.expect_response("**/api/checkout") as response_info:
    page.get_by_role("button", name="Pay").click()
assert response_info.value.status == 200

# Expecting an exception
with pytest.raises(ValueError):
    parse_config("garbage")
Enter fullscreen mode Exit fullscreen mode

Notice the pattern in the Playwright examples: with X() as info:start listening, do the thing that triggers it, then read the result. That's the idiom for anything asynchronous in a synchronous test.

Writing your own

from contextlib import contextmanager

@contextmanager
def temporary_user(api):
    user = api.create_user()          # setup
    try:
        yield user                    # hand it to the block
    finally:
        api.delete_user(user["id"])   # teardown, guaranteed

# Usage:
with temporary_user(api) as user:
    login_and_verify(page, user)
# user deleted, even if the test failed
Enter fullscreen mode Exit fullscreen mode

That setup → yield → teardown shape is exactly how pytest fixtures work. Understand this and fixtures will feel obvious instead of magical.


Part 12: Files and data — JSON, CSV, and paths

JSON — the format of testing

import json

# Read test data
with open("data/users.json") as f:
    users = json.load(f)          # → Python dict/list

# Write results
with open("results.json", "w") as f:
    json.dump(results, f, indent=2)

# Strings ↔ objects
obj = json.loads('{"status": "ok"}')     # parse a string
text = json.dumps({"status": "ok"})      # serialise to a string
Enter fullscreen mode Exit fullscreen mode

load/dump work with files. loads/dumps (with an s, for "string") work with strings. That's the only distinction, and it trips up everyone once.

CSV — for data-driven test cases

import csv

with open("data/checkout_cases.csv") as f:
    reader = csv.DictReader(f)      # each row → a dict
    cases = list(reader)

# cases == [{'card': '4111...', 'expected': 'success'}, ...]
Enter fullscreen mode Exit fullscreen mode

DictReader is the one to know — it uses the header row as keys, so you get dictionaries instead of positional lists. Far more readable, and immune to column reordering.

Paths — do it properly

from pathlib import Path

# ❌ Breaks on Windows, breaks when run from a different directory
path = "data/users.json"

# ✅ Robust, OS-independent, relative to THIS file
DATA_DIR = Path(__file__).parent / "data"
users_file = DATA_DIR / "users.json"

with users_file.open() as f:
    users = json.load(f)
Enter fullscreen mode Exit fullscreen mode

Why this matters for real: your test passes locally and fails in CI. Nine times out of ten, it's a path — because CI runs from a different working directory. Path(__file__).parent anchors to the file's own location, so it works everywhere. This single pattern prevents a genuinely common CI failure.

Useful Path operations:

p = Path("reports/run-1/results.json")
p.exists()          # bool
p.parent            # Path('reports/run-1')
p.name              # 'results.json'
p.suffix            # '.json'
p.stem              # 'results'
p.parent.mkdir(parents=True, exist_ok=True)   # create dirs safely
Enter fullscreen mode Exit fullscreen mode

Part 13: Modules, packages, and imports

A module is a .py file. A package is a directory of modules.

tests/
  conftest.py
  test_checkout.py
pages/
  __init__.py
  base_page.py
  cart_page.py
utils/
  __init__.py
  data_helpers.py
Enter fullscreen mode Exit fullscreen mode
from pages.cart_page import CartPage
from utils.data_helpers import unique_email
Enter fullscreen mode Exit fullscreen mode

Absolute imports, always. Relative imports (from ..pages import CartPage) look clever and break in ways that consume afternoons.

Virtual environments — non-negotiable

python -m venv .venv
source .venv/bin/activate       # macOS/Linux
.venv\Scripts\activate          # Windows

pip install playwright pytest pytest-playwright
playwright install
pip freeze > requirements.txt
Enter fullscreen mode Exit fullscreen mode

Why every project needs one: without it, Project A needs pytest 7 and Project B needs pytest 8, and you have exactly one system Python. Chaos. A venv gives each project its own isolated dependencies.

And requirements.txt is what makes your suite reproducible — on your teammate's machine, and in CI. Without it, "works on my machine" becomes your permanent identity.


Everything so far is the foundation. Here's where it's taught properly, in depth, with practice at every step:


📘 Volume 1 — Python for Test Automation: Fundamentals to Advanced

Every concept above, taught properly — with definitions, real automation examples, common-mistake warnings, and hands-on practice in every chapter. The Python foundation professional test automation is actually built on.

👉 Get Volume 1 here

Volume 1 is the first step of a 24-volume path — Python → Playwright → framework engineering → CI/CD at scale → AI-assisted testing → testing AI applications → portfolio & interviews.

Get the complete 24-volume Master Bundle


Part 14: Decorators — you're already using them

You've seen these:

@pytest.fixture
@pytest.mark.parametrize("card", CARDS)
@pytest.mark.skip(reason="Flaky on Firefox")
Enter fullscreen mode Exit fullscreen mode

Those are decorators. A decorator is a function that wraps another function to add behaviour without modifying it.

How they actually work

def timed(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.time() - start:.2f}s")
        return result
    return wrapper

@timed
def test_slow_checkout(page):
    ...
Enter fullscreen mode Exit fullscreen mode

@timed is exactly equivalent to test_slow_checkout = timed(test_slow_checkout). The decorator takes the function, wraps it, returns the wrapper. That's genuinely all it is.

A retry decorator — with a warning

import functools

def retry(times=2):
    def decorator(func):
        @functools.wraps(func)          # preserves the original name
        def wrapper(*args, **kwargs):
            for attempt in range(times + 1):
                try:
                    return func(*args, **kwargs)
                except AssertionError:
                    if attempt == times:
                        raise
                    print(f"Retry {attempt + 1}/{times}")
        return wrapper
    return decorator

@retry(times=2)
def test_flaky_thing(page):
    ...
Enter fullscreen mode Exit fullscreen mode

functools.wraps matters — without it, the wrapper's __name__ becomes "wrapper", and your test reports become useless. Always include it.

And now the important part: you can build this. You mostly shouldn't.

Retrying a flaky test doesn't fix it — it hides it. A test that needs three attempts is telling you something real: a race condition, a shared-state problem, a data collision, a genuine bug. Retries silence the messenger.

Retries are a bandage for genuinely transient infrastructure blips while you fix the root cause. They are not a strategy. A suite that stays green only because of retries is a suite nobody should trust.

Learn decorators because pytest is built on them, and because understanding them makes fixtures and markers stop feeling like magic. Not because you should decorate your way out of flakiness.


Part 15: Generators and yield

def read_test_cases(path):
    with open(path) as f:
        for line in f:
            yield line.strip().split(",")     # one at a time, lazily
Enter fullscreen mode Exit fullscreen mode

A generator produces values on demand rather than building a whole list in memory. For a million-row test-data file, this is the difference between working and crashing.

But here's the reason testers must understand yield:

@pytest.fixture
def logged_in_page(page):
    login(page, "test@example.com", "password")
    yield page                    # ← the test runs here
    logout(page)                  # ← cleanup, after the test
Enter fullscreen mode Exit fullscreen mode

This is the single most important yield in a tester's life. Everything before yield is setup. The value yielded is what the test receives. Everything after runs as teardown — even if the test fails.

Master this shape and pytest fixtures — the backbone of every professional Python test framework — become immediately intuitive.


Part 16: Type hints — professional Python

def get_cart_total(page: Page) -> float:
    ...

def find_user(email: str) -> User | None:
    ...

CHECKOUT_CASES: list[tuple[str, str, bool]] = [...]
Enter fullscreen mode Exit fullscreen mode

Type hints don't change how Python runs. They change everything about how you work.

Why testers should use them:

1. Your editor becomes genuinely helpful. Annotate page: Page and your IDE knows every Playwright method. Autocomplete works. Typos are caught as you type. This alone is worth it.

2. They're documentation that can't rot. def login(page: Page, email: str, password: str) -> None tells you everything without a docstring — and unlike a comment, it can be checked.

3. Bugs surface before runtime. Run mypy in CI and it catches type errors without executing anything.

4. They signal professionalism. A framework with type hints reads as engineered. One without reads as scripted. Reviewers notice within seconds.

The syntax you'll actually use:

name: str
count: int
ratio: float
enabled: bool
items: list[str]
config: dict[str, str]
case: tuple[str, int]
maybe: str | None          # optional (Python 3.10+)
def f() -> None:           # returns nothing
Enter fullscreen mode Exit fullscreen mode

That covers 95% of test code.


Part 17: Clean code — PEP 8 and naming

PEP 8 is Python's style guide. The parts that matter:

# snake_case for functions and variables
def get_cart_total(): ...
user_email = "..."

# PascalCase for classes
class CheckoutPage: ...

# UPPER_SNAKE for constants
BASE_URL = "https://example.com"
DEFAULT_TIMEOUT = 30_000

# 4 spaces, never tabs
# Two blank lines between top-level definitions
Enter fullscreen mode Exit fullscreen mode

Don't memorise it. Install ruff or black and let the tool enforce it:

pip install ruff
ruff check .
ruff format .
Enter fullscreen mode Exit fullscreen mode

Then put it in CI as your first, cheapest gate. Style debates end permanently.

Naming, which actually matters

# ❌ What is this testing?
def test_1(page): ...
def test_checkout(page): ...

# ✅ The name IS the specification
def test_checkout_succeeds_with_valid_card(page): ...
def test_checkout_rejects_expired_card(page): ...
def test_checkout_shows_error_when_payment_declined(page): ...
Enter fullscreen mode Exit fullscreen mode

A test's name is read far more often than its body — in CI output, in reports, in the failure notification that wakes someone up. When test_1 fails, someone opens the code. When test_checkout_rejects_expired_card fails, they already know what broke.

Name the behaviour and expectation, not the function under test. This is a free upgrade to your entire suite.


Part 18: Putting it together

Let's assemble everything into something real:

# utils/data_helpers.py
import json
import uuid
from pathlib import Path

DATA_DIR = Path(__file__).parent.parent / "data"


def unique_email(prefix: str = "qa") -> str:
    """Generate a collision-proof email for parallel test runs."""
    return f"{prefix}+{uuid.uuid4().hex[:8]}@example.com"


def load_cases(filename: str) -> list[dict]:
    """Load test cases from a JSON file, safely."""
    path = DATA_DIR / filename
    if not path.exists():
        raise FileNotFoundError(f"Test data not found: {path}")
    with path.open() as f:
        return json.load(f)


def parse_price(text: str) -> float:
    """'$42.50' → 42.50"""
    return float(text.strip().replace("$", "").replace(",", ""))
Enter fullscreen mode Exit fullscreen mode
# tests/test_checkout.py
import pytest
from utils.data_helpers import unique_email, load_cases, parse_price

CHECKOUT_CASES = load_cases("checkout_cases.json")


@pytest.fixture
def registered_user(api):
    """Create a user, hand it to the test, then clean up."""
    email = unique_email()
    user = api.create_user(email=email, password="TestPass123!")
    yield user                        # ← test runs here
    api.delete_user(user["id"])       # ← always cleans up


@pytest.mark.parametrize("case", CHECKOUT_CASES, ids=lambda c: c["name"])
def test_checkout_payment_outcomes(page, registered_user, case):
    login(page, registered_user["email"], "TestPass123!")
    add_to_cart(page, product="Backpack")

    total = parse_price(page.get_by_test_id("cart-total").inner_text())
    assert total > 0

    complete_checkout(page, card=case["card"])

    if case["should_succeed"]:
        expect(page.get_by_text("Order confirmed")).to_be_visible()
    else:
        expect(page.get_by_text(case["expected_error"])).to_be_visible()
Enter fullscreen mode Exit fullscreen mode

Count what's in there from this article:

  • Type hints on every helper (-> str, -> list[dict], -> float)
  • pathlib for CI-proof paths
  • JSON loading with a guard clause
  • f-strings for the unique email
  • String methods chained in parse_price
  • uuid for parallel-safe unique data
  • A fixture with yield — setup, test, guaranteed teardown
  • Dictionaries as test-case records
  • Parametrisation driven by external data
  • Descriptive naming throughout
  • A helper that keeps the messy parsing out of the test

That's not a beginner's script. That's the skeleton of a professional framework — and every single piece of it is plain Python from this article.


Part 19: The 80% you can safely skip

Being honest about what not to learn is as valuable as the rest.

You do not, for test automation, need:

  • pandas / numpy — unless you're specifically doing data testing
  • matplotlib / plotting — your reporting tool does this
  • Flask / Django / FastAPI — you're testing web apps, not building them
  • Metaclasses, descriptors — genuinely almost never
  • asyncio in depth — the sync Playwright API is fine for the vast majority of suites
  • Multiple inheritance and MRO — a BasePage is enough
  • __slots__, memory optimisation — irrelevant at test-suite scale
  • Threading — pytest-xdist handles parallelism for you

Ignore all of it, guilt-free. If you ever need one, learn it then.

What to learn next, in order:

  1. OOP properly — because the Page Object Model is just classes
  2. pytest fixtures deeply — the yield pattern is the whole framework
  3. Playwright — locators first, because locators are where suites live or die

That sequence — Python → modern Python/OOP → Playwright → frameworks — is not arbitrary. It's the shortest honest route from here to a professional automation engineer.


The one thing to take away

Here's what I'd want you to remember if you forget everything else in this article.

Python for test automation isn't less Python. It's differently-aimed Python.

You need dictionaries deeply, because API responses are dictionaries. You need yield, because fixtures are built on it. You need pathlib, because CI runs from a different directory than you do. You need to know that except Exception: pass in a test is a lie that will pass forever.

You do not need pandas.

Every hour spent learning generic Python is an hour not spent learning the Python that makes you good at this job. The concepts in this article aren't a subset chosen for being easy. They're the ones chosen for being load-bearing — the ones everything else in test automation is built on.

Learn these deeply. Skip the rest. Then go write a test.

— Himanshu


📘 Volume 1 — Python for Test Automation: Fundamentals to Advanced

Everything in this article — taught properly, in depth, with definitions, real automation examples, "common mistake" warnings, resources, and hands-on practice in every chapter.

The exact Python foundation that professional test automation is built on. No matplotlib. No barking dogs. No wasted hours.

👉 Get Volume 1 here


⭐ Or get the complete path — all 24 volumes

Volume 1 is step one. Here's the whole route:

🐍 Tier 1 — Python Foundations (Vols 1–2) · 🎭 Tier 2 — Playwright Core (3–6) · 🔬 Tier 3 — Playwright Advanced (7–9) · 🏗️ Tier 4 — Framework Engineering (10–12) · ⚙️ Tier 5 — Scale, CI/CD & Reporting (13–15) · 🤖 Tier 6A — AI-Assisted Testing (16–18) · 🧠 Tier 6B — Testing AI Applications (19–21) · 🎯 Tier 7 — Career & Real Projects (22–24)

870 pages. One continuous path from your first Python variable to testing production RAG systems, evaluating hallucinations, and walking into interviews with five portfolio projects and 258 prepared answers.

Get the complete 24-volume Master Bundle


Connect: LinkedIn · Substack — free daily articles · 1:1 consulting · himanshuai.com

Found this useful? A repost helps it reach someone who needs it. 🙏

Top comments (0)