Skip to content

experimental/bundletest: base for pytest-style DABs isolation testing (DuckDB local backend) - #6597

Draft
Sankalp-Mittal wants to merge 20 commits into
mainfrom
sankalp-mittal/dabs-testing-framework
Draft

Sankalp-Mittal wants to merge 20 commits into
mainfrom
sankalp-mittal/dabs-testing-framework

Conversation

@Sankalp-Mittal

@Sankalp-Mittal Sankalp-Mittal commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

What

Base scaffolding for bundletest — an experimental Python library for pytest-style isolation testing of DABs bundles. It tests a single deployed resource in isolation: stand in for the resource's upstream neighbors with env.seed(...), run that one resource's real deployed SQL, and assert on its real output.

This is the base of a stacked-PR series. It ships the scaffolding + a zero-infra local backend; the cloud (deco) backend is the next stacked PR on top.

Why

A bundle can pass 100% of its unit tests and still be broken on deploy — wrong table referenced, a resource wired to the wrong upstream, a transform regression. Those only surface after databricks bundle deploy + a real run. A cloud-only harness would just be automated UAT; the value here is fast local feedback that catches the "validates but doesn't run" bug class.

Design

  • One narrow seam (backend.py): a Backend protocol is the only abstraction with more than one implementation. Every assertion is derived above it from execute_sql/table_schema and never branches on backend type.
  • Isolate by seeding neighbors, not faking output. env.seed("shop.bronze.raw_orders", ...) stands in for the upstream ingest job; the real transform_orders SQL runs. Faking the output of the thing under test is a tautology and is not supported.
  • Two backends selected by BUNDLETEST_BACKEND: local (default) and cloud (follow-up PR; the factory currently raises NotImplementedError).

The local backend (DuckDB) stays honest

An earlier iteration used sqlite + a hand-written Python callable per job. A design review showed that tested a reimplementation, not the deployed artifact — so the headline "wrong table name" catch could pass locally and still fail on deploy. Replaced with a DuckDB backend that:

  • Runs the job's real sql_task artifact (read from databricks.yml) — same source of truth.
  • Binds names at the environment level, never rewrites the query body: seeded tables and target namespaces are created under their real catalog.schema.table names via ATTACH/CREATE SCHEMA, so the unmodified SQL resolves.
  • Routes three ways, never a false green or false red: a missing table/column fails red (the real bug); a Databricks-only function, a notebook/Python task, or a reserved catalog (main/temp/system) can't be judged locally → LocalUnsupported → the test skips with a reason (via a pytest hookwrapper). @pytest.mark.cloud_only fences known-cloud-only assertions the same way.

Layout

experimental/bundletest/
  src/bundletest/         backend.py (seam), table.py, env.py, pytest_plugin.py, backends/duckdb.py
  examples/orders_bundle/ databricks.yml, src/transform_orders.sql, tests/test_transform.py
  tests/                  test_assertions.py (framework tests)

Verification

cd experimental/bundletest
uv venv --python 3.12 && uv pip install -e ".[dev]"
uv run pytest -v          # 11 passed, 1 skipped

Notable tests: test_wrong_table_name_fails_red (headline catch against the real artifact), and notebook-task / Databricks-function / reserved-catalog all skip via LocalUnsupported.

Reviewing this stack

Commits are small and ordered: scaffold → seam → shared API → (sqlite backend, then its replacement by DuckDB) → example → tests. The sqlite→DuckDB pivot is preserved as its own commit so the design change is legible.

This pull request and its description were written by Isaac.

Sankalp-Mittal and others added 9 commits September 10, 2026 09:31
Base package layout for a pytest-style isolation testing framework for
DABs bundles: pyproject (hatchling, src layout, pytest dev extra), README
describing the seed-and-run isolation model and the two-backend design,
and an empty package module. Backends and the test API land in follow-up
commits on this branch.

Co-authored-by: Isaac <no-reply@databricks.com>
Define the Backend protocol and RunResult dataclass. The protocol is the
single abstraction with more than one implementation: deploy/teardown,
seed_table (stand in for an upstream resource), run_job (run one resource),
execute_sql + table_schema (the data plane the assertion layer builds on),
get_resource, and put_file.

