Crosshire
Standards & CI/CD
Build06 Standards & CI/CD · 1 of 3

Code structure

Almost all untestable Databricks code is untestable for one of three reasons, and all three are layout decisions rather than testing decisions. Fix the layout and the tests become ordinary.

Stand 29.07.2026

#The three testability rules

What
One: the transform is a pure function. DataFrame in, DataFrame out — no spark.read.table inside it, no table name, no catalog, no clock. Two: IO lives at the edges, in a thin caller the tests never import. Three: nothing sits between the logic and the test — no decorator, no notebook cell, no %run. A test imports the function the same way production does.
Why
This prevents the objection that ends every testing conversation before it starts: we cannot unit-test our transformations, they only run in a pipeline. That is true, and it is true because of layout, not because of Databricks.
With the split above, a test of business rule 2 — amount_eur = ROUND(quantity * unit_price * fx_rate, 2) — is four lines and runs on a laptop in under a second. Without it the same test costs a pipeline start, a seeded table and ninety seconds, so nobody writes it, so the rule is only checked when someone notices a wrong number in a dashboard.
How
python
src/dwh/pipelines/transforms/silver_sales_order.py — pure, importable, no pipeline in sight
from pyspark.sql import DataFrame, functions as F

# Business rules 1, 2, 5 and 6 as a plain dict — the pipeline and the
# test both import THIS, so they can never disagree about the rules.
# Rule 3 (valid countries DE/AT/CH/NL) is deliberately absent: an order
# line carries no country, so that rule is enforced in the customer
# transform. An expectation naming a column the table does not have
# fails the pipeline at analysis, not at runtime.
EXPECTATIONS = {
    "amount_positive":   "amount_eur > 0 OR status = 'CANCELLED'",
    "known_currency":    "currency IN ('EUR', 'CHF')",
    "fx_rate_resolved":  "fx_rate IS NOT NULL",
    "order_not_ancient": "order_ts >= TIMESTAMP '2020-01-01'",
    "status_domain":     "status IN ('OPEN', 'SHIPPED', 'CANCELLED')",
}

def to_silver_sales_order(lines: DataFrame, rates: DataFrame) -> DataFrame:
    """lines: bronze.sap_vbap shape. rates: silver.fx_rate (currency, rate_date, fx_rate)."""
    return (
        lines.withColumn("rate_date", F.to_date("order_ts"))
        .join(rates, on=["currency", "rate_date"], how="left")
        # EUR is the base currency and is absent from the ECB file, so it is 1
        # by definition. Any OTHER currency with no rate stays NULL on purpose:
        # fx_rate_resolved then drops the row and the event log counts it. The
        # literal is cast, because a Python float would promote the whole
        # DECIMAL chain to DOUBLE.
        .withColumn(
            "fx_rate",
            F.when(F.col("currency") == "EUR", F.lit(1).cast("decimal(18,6)"))
             .otherwise(F.col("fx_rate")),
        )
        .withColumn(
            "amount_eur",
            F.round(F.col("quantity") * F.col("unit_price") * F.col("fx_rate"), 2),
        )
        .drop("rate_date")
    )
That dict is the canonical definition of the expectations on silver.sales_order_line. Any other page that shows expectations for src/dwh/pipelines/transforms/silver_sales_order.py — including the testing routes — is quoting this one, and where it differs it is the other page that is wrong. The absence people try to correct is known_country: country lives on gold.dim_customer, an order line has no such column, and an expectation naming it fails the pipeline at analysis.
python
src/dwh/pipelines/silver_sales_order.py — the entire pipeline file. Three lines of glue.
from pyspark import pipelines as dp
from dwh.pipelines.transforms.silver_sales_order import EXPECTATIONS, to_silver_sales_order

@dp.materialized_view(name="sales_order_line")
@dp.expect_all_or_drop(EXPECTATIONS)
def sales_order_line():
    return to_silver_sales_order(
        spark.read.table("bronze.sap_vbap"),
        spark.read.table("silver.fx_rate"),
    )
Doing it
  • Split each transform into transforms/<name>.py (pure) and a callerDo the split mechanically first and improve the logic second — two commits, never one.
  • Move EXPECTATIONS out of the decorator call into a module-level dict in the transform file
  • Pass anything non-deterministic in as an argument: as_of timestamps, run ids, the catalogA transform calling current_timestamp() cannot be asserted against a fixed expected DataFrame.
  • Keep the wrapper under ten linesIf it grows logic, that logic has escaped the testable half.
Embedding it
  • Pick their ugliest notebook and do the split live with the author driving, not youTwenty minutes, one transform, one passing test.
  • Ask one question in every review: 'which line of this can I call from a test?'If the answer is none, the layout is the finding, not the logic.
  • Have them do the second split alone and review itThe second one is where the rule becomes theirs.
  • When someone argues a transform is too small to extract, let them merge it — then ask them to write the testThe argument settles itself.
Defaults
  • transforms/ holds pure functions; pipelines/ and jobs/ hold IOTests import only transforms/.
  • EXPECTATIONS is a dict in the transform module, imported by both the pipeline and the test
  • Clock, catalog and run id are arguments, never module-level reads
  • The wrapper is glue thin enough that nobody wants to test it
Gotchas
  • spark.read.table() inside the transformEvery test then needs a real table, so the suite needs a warehouse, so it moves to nightly, so it stops running before merge.
  • A transform calling F.current_timestamp()The expected DataFrame changes every run, the developer 'fixes' it by asserting only row counts, and the test now passes on wrong values.
  • Expectations written inline in the decoratorThe test asserting a rule re-types the same SQL string, the two drift after the first edit, and the test keeps passing against a rule production no longer runs.
  • dbutils.widgets.get() inside a transformIt works in a notebook and raises outside one, so the function is importable only from Databricks — which is the problem you were trying to leave.
  • coalesce(fx_rate, 1.0) as a 'safe' default for a missing rateA CHF order is then booked at 1:1 — at the canonical 0.94 rate that overstates revenue by roughly six percent, (1.00 - 0.94) / 0.94 = 6.4 % — and nothing fails: no expectation fires, no job goes red. Default the base currency by name and let every other missing rate stay NULL so an expectation can count it.
  • An expectation naming a column the transform does not produce — country on an order line is the classicLakeflow rejects it when the pipeline is analysed, so it fails on the first deploy rather than in review, and the fix is usually to move the rule to the table that owns the column.

#Workspace files, not a wheel

What
Deploy src/ as workspace files inside the bundle and import from it. Do not build a wheel, upload it to a Volume, install it on a cluster and restart.
One prerequisite makes this safe rather than merely convenient. From Databricks Runtime 13.3 LTS, directories added to sys.path — or structured as Python packages — are automatically distributed to all executors. On 12.2 LTS and below they are not, and the symptom is precise: the same import works on the driver and raises ModuleNotFoundError inside a UDF.
Testability rule 2 applies to the entry point as well, and it forces a split that teams usually get wrong: the job module is importable and contains no notebook globals, and a thin notebook does the widget reads, the sys.path bootstrap and the call. Put dbutils.widgets.get() at module level in src/ and the module is importable only from a notebook session — which is the thing you were trying to leave.
Why
The wheel loop is where teams quietly stop iterating. Build, upload, reinstall, restart, rerun — five minutes for a one-character fix, so people edit in the notebook instead and copy the change back later. Sometimes later never arrives, and the deployed wheel and the repository diverge without anyone lying to anyone.
Workspace files also give you what a wheel never does: the code in the workspace is the code in the commit, because the bundle put it there. 'What is running in prod' becomes a one-line answer instead of an investigation.
How
python
src/dwh/jobs/silver_customer.py — importable. No dbutils, no widgets, no module-level spark.
from pyspark.sql import SparkSession

from dwh.silver.customer import clean_customer

def run(spark: SparkSession, source: str, target: str) -> int:
    """Fully-qualified names in, row count out. Nothing here reads a widget,
    so pytest can import this module on a laptop."""
    df = clean_customer(spark.read.table(source))
    df.write.mode("overwrite").saveAsTable(target)
    return df.count()
python
src/dwh/jobs/entry/silver_customer.py — the NOTEBOOK the bundle points at. Bootstrap once, here only.
# Databricks notebook source
# ^ that first line is not a comment you can drop: without it the task is a
#   plain .py file and the CLI refuses it. See the callout below.
import sys

# Both are job parameters set by the bundle, not relative paths and not
# an environment sniff. dbutils and spark are notebook globals — they exist
# here and nowhere under src/dwh/jobs/ or src/dwh/silver/.
source_root = dbutils.widgets.get("source_root")   # noqa: F821
catalog = dbutils.widgets.get("catalog")           # noqa: F821
if source_root not in sys.path:
    sys.path.insert(0, source_root)

from dwh.jobs.silver_customer import run           # noqa: E402

# The entry point is the only place a table name is assembled.
rows = run(spark, f"{catalog}.bronze.sap_kna1", f"{catalog}.silver.customer")  # noqa: F821
print(f"silver.customer: {rows} rows written")
yaml
resources/jobs.yml — where source_root comes from
resources:
  jobs:
    silver_customer:
      name: dwh-silver-customer
      tasks:
        - task_key: silver_customer
          notebook_task:
            # Relative to THIS file (resources/jobs.yml), so ../ is correct
            # here and wrong in databricks.yml. The file starts with
            # "# Databricks notebook source".
            notebook_path: ../src/dwh/jobs/entry/silver_customer.py
            source: WORKSPACE
            base_parameters:
              source_root: ${workspace.file_path}/src
              catalog: ${var.catalog}
entry/ holds notebooks, not library code. Nothing imports from it, tests never touch it, and it is the only directory in src/ where dbutils and spark may appear as free names. from dwh.jobs.silver_customer import run works from pytest; from dwh.jobs.entry import silver_customer is not something anyone should ever write.
%run ./helpers is replaced by from dwh.common.keys import make_sk. The difference is not stylistic: %run executes a notebook into the current namespace, so nothing it defines can be imported by pytest, static analysis cannot see it, and two notebooks that both %run the same helper get two copies with independent state.
Doing it
  • Put the whole package under src/dwh/ with __init__.py files and deploy it as workspace files via the bundle
  • Split every job in two: run(spark, source, target) in an importable module, widgets and sys.path in a thin notebookThe notebook lives under src/dwh/jobs/entry/. Then prove the split by running python -c 'import dwh.jobs.silver_customer' with no Databricks anywhere.
  • Start every file wired to a notebook_task with the line '# Databricks notebook source'Or move the task to spark_python_task + python_file. Grep the bundle for notebook_path and check the first line of each target file once — it is a five-minute audit that removes a whole class of deploy failure.
  • Bootstrap sys.path exactly once, in the entry-point notebookNever inside a transform, a job module or a library module.
  • Pass the source root as a job parameter resolved from ${workspace.file_path}Do not compute it from a relative path or from os.getcwd().
  • Grep for %run and convert each one to an import in its own commit, so a revert is cheapCheck the runtime version on every cluster and pipeline first — 12.2 LTS is still in the wild.
Embedding it
  • Have the team time both loops themselves: one wheel rebuild, one bundle deploy, stopwatch on the tableThe argument ends there.
  • Ask them to reproduce the 12.2 failure once by calling an imported helper from inside a UDF on an old clusterReading about it does not stick; watching the driver succeed and the executor fail does.
  • Make converting %run a rotating chore across the first three sprintsRather than one heroic pull request nobody can review.
  • Give them one command as the standard: python -c 'import dwh.jobs.<name>' from src/, with no DatabricksIt either imports or it names the notebook global that has leaked into library code, and nobody needs your opinion to read the answer.
Defaults
  • src/dwh/ deployed as workspace files by the bundleNo wheel, no Volume, no cluster library.
  • dbutils and spark appear as free names in src/dwh/**/entry/ and nowhere elseEvery other module is importable from a bare Python process.
  • One sys.path bootstrap per entry-point notebook, fed by a job parameter
  • Every notebook_task target starts with '# Databricks notebook source'Anything that is genuinely a script is a spark_python_task instead.
  • DBR 13.3 LTS or newer everywhere, so imported modules reach executors
  • %run does not appear in the repositoryLocal pytest imports the identical package from src/ — no shim, no path hackery in conftest.py.
Gotchas
  • dbutils.widgets.get() at module level in an importable module'from dwh.jobs import silver_customer' then raises NameError: name 'dbutils' is not defined, so the module can only be loaded inside a notebook session and no test can reach run() at all.
  • A plain .py file wired to a notebook_taskThe deploy fails with 'file at <path> is not a notebook' — the file needs '# Databricks notebook source' as its literal first line, or the task needs to be spark_python_task with python_file. The message names the path, not the missing line, so people go looking for a wrong path.
  • Calling run() with the wrong arity because the signature drifted from the callerrun(spark, source, target) called as run(spark, catalog) raises TypeError: run() missing 1 required positional argument — cheap in a test, an hour in a 3 a.m. job run. Keep the signature in one place and let a test import it.
  • DBR 12.2 LTS or older: an import that works on the driver raises ModuleNotFoundError inside a UDFIt is intermittent by nature — the failure only appears once someone writes the first UDF, weeks after the layout was agreed.
  • Relative sys.path entries such as sys.path.append('../..')DBR 14.0 changed the default working directory to the directory of the notebook being run, so a path written against 13.3 resolves somewhere else after an upgrade and fails in production only.
  • sys.path.append inside a library moduleIt runs on every import, the list grows, and import order starts deciding which copy of a module you get — a bug that reproduces on one cluster and not another.
  • A stale wheel still installed as a cluster library after the migrationIt shadows the workspace files, so you debug the new code while the old code runs.

#Characterization test first, then refactor

