Beginner โ†’ Interview-Ready

Python for SDET & AI Projects

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.

๐Ÿ“š 31 Sections โฑ๏ธ ~10 hours ๐Ÿงช Live in-browser Python playground ๐ŸŽฏ Final interview quiz

๐Ÿ’ก What you'll walk away with

  • Solid command of core Python: data structures, OOP, error handling, files, decorators, generators
  • Ability to write and reason about pytest/unittest test suites, fixtures, and mocks
  • Practical SDET patterns: Page Object Model skeletons, data-driven tests, config & logging
  • Working knowledge of Python's role in AI projects โ€” pandas, numpy, and calling LLM APIs
  • Two hands-on AI-for-testing mini projects you can put on your resume

๐ŸŽฎ Interactive Python Playground

This 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.

python3 โ€” in-browser runtime
Loading Python runtimeโ€ฆ
Output
Click "Run Code" once the runtime finishes loading.

What Makes Python Worth Knowing Deeply?

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.

๐Ÿงฎvariables & typesint, float, str, bool
๐Ÿ”€control flowif/else, loops
๐Ÿ“ฆdata structureslist, dict, set, tuple
๐Ÿงฉfunctions*args, **kwargs, lambda
๐Ÿ—๏ธOOPclasses, inheritance
๐Ÿ›Ÿerrors & filestry/except, with
๐Ÿ”iterators/decoratorsyield, @wraps
๐ŸŒregex & APIsre, requests, json
โœ…testingpytest, unittest, mocks
โšกconcurrencyasyncio, threads, GIL
๐Ÿ—„๏ธdatabasessqlite3, SQLAlchemy
๐Ÿ”งtoolingvenv, mypy, black, ruff
๐Ÿ”CI/CDGitHub Actions + pytest
๐Ÿค–Python for AIpandas, numpy, LLM APIs

01Variables & Data Types

Python is dynamically typed โ€” a variable's type is inferred at runtime and can change.

variables.py
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()

02Operators & String Formatting

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.

operators.py
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)

03Control Flow

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.

control_flow.py
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"

04Data Structures

Interviewers probe this hard โ€” know exactly when to reach for each one.

TypeMutable?Ordered?Typical SDET use
listYesYesTest data sets, step sequences
tupleNoYesFixed records, function return bundles
dictYesInsertion order (3.7+)API payloads, config, test params
setYesNoDe-duplication, membership checks
data_structures.py
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

05Functions, *args, **kwargs, Lambda

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.

functions.py
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

โš ๏ธ Frequent interview trap

    Never use a mutable default argument like def f(items=[]) โ€” the same list is reused across calls. Use def f(items=None): items = items or [] instead.

06Object-Oriented Python

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.

oop.py
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

Know these OOP terms cold

  • Encapsulation โ€” bundling data + behavior; convention _protected / __private
  • Inheritance โ€” class Child(Parent), reuse + override behavior
  • Polymorphism โ€” same method name, different behavior per class
  • Composition over inheritance โ€” favor "has-a" (a Test has a Driver) over deep class trees

07Exceptions & File Handling

Tests 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.

errors_and_files.py
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}")

๐Ÿ’ก Interview one-liner

    "with guarantees cleanup (closing files, DB connections, browser sessions) even if an exception is raised โ€” it's Python's answer to try/finally boilerplate."

08Iterators, Generators & Decorators

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.

generators_decorators.py
# 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.

09Regex, JSON & HTTP Requests

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.

regex_and_requests.py
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.

Testing in Python

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.

10unittest vs pytest

Aspectunittestpytest
Included with PythonYes (stdlib)No (pip install)
Test class requiredYes โ€” subclass TestCaseNo โ€” plain functions work
Assertionsself.assertEqual(a, b)plain assert a == b
FixturessetUp/tearDown@pytest.fixture, more flexible scopes
Parametrized testsmanual loops / subTest@pytest.mark.parametrize
test_login_pytest.py
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

