How do you run TestGen from Airflow?

DataOps TestGen runs from Airflow through its REST API. One Python task starts a test suite run, polls the job until it finishes, reads the failed and warning counts, and raises an exception when any test fails, so downstream tasks never run. There is no Airflow connector or provider package, and you don't need one.

Key points

  • TestGen runs from Airflow, not inside it: an Airflow task calls the TestGen REST API, and the tests themselves run as SQL inside your database.
  • The TestGen API pattern is submit, poll, fetch: POST /api/v1/test-suites/{test_suite_id}/test-runs returns a job ID, GET /api/v1/jobs/{job_id} reports status, and GET /api/v1/test-runs/{job_id} returns failed, warning, and error counts.
  • To fail a DAG on a blocking test, raise an exception in the task when the failed or error count is above zero; Airflow then skips every task that depends on it.
  • Open-source TestGen authenticates the API with an OAuth 2.1 client whose access tokens last one hour and whose refresh token lasts 30 days; TestGen Enterprise also offers personal access tokens.
  • DataOps Observability has a documented Airflow agent that polls the Airflow REST API with a service account, so DAG runs and TestGen results can sit on one timeline.

TestGen runs from Airflow: an Airflow task calls the TestGen REST API, and TestGen runs the tests as SQL inside your database. No connector exists, and none is needed. Airflow never sees a row. TestGen tests any of the 14 databases it supports, plus files.

This page is the how-to. To choose a tool in the first place, read the Airflow tool comparison. To decide what a failed check should do to the pipeline, read stop, quarantine, retry, or publish.

Get a token

The API authentication page offers two kinds. An OAuth 2.1 client works in open source: register once, store the refresh token as an Airflow Variable or secret, and trade it for a one-hour access token on every run. The refresh token lasts 30 days, and getting a new one means signing in through a browser again, so put the renewal on a calendar. TestGen Enterprise adds personal access tokens, a single static token that suits a quick setup. Every request acts with the role of the user who owns the token.

The base URL is the TestGen API server (http://<host>:8530 on a Docker or pip install), not the UI.

The task

The Run Profiling and Tests page gives the pattern: submit, poll, fetch. Submission returns at once with a job ID. Three statuses are terminal: completed, error, and canceled. Give the poll a timeout.

import time, requests
from airflow.sdk import task, Variable   # Airflow 3; on Airflow 2: airflow.decorators, airflow.models
from airflow.exceptions import AirflowFailException

def access_token(base):
    r = requests.post(f"{base}/oauth/token",
        auth=(Variable.get("tg_client_id"), Variable.get("tg_client_secret")),
        data={"grant_type": "refresh_token",
              "refresh_token": Variable.get("tg_refresh_token")})
    r.raise_for_status()
    return r.json()["access_token"]

@task
def testgen_gate(test_suite_id: str, **context):
    TG = Variable.get("testgen_api_url")    # e.g. http://testgen-host:8530
    h = {"Authorization": f"Bearer {access_token(TG)}"}
    job = requests.post(f"{TG}/api/v1/test-suites/{test_suite_id}/test-runs",
        headers=h,
        json={"metadata": {"dag_id": context["dag"].dag_id,
                           "run_id": context["run_id"]}}).json()
    deadline = time.time() + 3600
    while True:
        status = requests.get(f"{TG}/api/v1/jobs/{job['id']}", headers=h).json()["status"]
        if status in ("completed", "error", "canceled"):
            break
        if time.time() > deadline:
            raise TimeoutError(f"TestGen job {job['id']} still {status}")
        time.sleep(30)
    if status != "completed":
        raise RuntimeError(f"TestGen job {job['id']} ended {status}")  # retries apply
    counts = requests.get(f"{TG}/api/v1/test-runs/{job['id']}",
                          headers=h).json()["result"]["result_counts"]
    if counts["failed"] + counts["error"] > 0:
        raise AirflowFailException(f"TestGen: {counts['failed']} failed, {counts['error']} errored")
    return counts

Put testgen_gate between the load and whatever reads from it: load >> testgen_gate("<suite id>") >> publish. When it raises, publish never runs and the dashboards keep yesterday’s numbers.

Blocking and warning results

A test run summary counts passed, failed, warning, error, log, and dismissed. Block on failed and on error, which means a test couldn’t run. Let warning through and send it to a person. That split is the tripwire pattern DataKitchen built in Databricks: a gate task that exits non-zero, so the orchestrator’s own dependency graph stops the next step.

Run profiling on its own schedule, not on every load. The scheduling docs recommend biweekly to monthly.

Where the results land

The run shows up on TestGen’s Test Results page with your metadata under Run details. The same summary comes back from the API. Notification rules send failures to email, Slack, Microsoft Teams, or any HTTPS webhook, including a Jira automation rule.

For one view across the pipeline, DataOps Observability has a documented Airflow agent. It polls the Airflow REST API with a service account, every 5 seconds by default. Connect TestGen to Observability and test results arrive as Test Outcomes events on a dataset component, beside the DAG runs. DataKitchen’s post on the four points in a medallion architecture where testing matters explains why you watch the tools as well as the tables: an Airflow job that hangs for three hours fails no data test.

The same calls work from a CI runner; see running data tests in CI/CD.

Documentation: TestGen API, API authentication, OAuth clients, Run profiling and tests, Scheduling, Notifications, Connect to Observability, Observability Airflow agent.

Frequently asked questions

Short answers to the follow-up questions people ask next.

Is there an Airflow operator or provider for TestGen?

No. TestGen runs from Airflow through its REST API, so a plain Python task with an HTTP client is the whole integration. The task starts a test run, polls the job, and reads the result counts. No connector is needed because Airflow never touches the data; TestGen runs the tests as SQL inside your database.

Which token should an Airflow task use to call TestGen?

Use an OAuth 2.1 client in open-source TestGen. The task trades a stored refresh token for an access token that lasts one hour, and the refresh token lasts 30 days from issuance, so repeat the one-time setup, including its browser sign-in, before it expires. TestGen Enterprise also offers personal access tokens, which are simpler but stay valid for months if they leak.

Should profiling run inside the DAG on every load?

No. TestGen's scheduling docs recommend profiling biweekly to monthly, because profiling is resource-intensive and the characteristics it measures change slowly. Run test suites after each load instead, daily to weekly. Trigger profiling from the API with POST /api/v1/table-groups/{table_group_id}/profiling-runs on its own schedule, or let TestGen's scheduler run it.

What happens if the TestGen job itself errors?

The job ends with status error and an error_message, which is an infrastructure problem, not a data failure. Treat it differently: raise an ordinary exception so Airflow's retries apply. Reserve AirflowFailException, which fails the task without retrying, for a completed run with failed tests, because rerunning the same tests on the same data gives the same answer.

Does any data leave my environment?

No. TestGen runs profiling and tests as SQL inside your database, and TestGen itself runs on your own infrastructure. Airflow receives only job IDs and result counts. TestGen stores results and metadata in its own application database, including the most frequent values and minimum and maximum text values that profiling keeps for display in the Data Catalog.

Try it on your own data

DataOps TestGen is free and open source. Profile a table and generate its tests this afternoon.