Lesson 1 - Unit-Testing Tasks

Welcome to Unit-Testing Tasks

Every DAG so far in this course has been tested the same way: write it, save it to .airflow-local/dags/, wait for the scheduler to notice it, trigger a real run, and poll until it finishes. That’s the right way to test that a DAG works — dependencies, trigger rules, retries, all of it. It is a slow, heavy way to test whether one function’s logic is correct, and CityFlow’s validate_trips task has real logic worth testing on its own: five checks that decide, for every single trip, whether it’s clean or quarantined.

By the end of this lesson, you will be able to:

  • Pull a task’s decision logic out into a plain, importable function with no Airflow dependency at all
  • Write real assert-based unit tests covering a valid case, multiple invalid cases, and a genuine edge case
  • Explain, with a real measured number, why testing logic in isolation is faster than testing it by triggering a DAG

Pulling the Logic Out, Not Copying It

cityflow_etl_dag.py’s validate_trips task (Module 5 Lesson 3) does two jobs at once: it decides whether each row is clean, and it reads/writes the files that make that decision visible. Only the first job is worth unit-testing — the second is just I/O. cityflow_validation_logic.py keeps only the first job, as a plain function:

"""Pure, importable CityFlow trip-validation logic (Module 6 Lesson 1).

This module has no Airflow import at all -- it is the same five real checks
cityflow_etl_dag.py's validate_trips task already runs (null required fields,
out-of-range trip_distance / fare_amount / passenger_count, and a
pickup-after-dropoff ordering check), pulled out into one plain function so it
can be unit-tested in isolation: no scheduler, no worker, no live Airflow
install required at all. cityflow_etl_instrumented_dag.py's validate_trips
task (Lesson 5) imports and calls this exact function -- the real DAG task
becomes a thin wrapper (read the CSV, call this, write the report and
quarantine files) around logic that was already proven correct here, not a
second copy of the same five checks."""
import pandas as pd

REQUIRED_FIELDS = ["passenger_count", "trip_distance", "fare_amount", "PULocationID", "DOLocationID"]


def validate_trips_df(trips: pd.DataFrame) -> tuple[dict, "pd.Series"]:
    """Runs the five real CityFlow data-quality checks against `trips` and
    returns (report, any_fail_mask): a JSON-safe report dict shaped exactly
    like validate_trips's own validation_report.json, plus the boolean
    Series a caller uses to split clean vs. quarantine rows."""
    null_mask = trips[REQUIRED_FIELDS].isna().any(axis=1)
    dist_mask = (trips["trip_distance"] < 0) | (trips["trip_distance"] > 100)
    fare_mask = (trips["fare_amount"] < 0) | (trips["fare_amount"] > 500)
    pax_mask = (trips["passenger_count"] < 0) | (trips["passenger_count"] > 6)
    time_mask = trips["tpep_pickup_datetime"] > trips["tpep_dropoff_datetime"]

    checks = {
        "null_required_fields": int(null_mask.sum()),
        "trip_distance_out_of_range": int(dist_mask.sum()),
        "fare_amount_out_of_range": int(fare_mask.sum()),
        "passenger_count_out_of_range": int(pax_mask.sum()),
        "pickup_after_dropoff": int(time_mask.sum()),
    }
    any_fail = null_mask | dist_mask | fare_mask | pax_mask | time_mask
    rows_failed = int(any_fail.sum())
    rows_passed = int((~any_fail).sum())
    pass_rate = rows_passed / len(trips) if len(trips) else 0.0

    report = {
        "total_rows": len(trips), "checks": checks,
        "rows_failed_any_check": rows_failed, "rows_passed": rows_passed,
        "pass_rate": round(pass_rate, 4),
    }
    return report, any_fail

This file is saved to .airflow-local/dags/cityflow_validation_logic.py — inside the DAGs folder, but with no @dag decorator, so the scheduler never treats it as a DAG. It sits there as a plain, importable module any real DAG file can pull from, exactly the way Lesson 5’s guided project will.