Co-authored-by: Isaac <no-reply@databricks.com>
BundleEnv plus TableHandle/ColumnHandle/JobHandle/VolumeHandle, all written
once against the backend seam. bundle_env() is a contextmanager that builds
a backend (selected by BUNDLETEST_BACKEND, default "memory"), deploys, yields
and tears down. The pytest plugin registers the cloud_only marker and skips
those tests with a visible reason on any non-cloud backend. The cloud backend
factory branch raises NotImplementedError until its follow-up PR lands.

Co-authored-by: Isaac <no-reply@databricks.com>
sqlite-backed implementation of the Backend protocol. It is a real engine,
not a mock: seeded rows are stored, run_job executes the job's registered
logic (from the bundle's transforms.py JOBS dict) against them, and
execute_sql/table_schema query the real output. Dotted table refs are
rewritten to flat sqlite names, anchored to FROM/JOIN/INTO/UPDATE/TABLE so
numeric literals are left alone. Failing jobs surface as RunResult(FAILED)
rather than crashing, so bad-data tests can assert on state.

Co-authored-by: Isaac <no-reply@databricks.com>
An orders_bundle example whose transform_orders job dedupes and null-filters
bronze into silver, with an isolation test that seeds bronze, runs the real
job, and asserts on silver (row count, no-nulls, uniqueness). A cloud_only
schema/type assertion demonstrates the loud-skip guard. Framework tests cover
the assertion layer, the numeric-literal rewrite edge case, unknown-job
KeyError, and the failed-run path.

Co-authored-by: Isaac <no-reply@databricks.com>
Commit the uv lockfile so `uv run pytest` resolves the same pytest version
for everyone working on the base.

Co-authored-by: Isaac <no-reply@databricks.com>
…he real SQL

A design review found the sqlite + Python-callable backend tested a
reimplementation of the job, not the deployed artifact — so its headline
catch ("wrong table name") could pass locally and still fail on deploy, and
sqlite's lax typing risked silent false greens.

Replace it with a DuckDB backend that runs the job's actual sql_task artifact
(read from databricks.yml). Invariants: same source of truth (real .sql, never
a reimplementation); names bound at the environment level via ATTACH/CREATE
SCHEMA so the query body is never rewritten; three-way routing via the new
LocalUnsupported signal — missing table/column is red, while a Databricks-only
function, a notebook/Python task, or a reserved catalog skips loudly through a
pytest hookwrapper. Backend kind renamed "memory" -> "local".

Co-authored-by: Isaac <no-reply@databricks.com>
Replace the orders_bundle's Python transforms.py callable with the real
deployed artifact: src/transform_orders.sql, referenced by the job's sql_task
in databricks.yml. The isolation test seeds shop.bronze.raw_orders, runs the
job's real SQL, and asserts on shop.silver.orders. Uses a non-reserved catalog
so the local backend can host it.

Co-authored-by: Isaac <no-reply@databricks.com>
Framework tests now run on the DuckDB backend and pin the routing contract:
a missing table in the real artifact fails red (the headline catch), while a
notebook task, a Databricks-only function, and a reserved catalog each skip
via LocalUnsupported. Also covers env-level name binding (two- and three-part
names) and that numeric literals are not misread as table refs. README updated
to describe the local (DuckDB) backend and the honesty invariants.

Co-authored-by: Isaac <no-reply@databricks.com>
@Sankalp-Mittal Sankalp-Mittal changed the title experimental/bundletest: base scaffolding for pytest-style DABs isolation testing experimental/bundletest: base for pytest-style DABs isolation testing (DuckDB local backend) Sep 10, 2026
@eng-dev-ecosystem-bot

eng-dev-ecosystem-bot commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Integration test report

Commit: 58b20e9

Run: 34609859100

Env 💚​RECOVERED ✅​pass 🙈​skip Time
💚​ aws linux 1 275 15 7:22
💚​ aws windows 1 277 13 4:58
💚​ azure linux 1 274 15 8:48
💚​ azure windows 1 276 13 4:38
💚​ gcp linux 1 275 15 8:29
💚​ gcp windows 1 277 13 5:30
Test Name aws linux aws windows azure linux azure windows gcp linux gcp windows
💚​ TestAccept 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R
Top 3 slowest tests (at least 2 minutes):
duration env testname
5:14 gcp windows TestAccept
3:29 aws windows TestAccept
3:24 azure windows TestAccept

Sankalp-Mittal and others added 6 commits September 10, 2026 11:28
One example file per resource/capability supported today, each with a precise
CAN / CANNOT-test-locally header, so the folder doubles as a record of
capability as the framework grows. Covers: SQL job run+assert (test_job_sql),
resource config/wiring read-back (test_job_config), the non-SQL job loud-skip
boundary (test_job_nonsql, via a new notebook job in the example bundle), and
the volume upload stub (test_volume). Shared env fixture moved to conftest.py.

Co-authored-by: Isaac <no-reply@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>
Formatting-only churn on files not otherwise touched this change.

Co-authored-by: Isaac <no-reply@databricks.com>
Volume upload was a no-op stub. Give the local backend a real volume: put_file
copies the source into a temp volume filesystem, and read_volume_file reads it
back via DuckDB (read_csv/json/parquet). A new FileHandle exposes exists(),
row_count(), and columns, so env.volume(name).file(...) can be asserted on.
The volume example now uploads a CSV and checks its rows/columns; the non-SQL
job example now asserts the LocalUnsupported guard fires rather than just
skipping. Every example test now checks something real.

Co-authored-by: Isaac <no-reply@databricks.com>
Add a second SQL job (aggregate_orders: silver -> gold) and a
test_pipeline_end_to_end that chains bronze -> silver -> gold in one test,
asserting the intermediate and final tables. Demonstrates that an all-SQL
pipeline runs hop by hop locally: the first job's output persists in the env's
shared DuckDB connection for the next to read. The test sequences run_job calls
itself (no automatic dependency ordering yet).

Co-authored-by: Isaac <no-reply@databricks.com>
The lockfile is regenerated behind the internal PyPI proxy, which bakes
pypi-proxy.cloud.databricks.com URLs into every entry and trips the
check-lockfiles guard. Gitignore it for this experimental package.

Co-authored-by: Isaac <no-reply@databricks.com>
…transform

The cloud_only test asserts silver.orders.total_price is decimal(10,2), but the
transform never cast it, so the pipeline produced double end-to-end — the
assertion would fail on the cloud backend. Cast total_price to DECIMAL(10,2) in
transform_orders.sql so the pipeline genuinely produces decimal; the assertion
is now truthful on cloud and stays cloud_only (Databricks 'decimal(10,2)' vs
DuckDB 'DECIMAL(10,2)' spelling). Surfaced by the cloud-backend work.

Co-authored-by: Isaac <no-reply@databricks.com>
Sankalp-Mittal and others added 4 commits September 11, 2026 14:22
A failed job run now raises JobRunFailed (carrying the RunResult) at the handle
layer, so a wired-up-wrong job cannot slip past unnoticed. Tests that deliberately
run a failing job opt out with check=False and inspect the result. Backends still
just return RunResult, so both tiers get this for free through the seam.

Co-authored-by: Isaac <no-reply@databricks.com>
Nothing ran the bundletest suite before. Add an independent 'bundletest' job to the
python build workflow that runs 'uv run --extra dev pytest' in experimental/bundletest.
Scoped to that dir; not folded into ./task test. uv.lock is gitignored (internal-proxy
URLs), so the runner resolves fresh against pypi.org.

Co-authored-by: Isaac <no-reply@databricks.com>
A minimal bundle whose one SQL job runs unchanged on both backends, selected by
BUNDLETEST_BACKEND. The single test's assertions are backend-agnostic (portable SQL
semantics only); the cloud tier, which needs per-run namespace isolation, lands with
the cloud backend PR and is scoped out here rather than shipped with a colliding
fixed-namespace fixture. Demonstrates the same-test-two-tiers claim instead of only
asserting it.

Co-authored-by: Isaac <no-reply@databricks.com>
…esting

Reword the local-backend bullet so a green local run reads as 'the SQL is portable and
structurally sound,' not 'this passes on Databricks.' DuckDB is not a Databricks SQL
emulator; dialect-dependent checks belong on cloud. Wording only.

Co-authored-by: Isaac <no-reply@databricks.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants