A complete, hands-on Python tutorial built for two audiences at once: people learning Python for the first time, and experienced SDET / QA automation engineers preparing for interviews. Every section pairs a concept with runnable code, and the second half applies Python to test automation and AI-driven testing projects.
pytest/unittest test suites, fixtures, and mockspandas, numpy, and calling LLM APIsThis runs real Python โ compiled to WebAssembly (Pyodide) โ directly in your browser. No backend, no installation. Pick a sample or write your own snippet, then hit Run.
Click "Run Code" once the runtime finishes loading.
Python is the dominant language for test automation (Selenium, Playwright, PyTest, Robot Framework) and for AI/ML tooling (NumPy, pandas, PyTorch, LangChain). Interviewers for SDET roles expect fluency in the language itself, not just "I know Selenium." The map below is what this tutorial covers, module by module.
Python is dynamically typed โ a variable's type is inferred at runtime and can change.
name = "Alice" # str
age = 29 # int
rating = 4.5 # float
is_active = True # bool
tags = ["qa", "sdet"] # list
print(type(age), type(rating), type(is_active))
# <class 'int'> <class 'float'> <class 'bool'>
# type hints (used heavily in modern SDET codebases)
def greet(user: str, times: int = 1) -> str:
return (f"Hello, {user}! " * times).strip()
Beyond basic arithmetic, two things trip people up in interviews: floor division vs. true division, and the
difference between == (value equality) and is (identity). f-strings are the modern,
readable way to build log messages and assertion output โ you'll use them constantly in test code.
a, b = 10, 3
print(a // b, a % b, a ** b) # 3 1 1000
# f-strings โ the standard for readable output/log messages
status, code = "FAILED", 500
print(f"Test {status} with status code {code}")
# comparison chaining & identity vs equality
print(1 < 2 < 3) # True
print([1,2] == [1,2]) # True (equality)
print([1,2] is [1,2]) # False (identity)
Python's if/elif/else and for/while loops
read almost like English, but a few idioms below (the for...else clause, ternary expressions) are
Python-specific and worth knowing cold โ they show up in real automation code for retry logic and conditional
test setup.
status_code = 404
if status_code == 200:
print("OK")
elif 400 <= status_code < 500:
print("Client error")
else:
print("Server error")
# for / while, with else clause (runs if loop wasn't 'break'ed)
for attempt in range(3):
if attempt == 1:
print(f"retry #{attempt} succeeded")
break
else:
print("all retries exhausted")
# ternary expression โ common in test data setup
env = "staging"
base_url = "https://stg.api.com" if env == "staging" else "https://api.com"
Interviewers probe this hard โ know exactly when to reach for each one.
| Type | Mutable? | Ordered? | Typical SDET use |
|---|---|---|---|
list | Yes | Yes | Test data sets, step sequences |
tuple | No | Yes | Fixed records, function return bundles |
dict | Yes | Insertion order (3.7+) | API payloads, config, test params |
set | Yes | No | De-duplication, membership checks |
users = [{"id": 1, "name": "Amit"}, {"id": 2, "name": "Priya"}]
# list comprehension โ extract all names
names = [u["name"] for u in users]
# dict comprehension โ id -> name lookup
by_id = {u["id"]: u["name"] for u in users}
# set โ quick duplicate check on test run IDs
run_ids = ["r1", "r2", "r1"]
print(len(run_ids) != len(set(run_ids))) # True -> duplicates exist
# tuple unpacking โ very common in for-loops over pairs
point = (10, 20)
x, y = point
Functions are first-class objects in Python โ you can pass them around, return them, and store them in
variables. *args and **kwargs let a function accept a flexible, unknown-ahead-of-time
number of arguments, which is exactly how flexible API-client or test-data-builder functions are usually
written.
def build_payload(endpoint, *args, **kwargs):
"""*args -> extra positional values, **kwargs -> extra key/value pairs"""
return {"endpoint": endpoint, "path_params": args, "query": kwargs}
print(build_payload("/users", 1, 2, active=True, limit=10))
# lambda โ short throwaway functions, often used with sort/filter/map
users = [{"name": "Zoe", "age": 25}, {"name": "Ana", "age": 31}]
by_age = sorted(users, key=lambda u: u["age"])
# closures โ a function that remembers state (used to build test-data factories)
def id_generator():
counter = [0]
def next_id():
counter[0] += 1
return counter[0]
return next_id
gen = id_generator()
print(gen(), gen(), gen()) # 1 2 3
def f(items=[]) โ the same list is reused across calls.
Use def f(items=None): items = items or [] instead.Most automation frameworks (Page Object Model, API clients, base test classes) lean on OOP. Bundling related data and behavior into a class โ instead of scattering loose functions and dictionaries โ is what makes a framework navigable once it grows past a handful of test files.
class BasePage:
def __init__(self, driver):
self.driver = driver
def open(self, url):
self.driver.get(url)
return self # enables method chaining
class LoginPage(BasePage): # inheritance
URL = "/login"
def login(self, username, password):
# self.driver.find_element(...).send_keys(...) in real code
return f"logging in as {username}"
def __repr__(self): # dunder / magic method
return f"<LoginPage url={self.URL}>"
page = LoginPage(driver=None)
print(page.login("qa_user", "secret"))
print(repr(page))
print(isinstance(page, BasePage)) # True
_protected / __privateclass Child(Parent), reuse + override behaviorTests and frameworks fail in predictable ways โ a missing fixture file, a timed-out request, a locator that
never appears. Handling these deliberately with try/except and custom exception
classes, rather than letting the whole suite crash, is what makes failures readable instead of cryptic.
class TestDataNotFoundError(Exception):
"""Custom exception โ common in framework code"""
pass
def load_fixture(path):
try:
with open(path) as f: # context manager -> auto-closes the file
return f.read()
except FileNotFoundError:
raise TestDataNotFoundError(f"Missing fixture: {path}")
finally:
print("fixture load attempted") # always runs
try:
load_fixture("nonexistent.json")
except TestDataNotFoundError as e:
print(f"Handled: {e}")
with guarantees cleanup (closing files, DB connections, browser sessions) even if an
exception is raised โ it's Python's answer to try/finally boilerplate."Generators let you produce a sequence of values one at a time instead of building the whole thing in memory up front โ useful when streaming large test-data sets. Decorators wrap a function to add behavior around it (timing, retries, logging) without touching the function's own code, which is why both concepts show up constantly in real framework internals and interview whiteboard questions.
# generator โ lazily yields test IDs instead of building a huge list in memory
def test_id_stream(n):
for i in range(n):
yield f"TC-{i+1:04d}"
for tid in test_id_stream(3):
print(tid)
# decorator โ wraps a function to add behavior (retry, timing, logging)
import functools, time
def retry(times=3):
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
last_err = None
for attempt in range(times):
try:
return fn(*args, **kwargs)
except Exception as e:
last_err = e
raise last_err
return wrapper
return decorator
@retry(times=2)
def flaky_api_call():
print("calling flaky endpoint...")
raise ConnectionError("timeout")
This exact @retry pattern shows up constantly in real SDET codebases wrapping flaky UI/API calls โ
it's a near-guaranteed interview whiteboard question.
These three come up together constantly in API and log-based testing: re pulls structured
values out of unstructured log lines, json converts between Python dicts and the wire format APIs
speak, and requests is the standard client for actually calling those APIs and asserting on the
response.
import re, json
log_line = "2026-08-25 10:03:12 ERROR OrderService: order_id=9981 failed"
match = re.search(r"order_id=(\d+)", log_line)
if match:
print(f"Failed order: {match.group(1)}") # 9981
# json <-> dict, common when asserting on API responses
payload = json.dumps({"user": "qa1", "active": True})
data = json.loads(payload)
assert data["active"] is True
# requests โ the de facto HTTP client for API test automation
import requests
resp = requests.get("https://api.example.com/users/1", timeout=5)
assert resp.status_code == 200
assert resp.json()["id"] == 1
Note: requests needs real network access, so it won't run in the in-browser playground above
โ try the regex/json parts there, and the HTTP call locally.
This is the section interviewers weight most heavily for SDET roles: can you write, structure, and debug an actual test suite โ not just call Selenium methods.
unittest vs pytest| Aspect | unittest | pytest |
|---|---|---|
| Included with Python | Yes (stdlib) | No (pip install) |
| Test class required | Yes โ subclass TestCase | No โ plain functions work |
| Assertions | self.assertEqual(a, b) | plain assert a == b |
| Fixtures | setUp/tearDown | @pytest.fixture, more flexible scopes |
| Parametrized tests | manual loops / subTest | @pytest.mark.parametrize |
import pytest
@pytest.fixture
def api_client():
client = {"base_url": "https://stg.api.com"} # setup
yield client
print("tearing down client") # teardown
@pytest.mark.parametrize("username,password,expected", [
("valid_user", "correct_pw", 200),
("valid_user", "wrong_pw", 401),
("", "", 400),
])
def test_login(api_client, username, password, expected):
status = fake_login(api_client, username, password)
assert status == expected
def fake_login(client, username, password):
if not username or not password:
return 400
return 200 if password == "correct_pw" else 401
You almost never want a unit test hitting a real payment gateway or email service.
from unittest.mock import patch, MagicMock
@patch("my_module.requests.get")
def test_get_user_handles_404(mock_get):
mock_get.return_value = MagicMock(status_code=404, json=lambda: {})
result = my_module.get_user(999)
assert result is None
mock_get.assert_called_once_with("https://api.example.com/users/999")
assert_called_with), fake
is a lightweight working implementation (e.g. an in-memory DB) used in place of the real one.Knowing how to slice and control a large suite from the command line is a day-one skill on real teams.
import pytest
@pytest.mark.smoke
def test_homepage_loads():
assert True
@pytest.mark.slow
@pytest.mark.skip(reason="flaky in CI, tracked in JIRA-4521")
def test_full_checkout_flow():
pass
@pytest.mark.xfail(reason="known bug, fix pending")
def test_export_pdf():
assert 1 == 2
# run only tests marked "smoke"
pytest -m smoke
# run tests whose name matches a keyword expression
pytest -k "login and not checkout"
# stop after the first failure, show local vars on failure
pytest -x --showlocals
# re-run failed tests up to 2 times (needs pytest-rerunfailures)
pytest --reruns 2 --reruns-delay 1
# run in parallel across CPU cores (needs pytest-xdist)
pytest -n auto
Register custom markers like smoke and slow in pyproject.toml or
pytest.ini so pytest doesn't warn about "unknown marks" โ this is a common config question in
interviews about scaling a suite.
These three files โ config, logger, and shared fixtures โ are the backbone of almost every real test framework. Centralizing them means a test suite can point at staging or production by changing one environment variable, and every test gets consistent logging and a shared browser/DB connection without repeating setup code.
# config.py โ centralized, environment-driven config
import os
class Config:
BASE_URL = os.getenv("BASE_URL", "https://stg.api.com")
TIMEOUT = int(os.getenv("TIMEOUT", "10"))
# logger.py โ structured logging instead of print()
import logging
logger = logging.getLogger("sdet")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
# conftest.py (pytest) โ shared fixtures across a whole test suite
import pytest
@pytest.fixture(scope="session")
def driver():
# driver = webdriver.Chrome()
yield "driver-instance"
# driver.quit()
Being able to explain why config is centralized, why logging beats print(), and how
conftest.py shares fixtures across files signals real framework experience, not just script-writing.
The Python language patterns you just learned are what separate a fragile script from a maintainable browser-automation framework.
# Selenium โ explicit waits beat time.sleep() and implicit waits
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
def wait_for_clickable(driver, locator, timeout=10):
return WebDriverWait(driver, timeout).until(
EC.element_to_be_clickable(locator)
)
submit_btn = wait_for_clickable(driver, (By.CSS_SELECTOR, "button[type='submit']"))
submit_btn.click()
# Playwright (sync API) โ auto-waiting is built in, less boilerplate
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto("https://example.com/login")
page.get_by_label("Username").fill("qa_user")
page.get_by_role("button", name="Sign in").click() # waits automatically
browser.close()
@retry like the decorator shown earlier.In high-velocity engineering teams, test execution speed and rich reporting are essential. Pytest's plugin ecosystem extends your test runner without changing test logic.
# 1. Run tests in parallel across CPU cores (pytest-xdist)
pytest -n auto
# 2. Automatically retry flaky tests in CI before declaring failure
pytest --reruns 2 --reruns-delay 1
# 3. Generate a standalone HTML report (pytest-html)
pytest --html=report.html --self-contained-html
# 4. Generate Allure XML results for enterprise dashboards
pytest --alluredir=allure-results
httpx & pytest-asyncioModern microservices (FastAPI / Starlette) leverage async/await for non-blocking I/O. Testing them with standard requests blocks execution โ using httpx.AsyncClient allows non-blocking HTTP requests inside async test suites.
import pytest
import httpx
@pytest.mark.asyncio
async def test_async_user_fetch():
async with httpx.AsyncClient(base_url="https://jsonplaceholder.typicode.com") as client:
response = await client.get("/users/1")
assert response.status_code == 200
data = response.json()
assert data["id"] == 1
assert "email" in data
A single flaky test can erode trust in a build pipeline. Top QA teams isolate flaky tests with custom markers and quarantine suites so intermittent network or browser glitches don't block releases.
import pytest
import logging
logger = logging.getLogger(__name__)
@pytest.mark.flaky(reruns=3)
@pytest.mark.quarantine
def test_payment_gateway_flaky():
# Test interacting with flaky 3rd party staging sandbox
status = "SUCCESS"
assert status == "SUCCESS"
pytest -m "not quarantine" (runs only deterministic tests, protecting pull requests).pytest -m "quarantine" (runs flaky tests separately to gather failure statistics and logs).These are the topics that separate someone who can write a script from someone who can own a production test framework. They come up constantly in interviews for mid-to-senior SDET roles.
Every serious Python project isolates its dependencies so one project's packages don't clash with another's.
# create and activate a virtual environment (stdlib, no install needed)
python -m venv .venv
source .venv/bin/activate # macOS/Linux
.venv\Scripts\activate # Windows
# install and freeze exact versions for reproducible CI runs
pip install pytest requests pandas
pip freeze > requirements.txt
# modern alternative: pyproject.toml + a tool like Poetry or uv
# pyproject.toml pins both the dependency AND its version range
[project]
name = "sdet-framework"
dependencies = [
"pytest>=8.0",
"requests>=2.31",
"pandas>=2.2",
]
API keys (like the Anthropic key used earlier) should never be hardcoded โ load them from the environment.
# .env file (never committed โ add it to .gitignore)
# ANTHROPIC_API_KEY=sk-ant-...
# BASE_URL=https://stg.api.com
from dotenv import load_dotenv
import os
load_dotenv() # reads .env into the process environment
api_key = os.environ["ANTHROPIC_API_KEY"] # raises KeyError if missing -- fail fast
requirements.txt from pip freeze pins exact versions for reproducibility;
pyproject.toml is the modern standard that also declares build metadata and can express version
ranges โ most new projects use it with a tool like Poetry or uv instead of raw pip."@dataclass removes the boilerplate of writing __init__, __repr__, and
__eq__ by hand โ ideal for modeling test data and API request/response objects.
from dataclasses import dataclass, field
@dataclass
class TestUser:
username: str
email: str
is_admin: bool = False
roles: list = field(default_factory=list) # safe mutable default
u1 = TestUser("qa_alice", "alice@test.com")
u2 = TestUser("qa_alice", "alice@test.com")
print(u1) # auto-generated __repr__
print(u1 == u2) # True -- auto-generated __eq__ compares field values
Notice field(default_factory=list) โ this is the dataclass-safe fix for the "mutable default
argument" trap covered earlier in the Functions section.
You've used with open(...) as f. Writing your own is a common "show me you understand what's
happening under the hood" question.
from contextlib import contextmanager
import time
@contextmanager
def timed_step(step_name):
start = time.perf_counter()
try:
yield
finally:
elapsed = time.perf_counter() - start
print(f"{step_name} took {elapsed*1000:.1f}ms")
with timed_step("login flow"):
sum(range(1_000_000)) # stand-in for real work
# class-based equivalent, using __enter__/__exit__
class TempFeatureFlag:
def __init__(self, flags, name):
self.flags, self.name = flags, name
def __enter__(self):
self.flags[self.name] = True
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.flags[self.name] = False # always cleans up, even on exception
Use this pattern to toggle a feature flag on for exactly the duration of a test, guaranteeing it's turned back off even if the test fails midway.
A near-guaranteed interview question: "What's the GIL, and when does it matter?"
async/await instead of OS threads.import asyncio
async def check_endpoint(name, delay):
await asyncio.sleep(delay) # stands in for an async HTTP call
return f"{name}: healthy"
async def run_health_checks():
results = await asyncio.gather(
check_endpoint("auth-service", 0.2),
check_endpoint("orders-service", 0.1),
check_endpoint("search-service", 0.3),
) # runs all three concurrently, not sequentially
for r in results:
print(r)
asyncio.run(run_health_checks())
Try this one in the playground โ pick Sample 5: asyncio. pytest-asyncio is the
standard plugin for writing async def test_... functions directly in pytest.
Type hints (introduced earlier) don't do anything at runtime by themselves โ mypy is what
actually enforces them, catching bugs before the test even runs.
from typing import Optional
def find_user(users: list[dict], user_id: int) -> Optional[dict]:
for u in users:
if u["id"] == user_id:
return u
return None
# mypy would flag this call at *analysis time*, before you ever run it:
find_user([{"id": 1}], "1") # error: Argument 2 has type "str", expected "int"
pip install mypy
mypy src/ # run static analysis over the whole codebase
Pairs directly with SQL knowledge โ this is how you set up and tear down test data programmatically instead of by hand.
# sqlite3 -- stdlib, zero setup, great for local/test databases
import sqlite3
conn = sqlite3.connect(":memory:") # in-memory DB, perfect for fast unit tests
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)")
conn.execute("INSERT INTO users (email) VALUES (?)", ("qa@test.com",))
conn.commit()
row = conn.execute("SELECT * FROM users WHERE email = ?", ("qa@test.com",)).fetchone()
print(row)
# SQLAlchemy ORM -- ergonomic layer for real Postgres/MySQL test DBs
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.orm import declarative_base, Session
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
email = Column(String)
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as session:
session.add(User(email="qa@test.com"))
session.commit()
Try the sqlite3 block in the playground โ pick Sample 6: sqlite3.
Test runners, data seeders, and log analyzers are often small internal CLIs. Rather than hardcoding an
environment or hand-editing a script before each run, exposing options like --env or
--tag on the command line makes the same script reusable across local runs, CI, and teammates.
# argparse -- stdlib
import argparse
parser = argparse.ArgumentParser(description="Run the SDET suite")
parser.add_argument("--env", choices=["staging", "prod"], default="staging")
parser.add_argument("--tag", help="only run tests with this marker")
parser.add_argument("--parallel", action="store_true")
args = parser.parse_args()
print(f"Running against {args.env}, tag={args.tag}, parallel={args.parallel}")
# click -- third-party, less boilerplate, nicer for larger CLIs
import click
@click.command()
@click.option("--env", type=click.Choice(["staging", "prod"]), default="staging")
def run(env):
click.echo(f"Running against {env}")
Huge for API testing โ validate that a response actually matches its schema instead of manually checking each key.
from pydantic import BaseModel, EmailStr, ValidationError
class UserResponse(BaseModel):
id: int
email: EmailStr
is_active: bool
def test_get_user_matches_schema(api_response_json):
try:
UserResponse(**api_response_json) # raises if shape/types don't match
except ValidationError as e:
assert False, f"Response failed schema validation: {e}"
This is exactly how you'd validate the LLM-generated test cases from Project 1 actually match the expected structure before trusting them.
Instead of writing individual example inputs, you describe the shape of valid input, and Hypothesis generates hundreds of cases โ including edge cases you wouldn't think to write by hand.
from hypothesis import given
from hypothesis import strategies as st
def normalize_email(email: str) -> str:
return email.strip().lower()
@given(st.emails())
def test_normalize_is_idempotent(email):
# normalizing twice should always equal normalizing once
assert normalize_email(normalize_email(email)) == normalize_email(email)
Bringing this up unprompted is a strong signal โ it shows you think about correctness properties, not just hand-picked examples.
Useful both for optimizing a slow test suite and for validating application performance. A quick
time.perf_counter() check is enough to spot an obviously slow step, while cProfile
breaks execution down function-by-function when you need to find exactly where the time is going in a larger
block of code.
import time, cProfile
# quick wall-clock timing of a single block
start = time.perf_counter()
result = sum(i * i for i in range(1_000_000))
print(f"took {time.perf_counter() - start:.4f}s")
# full call-graph profiling to find *where* time is spent
def slow_function():
return sorted(range(100_000, 0, -1))
cProfile.run("slow_function()") # prints per-function call counts and time
Try the timing portion in the playground โ pick Sample 7: profiling.
Interviewers sometimes probe whether you care about codebase hygiene, not just making tests pass.
repos:
- repo: https://github.com/psf/black
rev: 24.4.2
hooks:
- id: black # auto-formats code to a consistent style
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.4
hooks:
- id: ruff # fast linter: unused imports, style issues, bugs
pip install pre-commit black ruff
pre-commit install # now black + ruff run automatically before every git commit
black formats code so nobody argues about style in code review; ruff (or the older
flake8) catches unused imports, undefined names, and common bugs before they ever reach CI.
"How would you integrate this into CI?" is close to a guaranteed question โ have a concrete answer ready.
name: Run Test Suite
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest -m smoke --junitxml=results.xml
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: results.xml
Note the pattern: install pinned dependencies, run a fast smoke-marked subset on every push,
and always upload results (even on failure) so they're inspectable from the Actions tab.
Python is the default language of AI/ML tooling. For SDET roles, this increasingly matters for two reasons: testing AI-powered features, and using AI to make test automation smarter (generating test cases, triaging failures, analyzing logs).
| Library | What it's for | Typical SDET/AI use |
|---|---|---|
numpy | Fast numerical arrays | Computing metrics over test-run durations |
pandas | Tabular data analysis | Analyzing CSV test reports, log files |
requests/httpx | HTTP calls | Calling LLM APIs (OpenAI, Anthropic) |
scikit-learn | Classic ML models | Flagging anomalous test failures |
langchain / SDKs | LLM orchestration | Building AI test-case generators |
import pandas as pd
df = pd.DataFrame({
"test_name": ["test_login", "test_checkout", "test_search"],
"duration_ms": [120, 980, 340],
"status": ["passed", "failed", "passed"],
})
print(df[df["status"] == "failed"]) # filter failed tests
print(df["duration_ms"].mean()) # average run time
print(df.sort_values("duration_ms", ascending=False).head(1)) # slowest test
Try this one in the playground above โ pick Sample 4: pandas.
Every AI-powered testing tool is, underneath, Python code making structured API calls to a model and parsing the response. Here's the shape of it:
import os, json, requests
def ask_claude(prompt: str) -> str:
response = requests.post(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": os.environ["ANTHROPIC_API_KEY"],
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
json={
"model": "claude-sonnet-4-6",
"max_tokens": 500,
"messages": [{"role": "user", "content": prompt}],
},
timeout=30,
)
response.raise_for_status()
return response.json()["content"][0]["text"]
Goal: feed a user story in, get structured, ready-to-implement test cases out โ a script you can genuinely demo in an interview.
import os, json, requests
SYSTEM_PROMPT = """You are a senior SDET. Given a user story, output a JSON array of
test cases. Each item must have: id, title, type (functional/edge/negative),
steps (list of strings), expected_result."""
def generate_test_cases(user_story: str) -> list:
resp = requests.post(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": os.environ["ANTHROPIC_API_KEY"],
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
json={
"model": "claude-sonnet-4-6",
"max_tokens": 1200,
"system": SYSTEM_PROMPT,
"messages": [{"role": "user", "content": user_story}],
},
)
text = resp.json()["content"][0]["text"]
return json.loads(text) # model returns pure JSON per the system prompt
if __name__ == "__main__":
story = """As a user, I want to reset my password via email so that
I can regain access to my account if I forget my credentials."""
cases = generate_test_cases(story)
for c in cases:
print(f"[{c['type'].upper()}] {c['id']}: {c['title']}")
Goal: use pandas to flag test runs that are behaving statistically differently
from history โ a lightweight, explainable alternative to a full ML model, and a great "AI-adjacent" project for
a resume.
import pandas as pd
def flag_slow_tests(history_csv: str, z_threshold: float = 2.0) -> pd.DataFrame:
"""Flag test runs whose duration is a statistical outlier vs. their own history."""
df = pd.read_csv(history_csv) # columns: test_name, duration_ms, run_date
stats = df.groupby("test_name")["duration_ms"].agg(["mean", "std"]).reset_index()
merged = df.merge(stats, on="test_name")
merged["z_score"] = (merged["duration_ms"] - merged["mean"]) / merged["std"].replace(0, 1)
return merged[merged["z_score"].abs() > z_threshold]
if __name__ == "__main__":
anomalies = flag_slow_tests("test_run_history.csv")
print(f"Found {len(anomalies)} anomalous runs")
print(anomalies[["test_name", "duration_ms", "z_score"]])
Extend this by feeding flagged anomalies into ask_claude() from the earlier example, asking it
to summarize likely root causes from the associated log lines โ combining classic data analysis with an LLM
reasoning layer.
Goal: paste in a raw pytest traceback, get back a plain-English likely root cause and a suggested next debugging step โ genuinely useful the first time a CI run fails at 2am.
import os, json, requests
TRIAGE_SYSTEM_PROMPT = """You are a senior SDET triaging a failing pytest run.
Given a traceback and the test name, respond with JSON containing:
likely_cause (one sentence), category (one of: test_bug, product_bug,
flaky/environment, data_issue), and next_step (one concrete action)."""
def triage_failure(test_name: str, traceback_text: str) -> dict:
resp = requests.post(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": os.environ["ANTHROPIC_API_KEY"],
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
json={
"model": "claude-sonnet-4-6",
"max_tokens": 400,
"system": TRIAGE_SYSTEM_PROMPT,
"messages": [{
"role": "user",
"content": f"Test: {test_name}\n\nTraceback:\n{traceback_text}",
}],
},
)
return json.loads(resp.json()["content"][0]["text"])
if __name__ == "__main__":
tb = """AssertionError: assert 401 == 200
at test_login.py:42 in test_valid_login"""
result = triage_failure("test_valid_login", tb)
print(f"[{result['category']}] {result['likely_cause']}")
print(f"Next step: {result['next_step']}")
pydantic schema pattern from earlier before trusting itHere are 20 hands-on, interview-ready coding exercises covering core Python, pytest, OOP, concurrency, data validation, and AI tools. Expand any exercise to view the complete solution.
Given tags = ["smoke", "regression", "smoke", "api", "regression", "smoke"], write code that prints each unique tag with its frequency, sorted by count descending.
from collections import Counter
tags = ["smoke", "regression", "smoke", "api", "regression", "smoke"]
for tag, count in Counter(tags).most_common():
print(f"{tag}: {count}")Write a function check_status(response_dict) that returns "PASS" if response_dict["status_code"] == 200, "FAIL" if non-200, and "ERROR" if the key is missing (without raising a KeyError).
def check_status(response_dict):
try:
return "PASS" if response_dict["status_code"] == 200 else "FAIL"
except KeyError:
return "ERROR"Create a class ApiClient with base_url and a method get(path). Subclass it with AuthApiClient to automatically inject an authorization token into the request dictionary.
class ApiClient:
def __init__(self, base_url):
self.base_url = base_url
def get(self, path):
return {"url": self.base_url + path, "status": 200}
class AuthApiClient(ApiClient):
def __init__(self, base_url, token):
super().__init__(base_url)
self.token = token
def get(self, path):
res = super().get(path)
res["token"] = self.token
return resWrite a decorator @retry(times=3, delay=1) that retries a function if it raises an Exception, sleeping delay seconds between attempts and re-raising on the final failure.
import time
from functools import wraps
def retry(times=3, delay=1):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == times:
raise e
time.sleep(delay)
return wrapper
return decoratorWrite a generator function stream_error_logs(file_path) that opens a large log file and yields stripped lines containing "ERROR" without loading the whole file into memory.
def stream_error_logs(file_path):
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
if "ERROR" in line:
yield line.strip()Use @contextmanager from contextlib to create a timer_context(label) that measures and prints execution duration in milliseconds for any code block.
import time
from contextlib import contextmanager
@contextmanager
def timer_context(label="Task"):
start = time.perf_counter()
try:
yield
finally:
elapsed = (time.perf_counter() - start) * 1000
print(f"[{label}] Completed in {elapsed:.2f} ms")Write a parametrized test using @pytest.mark.parametrize testing an email validator function against valid, malformed, and empty inputs.
import pytest
def is_valid_email(email: str) -> bool:
return "@" in email and "." in email.split("@")[-1]
@pytest.mark.parametrize("email, expected", [
("qa@test.com", True),
("invalid-email", False),
("user@domain", False),
("", False)
])
def test_email_validation(email, expected):
assert is_valid_email(email) == expectedDefine a TestSuiteResult dataclass with default factory list for test_names and helper method add_test(name, status).
from dataclasses import dataclass, field
from typing import List
@dataclass
class TestSuiteResult:
suite_name: str
passed_count: int = 0
failed_count: int = 0
test_names: List[str] = field(default_factory=list)
def add_test(self, name: str, status: str):
self.test_names.append(name)
if status.upper() == "PASS":
self.passed_count += 1
else:
self.failed_count += 1Write a function extract_urls(text) using Python's re module to extract all HTTP/HTTPS links from a raw text payload.
import re
def extract_urls(text: str) -> list:
pattern = r"https?://[^\s]+"
return re.findall(pattern, text)Write a unit test for process_order(payment_service, order_id, amount) using MagicMock to verify charge() was called with exact parameters.
from unittest.mock import MagicMock
def process_order(payment_service, order_id, amount):
res = payment_service.charge(order_id, amount)
return res.get("status") == "COMPLETED"
def test_process_order_success():
mock_payment = MagicMock()
mock_payment.charge.return_value = {"status": "COMPLETED", "txn_id": "tx_99"}
success = process_order(mock_payment, "ord_101", 99.99)
assert success is True
mock_payment.charge.assert_called_once_with("ord_101", 99.99)Given a list of test dictionaries with keys name, status, and duration, return a dictionary mapping test names to durations for failing tests only.
results = [
{"name": "test_login", "status": "PASS", "duration": 120},
{"name": "test_checkout", "status": "FAIL", "duration": 450},
{"name": "test_search", "status": "FAIL", "duration": 310},
]
failed_durations = {r["name"]: r["duration"] for r in results if r["status"] == "FAIL"}
print(failed_durations)Write an async function check_all_services() using asyncio.gather() to fetch health status for multiple microservices concurrently.
import asyncio
async def fetch_status(service_name: str, delay: float) -> dict:
await asyncio.sleep(delay)
return {"service": service_name, "status": "UP"}
async def check_all_services():
tasks = [
fetch_status("auth", 0.1),
fetch_status("payments", 0.2),
fetch_status("inventory", 0.15)
]
return await asyncio.gather(*tasks)Define a Pydantic UserPayload model requiring user_id (int), email (str), and default role="user", handling ValidationError exceptions.
from pydantic import BaseModel, Field, ValidationError
class UserPayload(BaseModel):
user_id: int
email: str
role: str = Field(default="user")
try:
user = UserPayload(user_id=101, email="qa@company.com")
print(user.model_dump())
except ValidationError as e:
print(f"Validation error: {e}")Write a Hypothesis test using @given(st.lists(st.integers())) asserting that sorting a list preserves element count and produces monotonically non-decreasing order.
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_sorting_properties(nums):
sorted_nums = sorted(nums)
assert len(sorted_nums) == len(nums)
assert all(sorted_nums[i] <= sorted_nums[i+1] for i in range(len(sorted_nums)-1))Write a function init_test_db() that creates an in-memory SQLite table runs, populates sample rows, and queries the total count of "FAIL" status runs.
import sqlite3
def init_test_db():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE runs (id INT, status TEXT)")
conn.executemany("INSERT INTO runs VALUES (?, ?)", [(1, "PASS"), (2, "FAIL"), (3, "FAIL")])
conn.commit()
cursor = conn.execute("SELECT COUNT(*) FROM runs WHERE status = ?", ("FAIL",))
fail_count = cursor.fetchone()[0]
conn.close()
return fail_countWrite a function calculate_pass_percentage(json_str) that parses JSON data with keys total and passed and safely computes percentage without ZeroDivisionError.
import json
def calculate_pass_percentage(json_str: str) -> float:
data = json.loads(json_str)
total = data.get("total", 0)
passed = data.get("passed", 0)
if total == 0:
return 0.0
return round((passed / total) * 100, 2)Write a function find_first_failure(statuses: List[str]) -> Optional[int] returning the 0-based index of the first "FAIL" entry or None if all pass.
from typing import Optional, List
def find_first_failure(statuses: List[str]) -> Optional[int]:
for index, status in enumerate(statuses):
if status.upper() == "FAIL":
return index
return NoneBuild a CLI parser accepting --env (choices: stg/prod) and --threads (int) flags using Python's argparse module.
import argparse
def parse_cli_args(args_list=None):
parser = argparse.ArgumentParser(description="Test Suite Runner CLI")
parser.add_argument("--env", choices=["stg", "prod"], default="stg")
parser.add_argument("--threads", type=int, default=4)
return parser.parse_args(args_list)Write a function analyze_browser_metrics(df) that groups test execution records by browser and calculates average duration and pass rate for each browser.
import pandas as pd
def analyze_browser_metrics(df: pd.DataFrame) -> pd.DataFrame:
df["is_passed"] = df["status"] == "PASS"
summary = df.groupby("browser").agg(
avg_duration=("duration_ms", "mean"),
pass_rate=("is_passed", "mean")
)
return summaryWrite a function parse_ai_json_response(raw_text) that locates outer curly braces {...} in a markdown LLM response and parses the enclosed JSON string.
import json
def parse_ai_json_response(raw_text: str) -> dict:
start_idx = raw_text.find("{")
end_idx = raw_text.rfind("}")
if start_idx == -1 or end_idx == -1:
raise ValueError("No JSON object found in response")
clean_json = raw_text[start_idx:end_idx+1]
return json.loads(clean_json)20 questions spanning core Python, OOP, testing, concurrency, tooling, and Python for AI. Your score appears at the end.
pytest/unittest fundamentals: fixtures, parametrization, mocking, markers & CLI flagspydantic and property-based testing with hypothesispandas/numpy for analysis, calling LLM APIs, three hands-on projects@retry, mocking, the GIL, and the Page Object Model out loud โ interviewers grade clarity, not just correctnessmypy and a pre-commit hook to a personal project so the workflow becomes muscle memory