Data Pipeline Testing skill for Codex
Prove the pipeline before it touches prod data. Codex splits pipeline I/O from transformation logic, then tests the pure middle with tiny pytest fixtures that cover nulls, duplicates, late data and time-zone edges. It adds schema contracts at boundaries, a golden dataset whose diffs must be explained in review, idempotency checks on DuckDB or Testcontainers, and DAG import tests, all runnable in CI without production credentials.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- data-pipeline-testing Installs to ~/.agents/skills/data-pipeline-testing/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install data-pipeline-testing The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project data-pipeline-testing Installs to .agents/skills/data-pipeline-testing/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/data-pipeline-testing.zipunzip -o data-pipeline-testing.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) data-pipeline-testing PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $data-pipeline-testing, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Tests batch data pipelines before they reach production - pure transformation functions unit-tested with tiny pytest fixtures, schema contracts at stage boundaries, golden dataset (snapshot) comparisons, idempotency and re-run tests, edge-case fixtures (empty, nulls, duplicates, late data, time zones), DAG import tests for Airflow, Dagster or Prefect, and CI wiring with DuckDB or Testcontainers. Use when writing or refactoring ETL/ELT code in Python or SQL, a pipeline broke production data, a refactor must provably not change outputs, or a team needs a test strategy for its data platform.
Use it when
- Writing or refactoring ETL or ELT code in Python or SQL
- A pipeline bug corrupted production data and needs a regression test
- A refactor must provably produce identical outputs
- A data team needs a layered test strategy and CI setup
Use something else when
- You need checks that run against production data on every load: use data-quality-checks
- You are testing dbt models specifically: use dbt-testing
How to check the result
The suite runs in CI without production secrets, a deliberately broken transformation fails at least one test, and golden outputs match or their diff is explained.
The mistake it prevents
Updating golden files to make a failing test pass without understanding why the output changed.
A tested pipeline has fast unit tests for every transformation, a contract at each boundary where data changes hands, a golden-dataset check that catches any unintended change in outputs, and proof that running the same batch twice gives the same result. All of it runs in CI without production credentials.
Inspect first
- Read
AGENTS.mdfor the test command, orchestrator (Airflow, Dagster, Prefect, cron), engine (pandas, Polars, PySpark, SQL in a warehouse) and existing tests. - Map the pipeline: sources, each transformation step, sinks, and which steps are pure functions versus I/O. Untestable pipelines usually mix I/O and logic in one function.
- Find past incidents (postmortems, issues): each is a test case waiting to be written.
Test layers
| Layer | What it proves | Speed | Tools |
|---|---|---|---|
| Unit | One transformation is correct on hand-built rows | ms | pytest, pandas.testing, polars.testing |
| Contract | Data crossing a boundary has the agreed schema and invariants | ms-s | pandera, Pydantic, JSON Schema, dbt model contracts |
| Golden dataset | Whole pipeline output on fixed input is unchanged (or changes as intended) | s | Parquet/CSV snapshots in repo, DuckDB for diffs |
| Integration | Real I/O works: SQL dialect, warehouse writes, file formats | s-min | DuckDB, Testcontainers (Postgres, Kafka), local Spark |
| Orchestration | DAGs load, dependencies are wired, schedules valid | s | Airflow DagBag, Dagster Definitions validation |
| Runtime checks | Production data meets expectations each run | per run | hand off to $data-quality-checks |
Spend most effort on unit and golden tests; they catch most regressions cheaply.
Make logic testable
Separate I/O from transformation so the middle is a pure function of DataFrames:
# pipeline/orders.py
import polars as pl
def extract(conn) -> pl.DataFrame: ... # I/O
def load(df: pl.DataFrame, conn, batch_date: str) -> None: ... # I/O
def transform(orders: pl.DataFrame, fx: pl.DataFrame) -> pl.DataFrame: # pure
return (
orders.filter(pl.col("status") != "test")
.join(fx, on=["currency", "order_date"], how="left", validate="m:1")
.with_columns((pl.col("amount") * pl.col("rate_to_eur")).round(2).alias("amount_eur"))
.unique(subset="order_id", keep="last", maintain_order=True)
)
validate="m:1" makes Polars raise if the FX table has duplicate keys; pandas has
merge(..., validate="many_to_one").
Unit tests with tiny fixtures
# tests/test_orders_transform.py
from datetime import date
import polars as pl
from polars.testing import assert_frame_equal
import pytest
from pipeline.orders import transform
@pytest.fixture
def fx():
return pl.DataFrame({"currency": ["USD", "EUR"], "order_date": [date(2025, 10, 1)] * 2,
"rate_to_eur": [0.92, 1.0]})
def make_orders(**overrides):
base = {"order_id": ["o1"], "status": ["paid"], "currency": ["USD"],
"order_date": [date(2025, 10, 1)], "amount": [100.0]}
return pl.DataFrame({**base, **overrides})
def test_converts_to_eur(fx):
out = transform(make_orders(), fx)
assert out["amount_eur"].to_list() == [92.0]
def test_drops_test_orders(fx):
assert transform(make_orders(status=["test"]), fx).is_empty()
def test_duplicate_order_keeps_last(fx):
orders = make_orders(order_id=["o1", "o1"], status=["paid", "paid"], currency=["EUR", "EUR"],
order_date=[date(2025, 10, 1)] * 2, amount=[10.0, 12.0])
assert transform(orders, fx)["amount_eur"].to_list() == [12.0]
def test_missing_fx_rate_yields_null_not_crash(fx):
out = transform(make_orders(currency=["JPY"]), fx)
assert out["amount_eur"].null_count() == 1
def test_duplicate_fx_rates_raise(fx):
with pytest.raises(Exception):
transform(make_orders(), pl.concat([fx, fx]))
Edge-case checklist for fixtures
- [ ] Empty input (zero rows, but correct schema)
- [ ] Nulls in every nullable column, including join keys
- [ ] Duplicates on the business key
- [ ] Late-arriving rows for an earlier batch date
- [ ] Time-zone boundaries: 23:30 local on the last day of a month, DST change days
- [ ] Unicode and very long strings; leading zeros in IDs
- [ ] Extreme numbers: 0, negative, very large, NaN vs null
- [ ] Schema drift: an extra column, a missing optional column
Contracts at boundaries
import polars as pl
import pandera.polars as pa
class OrdersOut(pa.DataFrameModel):
order_id: str = pa.Field(unique=True)
amount_eur: float = pa.Field(ge=0, nullable=True)
order_date: pl.Date
class Config:
strict = True # unexpected columns fail
def transform_checked(orders, fx):
return OrdersOut.validate(transform(orders, fx))
Validate on the producer side before writing, and on the consumer side for data you do
not control. For dbt models, use model contracts and unit tests; hand off to
$dbt-testing.
Golden datasets
Freeze a small, representative input (hundreds to a few thousand rows, anonymised) and the expected output in the repo:
# tests/test_golden.py
from pathlib import Path
import polars as pl
from polars.testing import assert_frame_equal
from pipeline.orders import transform
GOLDEN = Path(__file__).parent / "golden"
def test_orders_golden(request):
out = transform(pl.read_parquet(GOLDEN / "orders_in.parquet"), pl.read_parquet(GOLDEN / "fx.parquet"))
out = out.sort("order_id")
expected_path = GOLDEN / "orders_out.parquet"
if request.config.getoption("--update-golden", default=False):
out.write_parquet(expected_path)
assert_frame_equal(out, pl.read_parquet(expected_path), check_dtypes=True, abs_tol=1e-9)
# tests/conftest.py
def pytest_addoption(parser):
parser.addoption("--update-golden", action="store_true", help="rewrite golden outputs")
- Sort deterministically before comparing; row order is not a contract.
- Updating goldens is a reviewed change: the PR must show and explain the diff. Summarise
it with DuckDB:
select * from 'new.parquet' except select * from 'old.parquet'. - Never update goldens just to make a red test green without understanding why.
Idempotency and re-runs
def test_rerun_same_batch_is_idempotent(duckdb_conn):
run_pipeline(duckdb_conn, batch_date="2025-10-01")
first = duckdb_conn.sql("select count(*), sum(amount_eur) from fct_orders").fetchone()
run_pipeline(duckdb_conn, batch_date="2025-10-01")
assert duckdb_conn.sql("select count(*), sum(amount_eur) from fct_orders").fetchone() == first
Also test: a backfill of an older date does not touch other partitions, and a failed run midway leaves the target unchanged (write to staging, then swap).
Integration and orchestration
- SQL that targets Postgres: run it against Postgres in Testcontainers
(
testcontainers.postgres.PostgresContainer("postgres:17")), which needs Docker and may need network to pull images. DuckDB is a fast stand-in only when the SQL is portable. - Airflow: a test that every DAG imports without errors and has no cycles:
from airflow.models import DagBag
def test_dags_import():
bag = DagBag(dag_folder="dags/", include_examples=False)
assert not bag.import_errors, bag.import_errors
- Dagster: load
Definitionsin a test and materialise assets with in-memory I/O managers. - Prefect: call flow functions directly in tests; they run locally without a server.
Gotchas
- Tests that read production or shared staging data are flaky and leak data into CI. Use synthetic or anonymised fixtures only.
datetime.now()inside transforms makes outputs untestable; pass the batch date in.- Float comparisons on money: use tolerances or decimal types consistently.
- Spark tests: one shared local
SparkSessionfixture withscope="session"keeps the suite fast; setspark.sql.shuffle.partitionsto a small number. - Mocking the database instead of running SQL hides dialect bugs; test SQL on a real engine.
Verification
pytest -q tests/ # unit + golden + contract, should run in seconds
pytest -q -m integration tests/ # if marked; needs Docker
- Mutate one transformation deliberately (change a filter); at least one test must fail.
- CI runs the suite on every PR without production secrets.
Delivery template
**Pipeline:** orders_daily (Polars, Airflow)
**Refactor:** transform() split from I/O; batch_date injected
**Tests added:** 14 unit (edge-case checklist), 1 golden (2,400 rows), 2 contract, 1 idempotency (DuckDB), DAG import
**Runtime:** 3.1 s locally; integration (Postgres via Testcontainers) 22 s, marked `integration`
**Mutation check:** removing the test-order filter fails 2 tests
**Golden diff:** none (refactor is output-identical) What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Data Pipeline Testing"
short_description: "Prove the pipeline before it touches prod data"
default_prompt: "$data-pipeline-testing Add tests to our orders ETL so the next refactor cannot change outputs silently" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.