Why this split matters

validate_trips_df takes a DataFrame and returns a dict — nothing about it knows what Airflow is. That’s the whole point: a function that doesn’t depend on a scheduler doesn’t need one running to be tested.


Seven Real Tests, No Airflow Required

import sys
import time

import pandas as pd

sys.path.insert(0, ".airflow-local/dags")
import cityflow_validation_logic as vlogic

REQUIRED_COLS = ["tpep_pickup_datetime", "tpep_dropoff_datetime", "passenger_count", "trip_distance", "fare_amount", "PULocationID", "DOLocationID"]


def row(**overrides):
    base = dict(
        tpep_pickup_datetime=pd.Timestamp("2024-01-01 08:00:00"),
        tpep_dropoff_datetime=pd.Timestamp("2024-01-01 08:15:00"),
        passenger_count=1, trip_distance=3.2, fare_amount=14.5,
        PULocationID=142, DOLocationID=236,
    )
    base.update(overrides)
    return base


def df_of(*rows):
    return pd.DataFrame(list(rows))[REQUIRED_COLS]


t_start = time.perf_counter()
n_tests = 0

# Test 1: a genuinely valid row passes every check.
report, any_fail = vlogic.validate_trips_df(df_of(row()))
assert report["rows_passed"] == 1 and report["rows_failed_any_check"] == 0
assert not any_fail.iloc[0]
assert all(v == 0 for v in report["checks"].values())
n_tests += 1
print("PASS: a genuinely valid row passes all five checks")

# Test 2: a genuinely invalid row (out-of-range fare_amount) is caught.
report, any_fail = vlogic.validate_trips_df(df_of(row(fare_amount=-12.0)))
assert report["rows_failed_any_check"] == 1
assert report["checks"]["fare_amount_out_of_range"] == 1
assert any_fail.iloc[0]
n_tests += 1
print("PASS: a negative fare_amount is caught by fare_amount_out_of_range")

# Test 3: a genuinely invalid row (a null required field) is caught.
report, any_fail = vlogic.validate_trips_df(df_of(row(passenger_count=None)))
assert report["checks"]["null_required_fields"] == 1
assert any_fail.iloc[0]
n_tests += 1
print("PASS: a null passenger_count is caught by null_required_fields")

# Test 4: a genuinely invalid row (pickup after dropoff) is caught.
report, any_fail = vlogic.validate_trips_df(df_of(row(
    tpep_pickup_datetime=pd.Timestamp("2024-01-01 08:30:00"),
    tpep_dropoff_datetime=pd.Timestamp("2024-01-01 08:15:00"),
)))
assert report["checks"]["pickup_after_dropoff"] == 1
n_tests += 1
print("PASS: a pickup timestamp after its own dropoff is caught by pickup_after_dropoff")

# Edge case A: the range check is a strict '>', so exactly 100.0 miles passes
# and 100.01 miles fails -- the boundary itself, not just an obviously-bad value.
report, any_fail = vlogic.validate_trips_df(df_of(row(trip_distance=100.0)))
assert report["checks"]["trip_distance_out_of_range"] == 0, "100.0 miles is exactly the boundary and must still pass"
report, any_fail = vlogic.validate_trips_df(df_of(row(trip_distance=100.01)))
assert report["checks"]["trip_distance_out_of_range"] == 1, "100.01 miles is one hundredth of a mile over and must fail"
n_tests += 1
print("PASS: the trip_distance boundary itself (100.0 passes, 100.01 fails) behaves correctly")

# Edge case B: a row that fails TWO checks at once is counted once in
# rows_failed_any_check, but each individual check still reports its own hit --
# the "any" semantics that make the checks dict and the total agree on purpose.
report, any_fail = vlogic.validate_trips_df(df_of(row(fare_amount=-5.0, passenger_count=9)))
assert report["rows_failed_any_check"] == 1, "one bad row must count once, not twice, in the combined total"
assert report["checks"]["fare_amount_out_of_range"] == 1
assert report["checks"]["passenger_count_out_of_range"] == 1
n_tests += 1
print("PASS: a row failing two checks at once counts once in rows_failed_any_check, twice across the two checks")

