Integrity tests load every DAG file the way the DAG processor does and assert on the result; unit tests exercise the plain code the tasks call, which is why BookNest keeps its logic in importable modules:
@pytest.fixture(scope="session")
def dagbag():
return DagBag(dag_folder="/opt/airflow/dags") # parses files like the DAG processor
def test_no_import_errors(dagbag):
assert dagbag.import_errors == {}
def test_daily_pipeline_shape(dagbag):
dag = dagbag.dags["booknest_daily"]
assert dag.max_active_runs == 1 and dag.catchup is False
assert dag.get_task("publish").upstream_task_ids == {"model.check"}
def test_trigger_fires_once_the_day_is_complete(tmp_path):
write_orders(tmp_path / "export.jsonl", "2026-06-30T23:55:02Z", "2026-07-01T00:03:12Z")
trigger = ExportWatermarkTrigger(str(tmp_path / "export.jsonl"), "2026-06-30", 0.1)
event = asyncio.run(anext(trigger.run()))
assert event.payload == {"ds": "2026-06-30", "newest": "2026-07-01T00:03:12Z"}run7/w6.sh runs them with docker run --rm --network none --entrypoint pytest, mounting dags, include and tests read-only into l2/airflow-booknest-test:3.3.2 (the BookNest image plus pytest 9.1.1): no network, no metadata database, no running stack. The last lines of its output:
tests/test_pipeline.py::test_trigger_fires_once_the_day_is_complete PASSED [100%] ============================== 8 passed in 10.72s ==============================
Two Airflow 3.3 129 details broke the first draft: DagBag no longer takes include_examples (examples follow [core] load_examples), and dagbag.get_dag() queries the metadata database, so the tests read dagbag.dags. Run the suite in CI on every change (Package Managers and DevOps).