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()
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"
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"
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()
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
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'
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
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
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.
(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
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
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
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
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),
]
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
.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"}
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
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'
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
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"}
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}")
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)
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)
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)
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()
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
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!
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
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
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)
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
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}
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"]}
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)]
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
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 # ❌❌❌
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")
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
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
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
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
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")
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
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
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'}, ...]
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)
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
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
from pages.cart_page import CartPage
from utils.data_helpers import unique_email
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
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")
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):
...
@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):
...
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
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
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]] = [...]
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
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
Don't memorise it. Install ruff or black and let the tool enforce it:
pip install ruff
ruff check .
ruff format .
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): ...
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(",", ""))
# 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()
Count what's in there from this article:
-
Type hints on every helper (
-> str,-> list[dict],-> float) -
pathlibfor CI-proof paths - JSON loading with a guard clause
- f-strings for the unique email
-
String methods chained in
parse_price -
uuidfor 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
-
asyncioin depth — the sync Playwright API is fine for the vast majority of suites -
Multiple inheritance and MRO — a
BasePageis 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:
- OOP properly — because the Page Object Model is just classes
-
pytest fixtures deeply — the
yieldpattern is the whole framework - 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)