# Edge case C: an exact regression check against real, already-published numbers.
# This is the same real 2024-01-01 CityFlow slice Module 5 Lesson 3 ingested and
# validated with the original inline logic -- if this pure, extracted function is
# faithful to that logic, it must reproduce those exact real published numbers.
raw = pd.read_parquet(
    ".airflow-local/dags/data/yellow_tripdata_2024-01.parquet",
    columns=["tpep_pickup_datetime", "tpep_dropoff_datetime", "passenger_count", "trip_distance", "PULocationID", "DOLocationID", "fare_amount"],
)
raw["tpep_pickup_datetime"] = pd.to_datetime(raw["tpep_pickup_datetime"])
raw["tpep_dropoff_datetime"] = pd.to_datetime(raw["tpep_dropoff_datetime"])
day = raw[(raw["tpep_pickup_datetime"] >= pd.Timestamp("2024-01-01")) & (raw["tpep_pickup_datetime"] < pd.Timestamp("2024-01-02"))]
report, any_fail = vlogic.validate_trips_df(day)
print(f"regression check against real 2024-01-01 CityFlow data: {report}")
assert report["total_rows"] == 81013, report["total_rows"]
assert report["rows_passed"] == 69290, report["rows_passed"]
assert report["pass_rate"] == 0.8553, report["pass_rate"]
n_tests += 1
print("PASS: the extracted function reproduces Module 5 Lesson 3's real, already-published numbers exactly")

t_end = time.perf_counter()
elapsed_ms = (t_end - t_start) * 1000
print(f"\n{n_tests} real test cases, {elapsed_ms:.1f} ms total wall-clock time (including reading the real ~50MB parquet file for the regression check)")
PASS: a genuinely valid row passes all five checks
PASS: a negative fare_amount is caught by fare_amount_out_of_range
PASS: a null passenger_count is caught by null_required_fields
PASS: a pickup timestamp after its own dropoff is caught by pickup_after_dropoff
PASS: the trip_distance boundary itself (100.0 passes, 100.01 fails) behaves correctly
PASS: a row failing two checks at once counts once in rows_failed_any_check, twice across the two checks
regression check against real 2024-01-01 CityFlow data: {'total_rows': 81013, 'checks': {'null_required_fields': 10518, 'trip_distance_out_of_range': 1, 'fare_amount_out_of_range': 1307, 'passenger_count_out_of_range': 4, 'pickup_after_dropoff': 2}, 'rows_failed_any_check': 11723, 'rows_passed': 69290, 'pass_rate': 0.8553}
PASS: the extracted function reproduces Module 5 Lesson 3's real, already-published numbers exactly

7 real test cases, 200.8 ms total wall-clock time (including reading the real ~50MB parquet file for the regression check)

Six of these seven tests run against tiny, hand-built one-row DataFrames — that’s on purpose, and it’s most of why this is fast. The seventh deliberately does something heavier (reading the real ~50MB parquet file) specifically to prove the extracted function isn’t just plausible, it’s identical: 81013 total rows, 69290 passed, 0.8553 pass rate — the exact numbers Module 5 Lesson 3 already published from the original inline logic. Even with that real file read included, the whole suite finished in 200.8 ms.


The Real Cost of Testing Through a DAG Instead

The comparison this lesson is actually making needs a real number on the other side, not an assumption. cityflow_hello (Module 2’s three-task DAG) does almost nothing — three tasks, roughly two seconds of real work — which makes it a fair, minimal stand-in for “the cheapest possible real DAG trigger”:

import json
import subprocess
import time

dag_id = "cityflow_hello"

