◐ ai-teacher

🛡 Quality, Testing & Ops · Chapter 2

Testing pipelines: unit, integration, contract, data

Code tests prove your logic is right. Data tests prove today's input didn't break it. A pipeline needs both, because the input changes every night and the code doesn't.

Study this lesson free in the app →

The basics

Pipeline testing is checking, automatically, that a pipeline does what you meant — before it ships and every time it runs.

Why it exists: in normal software, if the code is correct and the tests pass, you are mostly done. In data, correct code can still produce wrong numbers because the input changed: the app team renamed fare to fare_amount, a city started sending fares in paise, a partner file arrived empty. So you need tests on the code and tests on the data.

The mental model — four kinds, four questions:

The first three run in CI before merge. The fourth runs in the pipeline after each load, and it is the only one that sees real data.

Words to know: fixture, test pyramid, data contract, schema drift, quarantine.

Explained

Meghalaya Cabs' fare pipeline had 140 unit tests, all green.

On 3 November the driver app started sending fare in paise instead of rupees. Revenue on the dashboard jumped 100× to ₹38 crore for one day. The CFO forwarded the screenshot with one question mark.

Not one unit test was wrong. None of them could have caught it — they test the code, and the code hadn't changed.

The restaurant kitchen

A good kitchen checks four different things:

A perfect recipe does not help when tonight's fish smells off. And checking tonight's fish does not help if the recipe has too much salt. You need all four because they fail in different places.

Where each test runs

Two gates. CI protects you from your mistakes. Data tests protect you from everyone else's.

Unit and contract, in pytest

# unit: pure logic, hand-made input, no database
def compute_fare(km, minutes, surge=1.0):
    base, per_km, per_min = 50, 14, 1.5
    return round((base + km * per_km + minutes * per_min) * surge, 2)

def test_surge_applies_to_whole_fare():
    assert compute_fare(10, 20, surge=1.5) == 330.0   # (50+140+30)*1.5

def test_zero_km_is_base_plus_time():
    assert compute_fare(0, 4) == 56.0

# contract: the event the app team promises to send
RIDE_COMPLETED = {
    "ride_id": str, "driver_id": str,
    "fare_inr": float, "city": str, "completed_at": str,
}

def test_sample_event_matches_contract(sample_event):
    for field, typ in RIDE_COMPLETED.items():
        assert field in sample_event, f"missing {field}"
        assert isinstance(sample_event[field], typ), f"{field} wrong type"

The contract test is most useful in the producer's CI — it fails their PR when they rename a field, before it reaches you.

Integration and data tests

# integration: run the REAL SQL against a real engine with seeded rows
import duckdb

def test_daily_revenue_sql_dedupes_retries():
    con = duckdb.connect()
    con.execute("CREATE TABLE rides AS SELECT * FROM 'tests/seed_rides.csv'")
    # seed contains ride R1 twice (a retried event)
    con.execute(open('sql/daily_revenue.sql').read())
    total = con.execute("SELECT revenue FROM daily_revenue WHERE d='2026-11-04'").fetchone()[0]
    assert total == 1240.0      # R1 counted once
-- data test: runs in production after every load; returns failing rows
SELECT ride_date, sum(fare_inr) AS revenue
FROM fact_rides
GROUP BY ride_date
HAVING sum(fare_inr) > 3 * (SELECT avg(daily) FROM revenue_last_28_days);

The paise bug from the hook fails that last query on the first run.

What each kind can and cannot see

               speed    runs on       catches                  blind to
─────────────────────────────────────────────────────────────────────────────
unit           ms       fake rows     wrong formula, edge      real input,
                                      cases, null handling     SQL dialect
integration    sec–min  seeded real   bad joins, dialect,      tomorrow's
                        engine        type casts, dedupe       input
contract       ms       schema/sample renames, type changes,   values that are
                                      missing fields           well-typed but wrong
data           min      production    bad input, drift,        logic bugs on
                        data          volume drops, dupes      paths data hasn't hit

Every row has a blind spot that another row covers. That is the argument for all four.

Thursday, 4:05 PM — the PR that looked fine

Asha changes the revenue model from JOIN payments to LEFT JOIN payments to include unpaid rides.

She adds WHERE payments.state = 'captured' OR payments.ride_id IS NULL — careful to keep unpaid rides — and the test goes green.

Without the integration test, this would have shipped and been found a week later by a finance analyst. Join fan-out is the bug integration tests exist for.

Gotchas

Words to know

Unit testTests one function's logic on small hand-made input, with no external systems.
Integration testRuns real components together — real SQL on a real engine — with seeded data.
Data contractA written, tested agreement between producer and consumer on schema, types and semantics.
Data testAn assertion on the actual data produced by a run, executed in the pipeline every time.
Schema driftUnannounced change to field names, types or structure in incoming data.
FixturePrepared input or setup for a test — a seed CSV, a temp database, a sample event.
QuarantineMoving failing rows to a side table so good rows can publish and bad ones get reviewed.

In two minutes

Test yourself

1. Which kind of test is the only one that sees real production input?

  1. Unit test
  2. Integration test
  3. Contract test
  4. Data test
Show the answer

Data test — Unit, integration and contract tests run in CI on fixtures. Data tests run inside the pipeline on what actually landed.

2. A LEFT JOIN to payments doubles rides that had two payment attempts. Which test is designed to catch this?

  1. Integration test with seeded data containing a ride with two payment rows
  2. Unit test of compute_fare()
  3. Contract test on the payments schema
  4. Freshness check
Show the answer

Integration test with seeded data containing a ride with two payment rows — Join fan-out only appears when the real SQL runs against data with the duplicate shape. A function test or schema check never executes the join.

3. The app team renames fare_inr to fare_amount. Where is the cheapest place to catch it?

  1. A data test in your nightly pipeline
  2. A contract test in the producer's CI
  3. A dashboard alert
  4. A unit test in your repo
Show the answer

A contract test in the producer's CI — Caught in their PR, the rename never ships. Caught in your nightly run, it has already broken a load. Earlier is always cheaper.

4. What should usually happen when a blocking data test fails after a load?

  1. Log a warning and publish anyway
  2. Retry the load until it passes
  3. Stop publishing (or quarantine bad rows) and alert the owner
  4. Delete the table
Show the answer

Stop publishing (or quarantine bad rows) and alert the owner — The point of a data test is to stop bad numbers reaching consumers. Retrying won't fix bad input; a warning no one reads protects nobody.

5. Why are unit tests usually the majority of a pipeline's code tests?

  1. They are fast and pin down edge cases precisely
  2. They test real data
  3. They replace data tests
  4. They are required by Airflow
Show the answer

They are fast and pin down edge cases precisely — Milliseconds each, no infrastructure, and you can hand-build exactly the edge case you care about. Slower layers cover fewer, broader cases.

6. Trap: all 140 unit tests pass and code coverage is 98%. Fares suddenly arrive in paise. What happens?

  1. A unit test fails
  2. Coverage drops and CI blocks
  3. Nothing in the unit suite notices — the code didn't change
  4. The contract test in your repo fails automatically
Show the answer

Nothing in the unit suite notices — the code didn't change — Unit tests run on fixed fixtures; new input never reaches them. Paise are still floats, so even a type-only contract passes. A range or volume data test catches it.

There are 8 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →