Skip to main content
Data Engineering · lesson 12 of 23
Open the Data Engineering page →
Data Engineering intermediate Lesson 12 of 23

Also part of: Software Testing

Testing Data Pipelines

Write tests for a transform function, then let a 'harmless' refactor slip through code review and see the test suite catch it instead of production.

By SkillByExample · Published

data-engineering testing data-quality pipeline-testing

Write four tests for two small transform functions, watch them pass, then let a “harmless” refactor slip past review and see which test catches it.

Two transforms, four tests

def clean_email(raw: str) -> str:
    return raw.strip().lower()

def dedupe_orders(rows: list[dict]) -> list[dict]:
    seen = set()
    out = []
    for row in rows:
        if row["order_id"] not in seen:
            seen.add(row["order_id"])
            out.append(row)
    return out

def test_clean_email_trims_whitespace():
    assert clean_email("  Ada@Example.com  ") == "ada@example.com"

def test_clean_email_lowercases():
    assert clean_email("GRACE@EXAMPLE.COM") == "grace@example.com"

def test_dedupe_orders_keeps_first_occurrence():
    rows = [{"order_id": 1, "amount": 10}, {"order_id": 1, "amount": 999}, {"order_id": 2, "amount": 20}]
    result = dedupe_orders(rows)
    assert len(result) == 2
    assert result[0]["amount"] == 10, f"expected first occurrence (amount=10), got amount={result[0]['amount']}"

def test_dedupe_orders_empty_input():
    assert dedupe_orders([]) == []

A tiny runner that reports pass/fail without pulling in a test framework:

def run_tests(tests):
    passed = failed = 0
    for name, fn in tests:
        try:
            fn()
            print(f"PASS  {name}")
            passed += 1
        except AssertionError as e:
            print(f"FAIL  {name}: {e}")
            failed += 1
    print(f"{passed} passed, {failed} failed")

run_tests([
    ("clean_email trims whitespace", test_clean_email_trims_whitespace),
    ("clean_email lowercases", test_clean_email_lowercases),
    ("dedupe_orders keeps first occurrence", test_dedupe_orders_keeps_first_occurrence),
    ("dedupe_orders handles empty input", test_dedupe_orders_empty_input),
])
$ python pipeline_testing.py
PASS  clean_email trims whitespace
PASS  clean_email lowercases
PASS  dedupe_orders keeps first occurrence
PASS  dedupe_orders handles empty input
4 passed, 0 failed

The “optimization” that changes behavior

Someone notices dedupe_orders builds a set and a list by hand and “simplifies” it to a dict comprehension — same row count in, same row count out, code review approves it on sight:

def dedupe_orders(rows: list[dict]) -> list[dict]:
    # "optimization": keep the last-seen row per order_id instead of the first
    by_id = {row["order_id"]: row for row in rows}
    return list(by_id.values())
== after the "optimization" ==
PASS  clean_email trims whitespace
PASS  clean_email lowercases
FAIL  dedupe_orders keeps first occurrence: expected first occurrence (amount=10), got amount=999
PASS  dedupe_orders handles empty input
3 passed, 1 failed

Both versions have the same row count, same input, same output shape — a reviewer scanning the diff has no reason to suspect anything changed. The dict comprehension keeps whichever row overwrites last, which for a duplicate order (a common CDC replay artifact — see the incremental-loads-and-cdc lesson) is the exact opposite of “first occurrence wins,” and no one asked which one was actually correct. The test did not know why it mattered; it only had to know what the function was supposed to do, stated once, in advance.

Practice

1. Add a test asserting `clean_email` handles an already-clean address unchanged.
def test_clean_email_idempotent():
    assert clean_email("ada@example.com") == "ada@example.com"
PASS  clean_email idempotent
1 passed, 0 failed

A transform that is not idempotent on already-clean input usually indicates it is doing more than it claims — worth asserting explicitly rather than assuming.

2. Write a test for a `dedupe_orders` input containing zero duplicates.
def test_dedupe_orders_no_duplicates():
    rows = [{"order_id": 1, "amount": 10}, {"order_id": 2, "amount": 20}]
    assert dedupe_orders(rows) == rows
PASS  dedupe_orders no duplicates
1 passed, 0 failed

The “no duplicates” case is the one a bug in the duplicate-handling logic is least likely to break, but it is also the cheapest to write — no reason to skip it.

3. Revert `dedupe_orders` to the first-occurrence version and confirm all four original tests pass again.
PASS  clean_email trims whitespace
PASS  clean_email lowercases
PASS  dedupe_orders keeps first occurrence
PASS  dedupe_orders handles empty input
4 passed, 0 failed

The test suite is the thing that makes “revert and confirm” a 5-second check instead of a manual re-read of the diff.

4. Which of the four original tests would have caught a bug where `clean_email` stripped whitespace but forgot to lowercase?

test_clean_email_lowercases specifically, and only that one — test_clean_email_trims_whitespace’s input is already lowercase, so it would still pass even with a broken lowercasing step. This is why each test should isolate one behavior: a test that happens to pass for the wrong reason gives false confidence.

Next: streaming, exactly-once semantics — the same correctness problems with the clock running.

Frequently Asked Questions

How is testing a pipeline different from testing regular application code?
The transform functions themselves are tested exactly like any other function — plain unit tests. What's different is that a pipeline also needs data-shaped tests: does it handle an empty batch, a duplicate key, a null in a required field, a late-arriving row? Those are pipeline-specific failure modes a typical CRUD app's test suite never has to think about.
Isn't this the same thing as the data-quality lesson?
No — data quality checks run against production data every load, catching bad data the pipeline was never told to expect. Pipeline tests run against fixed, known test data before a code change ships, catching a bug in the pipeline's logic itself. One protects against bad input; the other protects against bad code.
Do I need pytest, or is plain assert enough?
Plain assert plus a small runner (as in this lesson) is enough to demonstrate the idea and works with zero dependencies. In a real codebase, use pytest — it gives you fixtures for building test DataFrames, parametrized tests for running one assertion against many input rows, and much better failure output, without changing what you're actually testing.
What should a pipeline test actually assert against — a full table or a few rows?
A handful of hand-built rows chosen to hit specific edge cases (a duplicate, a null, an empty batch), not a copy of production data. A test fixture should be small enough that a human can verify the expected output by hand — if the fixture needs a spreadsheet to check, it has stopped being a unit test.