🐍 Python for Data Engineers · Chapter 13
Testing pipelines with pytest
Application bugs return a 500. Data bugs return a plausible wrong number that nobody questions for three weeks.
Study this lesson free in the app →
The basics
Testing a pipeline means two different jobs: proving your code transforms data correctly, and proving the data your pipeline produced is plausible every time it runs.
The problem it solves: an application bug is loud — someone gets a 500. A data bug is quiet: the dashboard loads, the number looks like a number, and it is wrong for three weeks before finance notices it is 22% below the payment provider. Code tests alone cannot catch this, because often the code is fine and the world changed — a new status value, a reformatted fare column.
The mental model: a bottling plant. Inspect the machine before the shift with a known bottle (unit test, in CI). Inspect the line continuously during production (data quality check, on every run). Neither replaces the other.
Words you must know 5
- Unit test
- a fixed small input, an expected output, no network; runs in milliseconds
- Fixture
- reusable test setup in pytest (sample rows, a temp directory, a DB connection)
- Mock / monkeypatch
- replace the network or clock with a fake so tests are fast and deterministic
- Data quality check
- assertions on real output: row counts, nulls, uniqueness, accepted values, freshness
- Reconciliation
- comparing your totals against an independent source of truth
Explained
A broken API is loud: someone gets an error, someone pages you.
A broken pipeline is quiet. The dashboard still loads. The number is still a number. It is just wrong — and by the time anyone notices, three weeks of decisions have been made on it.
That asymmetry is why data engineers test differently: you test the code, and you also test the data the code produced.
Two inspections at the factory
A bottling plant checks two different things.
The machine inspection happens before the shift: is the capper torqued correctly, is the labeller aligned? You test the machine with a known bottle and a known cap. Fast, offline, no real product involved. That is your unit test.
The line inspection happens continuously during production: are bottles arriving at the expected rate, is the fill level within tolerance, did we somehow produce more caps than bottles? That is your data quality test — and no amount of machine inspection replaces it, because reality sends you things the machine was never shown.
Most teams do the first and skip the second. Then they find out in the quarterly review.
The five layers, cheapest first
Layers 1–3 run in CI, before merge. Layers 4–5 run in production, on every pipeline run — because they test facts about the world, not facts about your code.
The pytest you actually need
import pytest
from pipeline import validate, total_completed_fare
# ---- 1. plain assert, no boilerplate ----
def test_rejects_missing_ride_id():
bad = {"event_id": "e1", "event_type": "ride_completed", "fare": 100}
assert validate(bad).reason == "missing_ride_id"
# ---- 2. fixture: shared, reusable setup ----
@pytest.fixture
def events():
return [
{"event_id": "e1", "ride_id": "r1", "event_type": "ride_completed",
"fare": 250, "event_time": "2026-07-31T10:00:00Z"},
{"event_id": "e1", "ride_id": "r1", "event_type": "ride_completed",
"fare": 250, "event_time": "2026-07-31T10:00:00Z"}, # duplicate!
]
def test_duplicates_are_not_double_counted(events):
total, _ = total_completed_fare(events)
assert total == 250 # not 500
# ---- 3. parametrize: one test, many cases ----
@pytest.mark.parametrize("fare, reason", [
(None, "invalid_fare"),
(-50, "invalid_fare"),
("250", "invalid_fare"),
(0, "invalid_fare"),
])
def test_bad_fares_rejected(fare, reason):
e = {"event_id": "e", "ride_id": "r", "event_type": "ride_completed",
"fare": fare, "event_time": "2026-07-31T10:00:00Z"}
assert validate(e).reason == reason
# ---- 4. expected failures ----
def test_raises_on_unreadable_source():
with pytest.raises(FileNotFoundError):
load("does/not/exist.jsonl")parametrize is the highest-value thing here: four edge cases, four separate reported results, one function.
The test that would have caught it
Remember the Pandas lesson: the upstream team reformatted fares as "1,250.00", to_numeric(errors="coerce") turned them all into NaN, and the report showed ₹0 with a perfectly normal row count.
No unit test would have caught it. The function was correct. The world changed.
What catches it is a data quality test that runs on real output, every run:
def test_output_is_plausible(daily_revenue, previous_run):
assert daily_revenue.revenue.sum() > 0, "zero revenue"
assert daily_revenue.revenue.isna().mean() < 0.01, "too many nulls"
assert len(daily_revenue) > 0, "empty output"
drop = 1 - daily_revenue.revenue.sum() / previous_run.revenue.sum()
assert drop < 0.30, f"revenue fell {drop:.0%} vs previous run"The last assertion is the one that matters. Absolute thresholds go stale as the business grows; comparison against the previous run stays honest, and catches the class of bug where the number is a number but the wrong one.
Fixtures that touch the outside world
# tmp_path — a real directory, cleaned up automatically
def test_writes_partitioned_output(tmp_path):
write_rides(sample_df, tmp_path)
assert (tmp_path / "city=Shillong").exists()
# monkeypatch — no network in unit tests, ever
def test_retries_then_succeeds(monkeypatch):
calls = []
def fake_get(url, **kw):
calls.append(url)
return FakeResponse(503) if len(calls) < 3 else FakeResponse(200, {"data": []})
monkeypatch.setattr("pipeline.requests.get", fake_get)
fetch("http://api/rides", {})
assert len(calls) == 3 # proves the retry logic, in milliseconds
# a real database, once per test session
@pytest.fixture(scope="session")
def db():
conn = connect(TEST_DSN)
conn.execute(open("schema.sql").read())
yield conn # everything before yield = setup
conn.close() # everything after = teardownThe retry test is worth studying: it verifies genuinely important behaviour, with zero network and zero waiting. Testing time-based logic without sleeping is what separates fast suites from ones people skip.
What is wrong with this test?
def test_daily_revenue():
df = pd.read_parquet("s3://prod/marts/daily_revenue/")
assert df.revenue.sum() == 12_408_332.50It passes today.
The six data quality dimensions
What you assert about production data — the checklist worth memorising:
- Completeness — are rows missing? (row count vs the previous run, vs the source)
- Uniqueness — is the primary key actually unique? (
ride_idappearing twice) - Validity — do values obey the rules? (fare > 0, status in an allowed set)
- Consistency — do related systems agree? (rides completed vs payments captured)
- Timeliness — is the data fresh enough? (max event_time within the SLA)
- Accuracy — does it match reality? (only reconciliation against a source of truth answers this)
In dbt these become unique, not_null, accepted_values, relationships and freshness tests, declared in YAML next to the model. In Python, Great Expectations or Soda. The tool matters far less than having a named list you check against — which is exactly what an interviewer is probing for.
Words to know
| Fixture | Reusable pytest setup (and teardown) injected into tests as an argument. |
|---|---|
| parametrize | Running one test function across many input cases, each reported separately. |
| monkeypatch | Temporarily replacing a function or attribute during a test — how you avoid real network calls. |
| tmp_path | A pytest fixture giving a real temporary directory, cleaned up automatically. |
| Unit test | Tests one function in isolation with controlled input; fast and offline. |
| Integration test | Tests components together against real dependencies like a database. |
| Contract test | Verifies the source still provides the agreed schema and semantics. |
| Data quality test | An assertion about production data — completeness, uniqueness, validity, freshness. |
| Reconciliation | Comparing your totals against an independent source of truth. |
| Flaky test | A test that passes and fails without code changes — usually shared state, time or randomness. |
In two minutes
- Pipelines need two kinds of tests: code tests in CI and data checks in production on every run.
- Unit tests use 5–20 hand-written rows with your real edge cases — empty input, duplicates, nulls, wrong types, late data.
- pytest essentials: plain assert, fixtures (yield for teardown), parametrize for many cases, tmp_path, monkeypatch to remove network and clock.
- Keep the suite fast, deterministic and isolated, or people skip it; inject the clock instead of calling datetime.now().
- Data checks cover completeness, uniqueness, validity, consistency, timeliness, and accuracy (only reconciliation answers accuracy).
- Compare output to the previous run, not to a hard-coded number; a 30% drop should block publishing.
- A filter on an enum is an assumption; an accepted-values check at ingestion fires the day a new value appears.
Test yourself
1. Which bug can a unit test NOT catch?
- A wrong aggregation formula
- The source adding a new status value your filter silently excludes
- A duplicate not being deduped
- A crash on empty input
Show the answer
The source adding a new status value your filter silently excludes — The code does exactly what it was written to do; the assumption about the world went stale. That needs an accepted-values check on production data.
2. Why is assert df.revenue.sum() == 12_408_332.50 a bad test?
- It is too slow
- It hard-codes today's value, so it fails tomorrow and gets skipped forever
- sum() is inaccurate
- It needs a fixture
Show the answer
It hard-codes today's value, so it fails tomorrow and gets skipped forever — Tests that must be edited daily get disabled. Assert on invariants and on comparison with the previous run instead.
3. Where should data quality checks run?
- Only in CI before merge
- In production after every pipeline run
- Only when someone reports a problem
- Once at project setup
Show the answer
In production after every pipeline run — They test facts about the world, not about your code. The world changes between deployments, so they must run on every run.
4. What does @pytest.mark.parametrize give you?
- Parallel test execution
- One test function run over many cases, each reported separately
- Automatic mocking
- Randomised inputs
Show the answer
One test function run over many cases, each reported separately — It is the cheapest way to cover edge cases — and each case reports independently, so you see exactly which input broke.
5. Which check would catch a pipeline that succeeded but produced 40% fewer rows?
- A unit test of the transform
- A row-count comparison against the previous run
- A type annotation
- Increasing test coverage
Show the answer
A row-count comparison against the previous run — Nothing crashed and the code is correct — only a plausibility check against recent history catches silent partial output.
6. Which data quality dimension can only be answered by reconciling against an independent source of truth?
- Uniqueness
- Timeliness
- Validity
- Accuracy
Show the answer
Accuracy — You can check uniqueness, validity and freshness from your own table. Whether the number matches reality — the payment provider's settled total, the source system's count — needs a second, independent source.
There are 9 questions, 3 puzzles and 7 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.
Start this lesson →