Python for Data Testing — Language Essentials, Pandas & DataFrames¶
What this is: the programming companion to the Data Testing guide — just enough Python to be dangerous, then pandas and DataFrames in depth, ending with runnable data-quality test patterns. Same spirit as the TypeScript Cheat Sheet, but for the data side of QA.
1. Typical Python Facts (the 60-Second Orientation)¶
- Interpreted & dynamically typed — no compile step; a variable's type is determined at runtime (
x = 5thenx = "five"is legal — the flexibility and the danger). - Indentation is syntax — blocks are defined by whitespace, not
{ }. Four spaces is the convention; mixing tabs/spaces is an error. - Everything is an object — numbers, strings, functions, even types themselves.
- Batteries included — a huge standard library (
csv,json,datetime,pathlib,sqlite3) before you install anything. - The ecosystem is why data people use it — pandas, NumPy, pytest, SQLAlchemy, Great Expectations: the de-facto data & testing stack.
- Versions: modern code is Python 3.10+;
python --versionto check. Use a virtual environment per project:python -m venv venv→venv\Scripts\activate(Windows). - Zero-indexed, like most languages:
items[0]is the first element;items[-1]is the last (negative indexing is idiomatic Python).
2. Core Language Cheat Sheet¶
2.1 Variables & basic types¶
count = 42 # int
price = 19.99 # float
name = "Manoj" # str
active = True # bool (capital T/F)
nothing = None # Python's null
f"Order {count} costs £{price:.2f}" # f-string formatting → 'Order 42 costs £19.99'
2.2 The four workhorse collections¶
# list — ordered, mutable, allows duplicates
statuses = ["placed", "shipped", "delivered"]
statuses.append("cancelled")
statuses[0] # 'placed'
statuses[-1] # 'cancelled'
statuses[1:3] # slice → ['shipped', 'delivered']
# tuple — ordered, IMMUTABLE (great for fixed records)
point = (51.5, -0.12)
# dict — key → value (THE data-testing structure)
row = {"order_id": 1001, "amount": 250.0, "status": "shipped"}
row["amount"] # 250.0
row.get("discount", 0) # 0 (safe access with default — no KeyError)
# set — unique values, fast membership & DIFFERENCE
source_ids = {1, 2, 3, 4, 5}
target_ids = {1, 2, 3, 5}
source_ids - target_ids # {4} ← rows LOST in the pipeline
target_ids - source_ids # set() ← no phantom rows ✅
2.3 Control flow & functions¶
def classify(amount: float) -> str: # type hints = self-documenting tests
if amount >= 1000:
return "high"
elif amount >= 100:
return "medium"
return "low"
for row in rows: # iterate anything
print(row["order_id"])
# list comprehension — Python's signature move
amounts = [r["amount"] for r in rows]
big_rows = [r for r in rows if r["amount"] > 100]
by_id = {r["order_id"]: r for r in rows} # dict comprehension → keyed lookup
2.4 Files, errors, and the test frame¶
import json, csv
from pathlib import Path
data = json.loads(Path("orders.json").read_text()) # JSON → dicts/lists
try:
value = float(raw)
except ValueError: # catch SPECIFIC exceptions
quarantine.append(raw) # bad rows go to quarantine, never vanish
assert len(data) == 84213, f"expected 84213 rows, got {len(data)}" # the QA verb
pytest in one line: put functions named
test_*in a file namedtest_*.py, use plainassert, runpytest -v. That's the entire entry barrier.
3. Pandas & DataFrames — The Data-Testing Workbench¶
pandas is Python's data-manipulation library; the DataFrame is its core object — an in-memory table with named, typed columns and fast vectorised operations. For a data tester, the DataFrame is what the browser is for a UI tester: the surface you inspect and assert against.
3.1 Creating & loading DataFrames¶
# from a dict (test fixtures)
df = pd.DataFrame({
"order_id": [1001, 1002, 1003, 1004],
"customer": ["alice", "bob", "alice", "dana"],
"amount": [250.0, 75.5, 1200.0, None],
"status": ["shipped", "placed", "delivered", "placed"],
})
# from real sources
df = pd.read_csv("orders.csv")
df = pd.read_parquet("orders.parquet")
df = pd.read_sql("SELECT * FROM orders", connection)
df = pd.read_json("orders.json")
3.2 Inspecting — the first five commands you always run¶
df.head() # first 5 rows
df.shape # (rows, columns) → (4, 4)
df.dtypes # column types — catches "amount arrived as string"
df.info() # types + non-null counts in one view
df.describe() # min/max/mean/quartiles — instant sanity profile
3.3 Selecting & filtering¶
df["amount"] # one column (a Series)
df[["order_id", "amount"]] # multiple columns
df[df["amount"] > 100] # boolean filter — rows over 100
df[(df["status"] == "placed") & (df["amount"].isna())] # combine with & | ~
df.loc[df["customer"] == "alice", "amount"] # rows by condition, one column
df.iloc[0] # first row by position
3.4 Transforming¶
df["amount_gbp"] = df["amount"] * 0.79 # new derived column
df["customer"] = df["customer"].str.upper() # vectorised string ops
df["order_date"] = pd.to_datetime(df["order_date"]) # proper datetime type
df = df.rename(columns={"customer": "customer_name"})
df = df.sort_values("amount", ascending=False)
df = df.drop_duplicates(subset=["order_id"]) # dedupe by key
3.5 Nulls — the data tester's daily bread¶
df.isna().sum() # null count per column ← run this constantly
df[df["amount"].isna()] # SHOW me the offending rows
df["amount"] = df["amount"].fillna(0) # replace
df = df.dropna(subset=["order_id"]) # or drop rows missing the key
3.6 Grouping & aggregation (reconciliation fuel)¶
df.groupby("status")["amount"].sum() # revenue per status
df.groupby("customer").agg(
orders=("order_id", "count"),
total =("amount", "sum"),
avg =("amount", "mean"),
)
df["amount"].sum() # grand total → compare to the ledger
3.7 Joining (merge) — and catching join bugs¶
merged = orders.merge(customers, on="customer_id", how="left", indicator=True)
merged["_merge"].value_counts()
# both 9,950 ← matched
# left_only 50 ← orders whose customer_id has NO customer → referential bug!
indicator=True is the single most useful pandas flag for testers — it turns silent join losses into a countable column.
3.8 Comparing two DataFrames (the regression test)¶
# strict equality — schema + values (baseline vs new output)
pd.testing.assert_frame_equal(
baseline.sort_values("order_id").reset_index(drop=True),
current .sort_values("order_id").reset_index(drop=True),
check_dtype=False, atol=0.01, # tolerate float dust
)
# or diff row-by-row
diff = baseline.compare(current) # shows exactly which cells changed
4. Putting It Together — Runnable Data-Quality Tests¶
Each test below implements a dimension from the Data Testing guide §8 — drop them into test_orders_quality.py and run pytest -v.
import pandas as pd
import pytest
@pytest.fixture
def df():
return pd.read_parquet("warehouse/daily_orders.parquet")
# ── Completeness ──────────────────────────────────────────────
def test_row_count_reconciles(df):
source_count = 84_213 # from source-system query
assert len(df) == source_count, f"lost {source_count - len(df)} rows in pipeline"
def test_no_null_keys(df):
assert df["order_id"].isna().sum() == 0
# ── Uniqueness ────────────────────────────────────────────────
def test_order_id_unique(df):
dupes = df[df.duplicated(subset=["order_id"], keep=False)]
assert dupes.empty, f"duplicate keys:\n{dupes[['order_id']].head()}"
# ── Validity ──────────────────────────────────────────────────
def test_status_in_allowed_set(df):
allowed = {"placed", "shipped", "delivered", "cancelled"}
bad = set(df["status"].dropna().unique()) - allowed
assert not bad, f"unexpected status values: {bad}" # catches new upstream enums!
def test_amounts_non_negative(df):
assert (df["amount"].dropna() >= 0).all()
# ── Consistency ───────────────────────────────────────────────
def test_totals_match_ledger(df):
ledger_total = 1_284_550.25
assert abs(df["amount"].sum() - ledger_total) < 0.01
# ── Referential integrity ─────────────────────────────────────
def test_every_order_has_customer(df):
customers = pd.read_parquet("warehouse/customers.parquet")
m = df.merge(customers[["customer_id"]], on="customer_id",
how="left", indicator=True)
orphans = m[m["_merge"] == "left_only"]
assert orphans.empty, f"{len(orphans)} orders reference missing customers"
# ── Freshness ─────────────────────────────────────────────────
def test_data_is_fresh(df):
latest = pd.to_datetime(df["loaded_at"]).max()
assert latest > pd.Timestamp.now() - pd.Timedelta(hours=2)
# ── Snapshot regression (golden baseline) ─────────────────────
def test_matches_golden_baseline():
baseline = pd.read_parquet("golden/daily_sales_baseline.parquet")
current = pd.read_parquet("warehouse/daily_sales.parquet")
pd.testing.assert_frame_equal(
baseline.sort_values("region").reset_index(drop=True),
current .sort_values("region").reset_index(drop=True),
atol=0.01,
)
Beyond hand-rolled asserts: when suites grow, graduate to declarative DQ frameworks — Great Expectations or Pandera (schema + checks as config/code), and dbt tests for in-warehouse SQL assertions. The concepts are identical; the pandas versions above are what's happening under the hood.
5. Pandas Quick Reference Card¶
| Task | Code |
|---|---|
| Load | pd.read_csv / read_parquet / read_sql / read_json |
| Peek | df.head() · df.shape · df.dtypes · df.describe() |
| Filter | df[df["col"] > x] · & \| ~ for combos |
| Nulls | df.isna().sum() · fillna() · dropna() |
| Dedupe | df.duplicated(subset=[...]) · drop_duplicates() |
| Group | df.groupby("k")["v"].sum() / .agg(...) |
| Join | a.merge(b, on="k", how="left", indicator=True) |
| Compare | pd.testing.assert_frame_equal(a, b) · a.compare(b) |
| New column | df["new"] = df["a"] * 2 |
| Dates | pd.to_datetime() · pd.Timestamp.now() · pd.Timedelta |
Where to Go Next¶
- Data Testing guide — the concepts these tests implement
- TypeScript Cheat Sheet — the same treatment for the UI-automation side
- Ragas FAQ / DeepEval FAQ — Python frameworks built on this stack
- Official docs: pandas.pydata.org — the 10 minutes to pandas page