Vertex Data

Airflow DAG Testing: Catch Breaks Before Deploy

A practitioner’s guide to testing Airflow DAGs so they don’t break the scheduler, the UI, or your downstream data. Concrete checks, code, and CI examples.

Eric Provencio, Principal Analytics Engineer at Vertex Data Consulting
Eric Provencio
4 min read

Your deploy broke the scheduler, a backfill flooded an external API, or a missing owner left on-call guessing. Airflow DAG testing stops that—before a deploy. The stack that holds up in production is layered: fast import/parse checks, repository policy tests (IDs, cycles, retries, ownership, schedules), task-level unit tests with mocks, local full-DAG runs via dag.test() and CLI, a thin integration environment, and end-to-end data assertions. Below is the exact shape, with code you can drop into your repo, plus what not to test. If you only have 30 minutes, implement import checks and policy tests now; they catch the majority of production breakages at parse time.

Fail fast: import, parse, and structural checks on every DAG file

Most Airflow outages start here: a DAG that won’t import, duplicate IDs, or a bad dependency that deadlocks the Directed acyclic graph. Make your first test run in seconds and block any PR that fails. Use pure-Python parsing—no Airflow instance or Database required.

# tests/test_dag_import.py
import importlib.util
import pathlib
import types

DAGS_DIR = pathlib.Path("dags")

def load_module(path: pathlib.Path) -> types.ModuleType:
    spec = importlib.util.spec_from_file_location(path.stem, path)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)  # raises SyntaxError/ImportError fast
    return mod

def test_all_dags_import_cleanly():
    for dag_file in DAGS_DIR.rglob("*.py"):  # each DAG file
        load_module(dag_file)

Add structural checks for duplicate dag_id, duplicate task_id, and cycles:

# tests/test_dag_structure.py
from airflow.models import DagBag

bag = DagBag(read_dags_from_db=False)

def test_unique_dag_ids():
    ids = [d.dag_id for d in bag.dags.values()]
    assert len(ids) == len(set(ids)), "Duplicate dag_id found"

def test_no_duplicate_task_ids_and_no_cycles():
    for dag in bag.dags.values():
        # no duplicate task_id
        tids = [t.task_id for t in dag.tasks]
        assert len(tids) == len(set(tids)), f"Duplicate task_id in {dag.dag_id}"
        # simple DFS cycle check on dependency graph
        seen, stack = set(), set()
        adj = {t.task_id: set(t.downstream_task_ids) for t in dag.tasks}
        def dfs(n):
            if n in stack: raise AssertionError(f"Cycle in {dag.dag_id} at {n}")
            if n in seen: return
            seen.add(n); stack.add(n)
            for m in adj.get(n, []): dfs(m)
            stack.remove(n)
        for root in adj: dfs(root)

Run these in CI on every commit. They catch bad imports, broken provider upgrades, and subtle dependency mistakes before the scheduler sees them. If you need more depth on keeping the scheduler healthy, see Airflow Scheduler Performance: Find the Bottleneck.

Policy tests: ownership, retries, SLAs, schedules, and safety rails

Next, encode team rules so a PR can’t merge if a DAG is unsafe. Typical failures in the second week: unlimited retries slamming a Server, missing owner so no one gets paged, or a schedule regression that quietly doubles load.

# tests/test_dag_policies.py
import json
from datetime import timedelta
from airflow.models import DagBag

bag = DagBag(read_dags_from_db=False)

REQUIRED_TAGS = {"team:", "tier:"}

def test_metadata_and_retries_are_safe():
    for dag in bag.dags.values():
        assert dag.owner, f"{dag.dag_id} missing owner"
        assert dag.default_args.get("retries", 0) <= 3, f"{dag.dag_id} too many retries"
        delay = dag.default_args.get("retry_delay", timedelta(minutes=5))
        assert delay >= timedelta(minutes=1), f"{dag.dag_id} retry_delay too small"
        assert dag.max_active_runs <= 4, f"{dag.dag_id} max_active_runs too high"
        assert dag.concurrency is None or dag.concurrency <= 16
        tags = set(dag.tags or [])
        assert any(t.startswith("team:") for t in tags), f"{dag.dag_id} missing team tag"