subprocess.run(["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "unpause", dag_id],
                capture_output=True, text=True, check=True)

run_id = f"m6l1_timing_{int(time.time())}"
t0 = time.perf_counter()
trigger = subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "trigger", dag_id, "--run-id", run_id],
    capture_output=True, text=True,
)
if trigger.returncode != 0:
    subprocess.run(["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "reserialize"],
                    capture_output=True, text=True, check=True)
    subprocess.run(
        ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "trigger", dag_id, "--run-id", run_id],
        capture_output=True, text=True, check=True,
    )
print(f"triggered {dag_id!r} as {run_id!r}")

deadline = time.time() + 90
state = "queued"
while state not in ("success", "failed") and time.time() < deadline:
    time.sleep(1)
    r = subprocess.run(
        ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "list-runs", "-d", dag_id, "--output", "json"],
        capture_output=True, text=True, check=True,
    )
    runs = json.loads(r.stdout)
    match = [x for x in runs if x["run_id"] == run_id]
    state = match[0]["state"] if match else "queued"
t1 = time.perf_counter()
print(f"run {run_id!r} reached state: {state}")
assert state == "success"
print(f"real wall-clock time from trigger command to detected success (1s poll interval): {t1 - t0:.2f} s")
triggered 'cityflow_hello' as 'm6l1_timing_1784925570'
run 'm6l1_timing_1784925570' reached state: success
real wall-clock time from trigger command to detected success (1s poll interval): 6.68 s

200.8 ms against 6.68 s is a real, measured ~33x difference — and cityflow_hello is the smallest DAG in this course. validate_trips alone, inside cityflow_etl, is one task in a four-task chain that itself takes several real seconds; testing a single logic change to it by re-triggering the whole DAG and waiting for the scheduler’s own polling loop to notice is real, wasted time, every single time. None of that overhead — CLI trigger, scheduler heartbeat, worker pickup, polling interval — is present when a function is called directly in a Python process. This is the entire argument for pulling logic out into a plain function: not “it’s cleaner,” but a measured 33x.


Key Takeaways

  • cityflow_validation_logic.py’s validate_trips_df has no Airflow import at all — it’s a plain function that takes a DataFrame and returns a dict, callable from a unit test, a notebook, or (Lesson 5) a real DAG task, without a scheduler running anywhere.
  • Real assert-based tests should cover more than “one good case, one bad case” — this lesson’s edge cases (the exact 100.0/100.01 boundary, and a row failing two checks at once) caught behavior a single happy-path test would never have exercised.
  • A regression test against a previously-published real number (Module 5 Lesson 3’s 81013 / 69290 / 0.8553) is a strong way to prove an extracted function is faithful to the logic it replaced, not just superficially similar.
  • The 33x gap measured here (200.8 ms vs. 6.68 s) is real and specific to this machine and this DAG — the exact multiple will vary, but the direction never will: a scheduler, a worker, and a polling loop are real overhead a plain function call doesn’t pay.

Exercises

Exercise 1 — Add an eighth test for a row with a negative trip_distance (rather than one over the 100 mile ceiling) and confirm it’s still caught by trip_distance_out_of_range — the mask checks both directions (< 0 and > 100), but this lesson’s tests only ever exercised the upper bound directly.

Exercise 2 — Write a test for a DataFrame with zero rows (df_of() with no arguments). Confirm validate_trips_df doesn’t raise a ZeroDivisionError and instead returns pass_rate: 0.0 — check the if len(trips) else 0.0 guard in the source above and explain why it’s there.

Exercise 3 — Time the regression check (Test 7) on its own, separate from the other six tests, using time.perf_counter() around just that block. How much of the real 200.8 ms is the parquet read itself versus the five vectorized checks over 81,013 rows? Which one dominates?

Sponsor

Keep DATATWEETS free. Help fund practical data, AI, and engineering lessons for learners worldwide.

Buy Me a Coffee at ko-fi.com