11Mocking External Dependencies

You almost never want a unit test hitting a real payment gateway or email service.

test_with_mock.py
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")

โš ๏ธ Common interview question

    "What's the difference between a mock, a stub, and a fake?" โ€” Stub returns canned data, mock also verifies calls were made correctly (assert_called_with), fake is a lightweight working implementation (e.g. an in-memory DB) used in place of the real one.

12pytest Markers, CLI Flags & Config

Knowing how to slice and control a large suite from the command line is a day-one skill on real teams.

pytest_markers.py
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
terminal
# 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.

13SDET Framework Patterns

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.

framework_skeleton.py
# 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.

14Selenium & Playwright Automation Patterns

The Python language patterns you just learned are what separate a fragile script from a maintainable browser-automation framework.

waits_and_locators.py
# 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()

โš ๏ธ Handling a StaleElementReferenceException

    This happens when the DOM re-renders after you located an element but before you acted on it. Fix it by re-locating the element right before interacting โ€” don't cache WebElement references across page state changes โ€” or wrap the interaction in a small @retry like the decorator shown earlier.

14.1Pytest Ecosystem & CI Performance

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.

pytest_ecosystem.sh
# 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

14.2Async API Testing with httpx & pytest-asyncio

Modern 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.

test_async_api.py
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

14.3Flaky Test Management & Quarantine Strategy

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.

test_quarantine.py
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"

๐Ÿ’ก CI Quarantine Execution Pattern

  • Main CI Pipeline: pytest -m "not quarantine" (runs only deterministic tests, protecting pull requests).
  • Nightly Diagnostic Pipeline: pytest -m "quarantine" (runs flaky tests separately to gather failure statistics and logs).

๐Ÿงฐ Beyond the Basics โ€” Practical Engineering Skills

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.

15Virtual Environments & Dependency Management

Every serious Python project isolates its dependencies so one project's packages don't clash with another's.

terminal
# 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
pyproject.toml
[project]
name = "sdet-framework"
dependencies = [
    "pytest>=8.0",
    "requests>=2.31",
    "pandas>=2.2",
]

Secrets & environment variables

API keys (like the Anthropic key used earlier) should never be hardcoded โ€” load them from the environment.

secrets.py
# .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

๐Ÿ’ก Interview one-liner

    "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."

16Data Classes

@dataclass removes the boilerplate of writing __init__, __repr__, and __eq__ by hand โ€” ideal for modeling test data and API request/response objects.

dataclasses_demo.py
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.

17Writing Your Own Context Managers

You've used with open(...) as f. Writing your own is a common "show me you understand what's happening under the hood" question.

context_managers.py
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.

18Concurrency: asyncio, Threading, Multiprocessing & the GIL

A near-guaranteed interview question: "What's the GIL, and when does it matter?"

The three concurrency tools, and when to use each

  • Threading โ€” good for I/O-bound work (network calls, file I/O). The GIL (Global Interpreter Lock) means only one thread executes Python bytecode at a time, but threads release the GIL while waiting on I/O, so this still gives real speedups for I/O-bound tests.
  • Multiprocessing โ€” good for CPU-bound work (heavy computation). Each process gets its own interpreter and GIL, achieving true parallelism at the cost of higher memory and IPC overhead.
  • asyncio โ€” a single-threaded event loop for I/O-bound work with very high concurrency (thousands of API calls), using cooperative async/await instead of OS threads.
asyncio_api_calls.py
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.

19Static Type Checking with mypy

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.

typed_helpers.py
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"
terminal
pip install mypy
mypy src/                    # run static analysis over the whole codebase

20Working with Databases: sqlite3 & SQLAlchemy

Pairs directly with SQL knowledge โ€” this is how you set up and tear down test data programmatically instead of by hand.

db_setup.py
# 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.

21Building CLI Tools: argparse & click

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.

run_tests_cli.py
# 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}")

22Data Validation with pydantic

Huge for API testing โ€” validate that a response actually matches its schema instead of manually checking each key.

pydantic_schemas.py
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.

23Property-Based Testing with Hypothesis

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.

test_property_based.py
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.

24Performance Profiling

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.

profiling.py
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.

25Code Quality: Linting, Formatting & Pre-commit Hooks

Interviewers sometimes probe whether you care about codebase hygiene, not just making tests pass.

.pre-commit-config.yaml
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
terminal
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.

26CI/CD: Running pytest in GitHub Actions

"How would you integrate this into CI?" is close to a guaranteed question โ€” have a concrete answer ready.

.github/workflows/tests.yml
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 for AI Projects

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).

27The Core AI/Data Toolkit

LibraryWhat it's forTypical SDET/AI use
numpyFast numerical arraysComputing metrics over test-run durations
pandasTabular data analysisAnalyzing CSV test reports, log files
requests/httpxHTTP callsCalling LLM APIs (OpenAI, Anthropic)
scikit-learnClassic ML modelsFlagging anomalous test failures
langchain / SDKsLLM orchestrationBuilding AI test-case generators
pandas_basics.py
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.

28Calling an LLM from Python

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:

call_llm.py
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"]

29๐Ÿš€ Hands-On Project 1: AI Test Case Generator

Goal: feed a user story in, get structured, ready-to-implement test cases out โ€” a script you can genuinely demo in an interview.

ai_test_case_generator.py
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']}")

๐Ÿ’ก Why this is a strong interview talking point

  • Shows you can turn unstructured requirements into structured test artifacts programmatically
  • Demonstrates prompt design (a constrained system prompt forcing JSON output)
  • Extend it: pipe the generated cases straight into pytest stubs, or a test-management tool via its API

30๐Ÿš€ Hands-On Project 2: Log Anomaly Detector

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.

anomaly_detector.py
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.

31๐Ÿš€ Hands-On Project 3: AI-Powered Failure Triage

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.

ai_failure_triage.py
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']}")

๐Ÿ’ก Where this goes in a real pipeline

  • Wire it into the CI job from the CI/CD section above โ€” post the triage result as a PR comment
  • Validate the model's JSON output with the pydantic schema pattern from earlier before trusting it
  • This is the same shape as Project 1: constrained system prompt in, parsed JSON out โ€” the pattern generalizes

Practice Exercises

Here 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.

Exercise 1 ยท Data structures

De-duplicate and count test tags

Given tags = ["smoke", "regression", "smoke", "api", "regression", "smoke"], write code that prints each unique tag with its frequency, sorted by count descending.

Show Solution
from collections import Counter
tags = ["smoke", "regression", "smoke", "api", "regression", "smoke"]
for tag, count in Counter(tags).most_common():
    print(f"{tag}: {count}")
Exercise 2 ยท Functions & error handling

Safe API status checker

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).

Show Solution
def check_status(response_dict):
    try:
        return "PASS" if response_dict["status_code"] == 200 else "FAIL"
    except KeyError:
        return "ERROR"
Exercise 3 ยท OOP & Inheritance

Build an API client class hierarchy

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.

Show Solution
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 res
Exercise 4 ยท Custom Decorators

Write a configurable retry decorator

Write 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.

Show Solution
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 decorator
Exercise 5 ยท Generator Functions

Stream large log files line-by-line

Write 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.

Show Solution
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()
Exercise 6 ยท Context Managers

Execution timer context manager

Use @contextmanager from contextlib to create a timer_context(label) that measures and prints execution duration in milliseconds for any code block.

Show Solution
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")
Exercise 7 ยท Pytest & Parametrization

Parametrize email format validation

Write a parametrized test using @pytest.mark.parametrize testing an email validator function against valid, malformed, and empty inputs.

Show Solution
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) == expected
Exercise 8 ยท Data Classes

Define a TestSuiteResult dataclass

Define a TestSuiteResult dataclass with default factory list for test_names and helper method add_test(name, status).