What
You will propose the split above to a team whose code works. The answer you get is you want to rewrite our working code, and it is a fair objection. The response is not reassurance — it is a test that pins the current behaviour before a single line moves.
Why
Without it a refactor is a negotiation about trust, and the consultant loses, correctly. With it the conversation changes shape: the team is no longer asked to believe you, they are shown a query that returns zero rows.
It protects you as well. Warehouse code accumulates undocumented behaviour — a rounding order, a coalesce nobody remembers adding, a filter that quietly drops one source. Those are load-bearing accidents, and the snapshot finds them before a report writer does.
How
sql
Step 1 — snapshot what production produces TODAY, before touching anything
CREATE TABLE dwh_test.fixtures.char_fct_order_line_20260729
COMMENT 'Characterization snapshot of gold.fct_order_line, June 2026, taken before the transform refactor. Drop when the refactor is merged.'
AS
SELECT order_line_sk, order_id, line_number,
       customer_sk, product_sk, date_sk, region_sk,
       order_ts, quantity, unit_price, currency, fx_rate, amount_eur, status
       -- _tech_loaded_at is deliberately NOT projected. It changes on every
       -- run, so including it makes every row differ and the test useless.
FROM   dwh_prod.gold.fct_order_line
WHERE  order_ts >= DATE '2026-06-01'
  AND  order_ts <  DATE '2026-07-01';
sql
Step 2 — the test. Symmetric EXCEPT ALL: zero rows means the refactor changed nothing.
-- Same projection AND same window on both sides, or every row outside
-- June is reported as a difference and the test can never return zero.
WITH new_rows AS (
  SELECT order_line_sk, order_id, line_number,
         customer_sk, product_sk, date_sk, region_sk,
         order_ts, quantity, unit_price, currency, fx_rate, amount_eur, status
  FROM   dwh_test.gold.fct_order_line
  WHERE  order_ts >= DATE '2026-06-01'
    AND  order_ts <  DATE '2026-07-01'
)
SELECT 'new_not_in_old' AS side, d.* FROM (
  SELECT * FROM new_rows
  EXCEPT ALL
  SELECT * FROM dwh_test.fixtures.char_fct_order_line_20260729
) d
UNION ALL
SELECT 'old_not_in_new' AS side, d.* FROM (
  SELECT * FROM dwh_test.fixtures.char_fct_order_line_20260729
  EXCEPT ALL
  SELECT * FROM new_rows
) d;
The characterization test asserts nothing about correctness. It asserts sameness, which is exactly the promise you are making. If the refactor surfaces a genuine bug the test fails, you look, and you fix it as a separate change with its own test — never inside the refactor commit.
Doing it
  • Take the snapshot from production output, not from a re-run of the old codeYou are pinning what the business currently sees.
  • Choose a window wide enough to contain the awkward casesA month with cancellations, at least one CHF order, at least one line hitting the UNKNOWN member.
  • Run EXCEPT ALL in both directions, with the identical column list and the identical window on both sidesOne direction catches only half the differences, and EXCEPT without ALL hides duplicate-count changes entirely.
  • Keep the refactor commit behaviour-neutralPut an expiry in the snapshot's COMMENT, and actually drop it when the refactor merges.
Embedding it
  • Let the team choose the snapshot windowThey know which month broke, and choosing it makes the test theirs rather than yours.
  • Have them break it deliberately — change a ROUND to a CAST and watch it fail — before they trust it to pass
  • When the snapshot catches an undocumented behaviour, hand the finding to the team to explain to the businessThat conversation is the one that changes how they feel about tests.
Defaults
  • Snapshot production output, never a re-run of the code under change
  • Symmetric EXCEPT ALL, both directions, in one query with a side label
  • One refactor commit, behaviour-neutral, reviewed on its own
  • The snapshot table carries a COMMENT saying what it is for and when it dies
Gotchas
  • Comparing the whole rebuilt table against a windowed snapshotEvery row outside the window comes back as new_not_in_old, the query can never return zero, and the team concludes the pattern does not work rather than that the filter is missing on one side.
  • Snapshotting a re-run of the old code instead of production outputYou pin a behaviour the business has never seen, and a real production-versus-code drift stays invisible.
  • EXCEPT instead of EXCEPT ALLEXCEPT deduplicates, so a refactor that doubles every row returns zero differences and passes.
  • Including _tech_loaded_at in the compared columnsEvery row differs, the test is declared useless, and the team abandons the pattern on day one. Project the business columns explicitly.
  • Keeping the snapshot after the mergeSix months later it is a fixture nobody can explain, still failing CI whenever a legitimate change lands.
Platform facts on this page verified 29.07.2026 against the official documentation. Volatile claims are anchored to the currency register. This is section 1 of 3 in 06 Standards & CI/CD.