# Schedule regression guardrail: compare to a committed snapshot
SNAPSHOT_PATH = "tests/resources/dag_schedules.json"

def test_schedules_havent_changed_without_ack():
    current = {d.dag_id: str(d.timetable_summary) for d in bag.dags.values()}
    with open(SNAPSHOT_PATH) as f:
        snap = json.load(f)
    assert current == snap, (
        "DAG schedule/timetable changed; update snapshot to ack intentional change"
    )

Regenerate the snapshot intentionally with a small script when you mean to change cadence. This stops accidental cron drift or timetable swaps. For production-graded guidance on routing alerts and SLAs, skim Airflow DAGs — Production DAGs with retries, SLAs, on-call routing, and observability and Airflow Best Practices From Someone Who's Been Paged at 3am.

Task-level unit tests with mocks: test logic, not providers

Don’t boot an airflow environment to test task logic. Write a unit test for the Python callable or small adapter layer, and mock network or cloud SDKs. You’re testing your airflow code and business rules, not the S3 API.

# dags/orders_extract.py
from airflow.operators.python import PythonOperator
import requests

def fetch_orders(since_ts: str) -> list[dict]:
    resp = requests.get("https://api.example.com/orders", params={"since": since_ts}, timeout=30)
    resp.raise_for_status()
    return resp.json()["orders"]

# in your DAG file, you wire it with PythonOperator
# tests/test_fetch_orders.py
import json
import requests
from orders_extract import fetch_orders

class DummyResp:
    def __init__(self, payload, status=200):
        self._p, self.status_code = payload, status
    def raise_for_status(self):
        if self.status_code >= 400: raise requests.HTTPError(self.status_code)
    def json(self): return self._p

def test_fetch_orders_happy_path(monkeypatch):
    def fake_get(url, params, timeout):
        assert params["since"] == "2025-01-01T00:00:00Z"
        return DummyResp({"orders": [{"id": 1}, {"id": 2}]})
    monkeypatch.setattr(requests, "get", fake_get)
    out = fetch_orders("2025-01-01T00:00:00Z")
    assert len(out) == 2

What not to test here: provider hooks end-to-end, the cloud service itself, or Airflow Operators’ internal retries. Save that for integration. Keep task unit testing fast so it runs on each push in GitHub. If you need model-level visibility with dbt in DAGs, see Airflow + dbt: Run dbt From Airflow With Model-Level Visibility.

Mock external systems and connections (and pass them into dag.test)

Use environment-defined connections and light fakes. This lets you run dag.test() locally and in CI without real secrets or network.

# tests/test_dag_local_run.py
import os
from contextlib import contextmanager
from airflow.models import DagBag

@contextmanager
def temp_conn(key: str, uri: str):
    envkey = f"AIRFLOW_CONN_{key.upper()}"
    old = os.environ.get(envkey)
    os.environ[envkey] = uri
    try: yield
    finally:
        if old is None: os.environ.pop(envkey, None)
        else: os.environ[envkey] = old

bag = DagBag(read_dags_from_db=False)
dag = bag.get_dag("orders_daily")

def test_run_dag_with_fake_conns():
    with temp_conn("snowflake", "snowflake://user:pass@acct/db/schema?warehouse=DEV_WH"), \
         temp_conn("http_default", "http://localhost:9999"):
        # Run a full dag run in-process; Debugging-friendly
        dag.test(run_conf={"since": "2025-01-01"})

FAQ quick hits for testing and debugging:

  • How can I pass a connection to dag.test()? Set AIRFLOW_CONN_* env vars (as above) or insert a Connection in the metadata DB in your test setup.
  • How can I test my connections in the Airflow UI without running my DAG? In the Admin → Connections screen of Apache Airflow, use the Test action; many providers implement test_connection.
  • Can we test the connections before we deploy? Yes—call a Hook’s test_connection in a smoke test, or use UI test in a dev airflow instance.
  • Are you allowed to use Docker Desktop? If company policy forbids it, use Colima or Rancher Desktop on macOS, or Linux. Astronomer’s CLI can also run local containers.