Show Solution
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 += 1
Exercise 9 ยท Regex & Text Parsing

Extract HTTP URLs from log outputs

Write a function extract_urls(text) using Python's re module to extract all HTTP/HTTPS links from a raw text payload.

Show Solution
import re

def extract_urls(text: str) -> list:
    pattern = r"https?://[^\s]+"
    return re.findall(pattern, text)
Exercise 10 ยท Unittest Mock

Mock external payment service

Write a unit test for process_order(payment_service, order_id, amount) using MagicMock to verify charge() was called with exact parameters.

Show Solution
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)
Exercise 11 ยท Dictionary Comprehensions

Filter failed test durations

Given a list of test dictionaries with keys name, status, and duration, return a dictionary mapping test names to durations for failing tests only.

Show Solution
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)
Exercise 12 ยท Concurrency (asyncio)

Concurrent health check runner

Write an async function check_all_services() using asyncio.gather() to fetch health status for multiple microservices concurrently.

Show Solution
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)
Exercise 13 ยท Pydantic Validation

Validate API payload schema

Define a Pydantic UserPayload model requiring user_id (int), email (str), and default role="user", handling ValidationError exceptions.

Show Solution
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}")
Exercise 14 ยท Property-Based Testing

Property test for sorting with Hypothesis

Write a Hypothesis test using @given(st.lists(st.integers())) asserting that sorting a list preserves element count and produces monotonically non-decreasing order.

Show Solution
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))
Exercise 15 ยท SQLite DB Testing

Query in-memory test database

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.

Show Solution
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_count
Exercise 16 ยท File I/O & Safety

Calculate pass percentage from JSON

Write a function calculate_pass_percentage(json_str) that parses JSON data with keys total and passed and safely computes percentage without ZeroDivisionError.

Show Solution
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)
Exercise 17 ยท Type Hinting

Find index of first failing test

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.

Show Solution
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 None
Exercise 18 ยท CLI Tooling

Build test runner arguments with argparse

Build a CLI parser accepting --env (choices: stg/prod) and --threads (int) flags using Python's argparse module.

Show Solution
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)
Exercise 19 ยท Pandas Metrics

Aggregate browser test metrics with pandas

Write a function analyze_browser_metrics(df) that groups test execution records by browser and calculates average duration and pass rate for each browser.

Show Solution
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 summary
Exercise 20 ยท AI Output Parsing

Safely extract JSON object from LLM output

Write a function parse_ai_json_response(raw_text) that locates outer curly braces {...} in a markdown LLM response and parses the enclosed JSON string.

Show Solution
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)

๐ŸŽฏ Final Interview Quiz

20 questions spanning core Python, OOP, testing, concurrency, tooling, and Python for AI. Your score appears at the end.

Summary

โœ… Key takeaways

  • Core Python: variables, control flow, data structures, functions, OOP, error handling, files
  • Advanced language features: iterators, generators, decorators, custom context managers, data classes
  • pytest/unittest fundamentals: fixtures, parametrization, mocking, markers & CLI flags
  • Real-world engineering skills: venvs & dependency pinning, concurrency & the GIL, type checking with mypy, databases, CLI tools, linting/formatting, and CI/CD with GitHub Actions
  • Automation-specific patterns: Selenium/Playwright waits and locators, config management, structured logging, shared fixtures
  • Data validation with pydantic and property-based testing with hypothesis
  • Python's role in AI: pandas/numpy for analysis, calling LLM APIs, three hands-on projects

Next Steps

  • Rebuild the three AI mini-projects above end-to-end with your own API key
  • Practice explaining @retry, mocking, the GIL, and the Page Object Model out loud โ€” interviewers grade clarity, not just correctness
  • Wire a small pytest suite into GitHub Actions using the workflow shown above โ€” a working CI badge is a strong resume signal
  • Add mypy and a pre-commit hook to a personal project so the workflow becomes muscle memory
  • Pair this with a Playwright or Selenium tutorial to connect the language to a live browser
โ†‘