If your Airflow webserver sits behind an Apache HTTP Server proxy, ensure local tests don’t inherit corporate proxy env that break local HTTP mocks.

Local full-DAG runs: dag.test(), CLI, and the Debug executor

Before merging, run the whole workflow once locally. Choose the tool that matches your need to debug:

MethodScopeNotes
dag.test() (Python)Full DAGRuns in-process with Debug executor; easiest to set breakpoints in your IDE.
airflow dags test <DAG_ID> <LogicalDate>Full DAGCLI, uses DebugExecutor; good parity with CI logs.
airflow tasks test <DAG_ID> <TASK_ID> <LogicalDate>Single taskIgnores upstream dependency; fast isolate.
# examples
airflow dags test orders_daily 2025-01-01
airflow tasks test orders_daily extract 2025-01-01

Tune for developer speed: Debug in your IDE (VS Code, PyCharm), print Data samples, and write small fixtures for Comma-separated values test files. Avoid validating by “clicking around” the airflow UI; use repeatable commands and captured logs. If you need a systematic path when a DAG run won’t start, use Airflow DAG Not Running: A Systematic Debugging Path.

CI, integration environments, and end-to-end data assertions

CI should run import/structure, policy, unit tests, and at least one dag.test(). Here’s a minimal GitHub Actions job that installs Apache Airflow with constraints and runs tests.

# .github/workflows/airflow-tests.yml
name: airflow-tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    env:
      AIRFLOW_VERSION: 2.8.4
      PYTHON_VERSION: 3.10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '${{ env.PYTHON_VERSION }}' }
      - name: Install Airflow with constraints
        run: |
          pip install --upgrade pip
          pip install "apache-airflow==${AIRFLOW_VERSION}" \
            --constraint https://raw.githubusercontent.com/apache/airflow/constraints-${AIRFLOW_VERSION}/constraints-${PYTHON_VERSION}.txt
          pip install pytest
      - name: Run fast tests
        run: pytest -q -k "dag_import or dag_structure or dag_policies or fetch_orders"

For integration, bring up a short-lived airflow environment (Astronomer branch deploys, MWAA on Amazon Web Services dev, or Composer dev). Run one real DAG against a staging Database and a tiny dataset. Trigger via CLI or API and inspect logs. Keep it thin and disposable.

Finish with data assertions. Drive a dbt job that runs on a 40M-row orders table on an X-Small warehouse? Add targeted tests that prove the pipeline didn’t corrupt Data:

# dbt schema.yml
models:
  - name: orders
    tests:
      - not_null:
          column_name: order_id
      - relationships:
          to: ref('customers')
          field: customer_id
# or a plain SQL assert in Airflow
-- tests/sql/orders_rowcount.sql
SELECT CASE WHEN COUNT(*) >= 1000 THEN 1 ELSE 0 END AS ok FROM analytics.orders;

These are best practices, but tailor them: test airflow where it can break (parsing, scheduling, retries), and test your business logic where it lives. For broader data-quality strategy, see Data Quality Testing Strategy That Catches Real Problems and our data quality articles. If you’re planning platform upgrades, anchor on Airflow 3 Migration: A Production Cutover Plan and our Airflow & orchestration articles.


Need help to test your Airflow at this standard—or to set up CI that runs green the first week and the fiftieth? Start a conversation: Vertex Data Consulting.

About the author

Eric Provencio Analytics engineer who has built and run production data platforms for Disney, Hulu, Nike, Peloton, Gopuff, and Kaplan. Founded Vertex Data Consulting to do the deep work most data teams never find time for: dbt Cloud migrations, repo performance, Airflow reliability, and AI agents that actually touch the stack.

Keep reading

Start a conversation

Tell us what's slowing your data team down.

We maintain a small client roster on purpose. If we're the wrong fit, we'll say so — and usually we know somebody who isn't.

  • Replies within 2 business days
  • NDA before specifics
  • Fixed-scope first engagement, retainer if it works
What do you want help with? *

We reply within 2 business days if there's a fit. No newsletters, ever.