DatabricksZero to Advanced

Prove that the data and the code are right before anyone relies on them. Two things to test: the code (transformations) and the data (what actually arrives).

Code testssmall fake inputsrun in secondspytest, assertDataFrameEqualData testsreal tablesrun on every loadconstraints, expectations, reconciliation
  • Practice function (same rules as Module 8), used in the unit tests below:
Python
# transformations.py
from pyspark.sql import DataFrame, functions as F

def clean_orders(df: DataFrame) -> DataFrame:
    """Standardise status, derive the date, flag valid rows."""
    return (
        df.withColumn("status", F.lower(F.trim("status")))
          .withColumn("order_date", F.to_date("order_ts"))
          .withColumn("is_valid", F.col("customer_id").isNotNull()
                                  & F.col("quantity").isNotNull() & (F.col("quantity") > 0))
    )
  • The testing tools used here (pytest, pyspark.testing) are standard. Run them from a notebook on serverless
14.01Hands-on on Free Edition

Unit Testing PySpark Transformations

By the end: unit-test a transformation with pytest and assertDataFrameEqual.

Layout: code and tests live in files, next to each other (a workspace folder or Git folder):

transformations.pythe functiontest_transformations.pythe tests
Python
# test_transformations.py
import pytest
from pyspark.sql import SparkSession, functions as F
from pyspark.testing import assertDataFrameEqual
from transformations import clean_orders

COLS = "order_id INT, customer_id INT, product_id INT, quantity INT, status STRING, order_ts STRING"

@pytest.fixture(scope="session")
def spark():
    return SparkSession.builder.getOrCreate()

def make(spark, rows):
    return spark.createDataFrame(rows, COLS).withColumn("order_ts", F.to_timestamp("order_ts"))

def test_status_is_lowercased_and_trimmed(spark):
    out = clean_orders(make(spark, [(1, 10, 5, 2, "  COMPLETED ", "2025-03-01 10:00:00")]))
    assert out.first()["status"] == "completed"

def test_missing_customer_is_invalid(spark):
    out = clean_orders(make(spark, [(1, None, 5, 2, "shipped", "2025-03-01 10:00:00")]))
    assert out.first()["is_valid"] is False

def test_zero_quantity_is_invalid(spark):
    out = clean_orders(make(spark, [(1, 10, 5, 0, "shipped", "2025-03-01 10:00:00")]))
    assert out.first()["is_valid"] is False

def test_full_output(spark):
    out = clean_orders(make(spark, [(1, 10, 5, 2, "Shipped", "2025-03-01 10:00:00")]))
    expected = spark.createDataFrame(
        [(1, 10, 5, 2, "shipped", True)],
        "order_id INT, customer_id INT, product_id INT, quantity INT, status STRING, is_valid BOOLEAN",
    )
    cols = ["order_id", "customer_id", "product_id", "quantity", "status", "is_valid"]
    assertDataFrameEqual(out.select(*cols), expected)

Run it from a notebook:

Python
%pip install pytest
Python
import sys, pytest
sys.dont_write_bytecode = True
retcode = pytest.main([".", "-v", "-p", "no:cacheprovider"])
assert retcode == 0, "Tests failed: see the log"
  • Test behaviour on tiny inputs: one row per rule, plus edge cases (nulls, empty, duplicates)
  • assertDataFrameEqual ignores row order by default. It compares floats with a tolerance
  • Never test against production data: build small fake DataFrames
  • Functions take and return a DataFrame: nothing hidden, easy to test (Module 26)

Spark session in tests

On serverless, SparkSession.builder.getOrCreate() returns the notebook's session. For tests outside a notebook use Databricks Connect (Module 26.6).

14.02Hands-on on Free Edition

Integration Testing Data Pipelines

By the end: test a pipeline stage against real tables in a test schema.

Test input tableRun the stageOutput tableAssert
Integration tests use real Delta tables, in a separate schema.
Python
catalog = spark.sql("SELECT current_catalog()").first()[0]
test_schema = f"{catalog}.dbx_test"
spark.sql(f"CREATE SCHEMA IF NOT EXISTS {test_schema}")

# 1. arrange: a known input table
(spark.table("orders").limit(100)
      .write.mode("overwrite").saveAsTable(f"{test_schema}.orders_in"))

# 2. act: run the real code against it
from transformations import clean_orders
clean_orders(spark.table(f"{test_schema}.orders_in")) \
    .write.mode("overwrite").saveAsTable(f"{test_schema}.orders_out")

# 3. assert
out = spark.table(f"{test_schema}.orders_out")
assert out.count() == 100, "rows were lost or added"
assert out.filter("status != lower(trim(status))").count() == 0
assert out.select("order_id").distinct().count() == 100, "order_id must stay unique"
  • Needs the dbx_test schema created above (topics 9 and 10 reuse it)
  • Tests the wiring: reads, writes, joins, schemas, permissions
  • Use a separate schema (dbx_test), recreated on every run. Never write to prod
  • Keep inputs small and fixed so a failure means the code changed
  • Parameterise schema and catalog (Module 26) so the same code runs in dev, test and prod

Arrange, act, assert

Build the input, run the code, check the output. One reason to fail per test.

14.03Hands-on on Free Edition

End-to-End Pipeline Testing

By the end: test the whole job end to end.

  • Run the real job (Module 10) in a test environment with fixed input, then check the results
  • Trigger it from a script or CI, wait for the result, and assert on tables
Deploy to testLoad known inputRun jobCheck output tables
The closest thing to production, with data you control.
Check after the run
Job run succeeded All tasks green
Row counts per layer Match the known input
Key numbers (revenue) Match a hand-computed value
Reruns Same job twice gives the same tables (idempotent)
  • Keep E2E tests few: slow and the most fragile
  • Make the data the part that changes: parameters, not copies of the job
  • Run on a schedule or before a release, not on every commit

Free Edition

Running a job from CI needs API access. Not confirmed on Free Edition: run the job manually and check with a notebook.

14.04Hands-on on Free Edition

Test Data Generation

By the end: generate small, deterministic test data.

Python
from pyspark.sql import functions as F

def fake_orders(spark, n=1000):
    """Same rows every run: values derive from the id with a hash."""
    return spark.range(1, n + 1).select(
        F.col("id").cast("int").alias("order_id"),
        (F.pmod(F.hash("id", F.lit(1)), F.lit(50)) + 1).cast("int").alias("customer_id"),
        (F.pmod(F.hash("id", F.lit(2)), F.lit(30)) + 1).cast("int").alias("product_id"),
        (F.pmod(F.hash("id", F.lit(3)), F.lit(5)) + 1).cast("int").alias("quantity"),
        F.lit(" Completed ").alias("status"),
        F.timestamp_seconds(F.lit(1740787200) + F.col("id") * 60).alias("order_ts"),
    )
Approach Use for
Hand-written rows with createDataFrame Unit tests and edge cases
Deterministic generator (range + hash) Volume and integration tests
Sample of real data, masked Realistic distributions (check privacy rules)
Faker-style libraries Names, emails, addresses
  • Cover the edge cases: nulls, empty strings, duplicates, boundary dates, huge values, unknown codes
  • No rand() without a seed: tests must be repeatable
  • Put a few "bad" rows in on purpose, to prove that your checks catch them
  • Don't use real personal data in tests
14.05Hands-on on Free Edition

Schema and Contract Testing

By the end: test schemas and data contracts.

Python
from pyspark.testing import assertSchemaEqual

expected = spark.createDataFrame([], "order_id INT, customer_id INT, product_id INT, quantity INT, status STRING, order_ts TIMESTAMP").schema
actual = spark.table("orders").select("order_id", "customer_id", "product_id", "quantity", "status", "order_ts").schema
assertSchemaEqual(actual, expected)

A contract says what a table promises its consumers:

Python
contract = {
    "table": "orders",
    "columns": {"order_id": "int", "customer_id": "int", "quantity": "int"},
    "not_null": ["order_id"],
    "unique": ["order_id"],
}

df = spark.table(contract["table"])
types = dict(df.dtypes)
for col, t in contract["columns"].items():
    assert types.get(col) == t, f"{col}: expected {t}, got {types.get(col)}"
for col in contract["not_null"]:
    assert df.filter(F.col(col).isNull()).count() == 0, f"{col} has nulls"
for col in contract["unique"]:
    assert df.count() == df.select(col).distinct().count(), f"{col} is not unique"

Let Delta enforce part of it:

SQL
%sql
ALTER TABLE orders_delta ADD CONSTRAINT qty_positive CHECK (quantity > 0 OR quantity IS NULL)
Tool Enforced?
NOT NULL, CHECK Yes: a violating write fails
PRIMARY KEY, FOREIGN KEY No: informational only
Delta schema enforcement Yes: wrong columns or types are rejected on write
  • Breaking changes (rename, drop, type change) need agreement with consumers
  • Test the contract in CI and on every load
14.06Hands-on on Free Edition

Data Quality Rules and Expectations

By the end: write data quality rules once and reuse them everywhere.

Python
rules = {
    "order_id not null":    "order_id IS NOT NULL",
    "quantity positive":    "quantity IS NULL OR quantity > 0",
    "status known":         "status IN ('completed', 'shipped', 'cancelled', 'returned')",
    "customer exists":      None,       # needs a join: see below
}

def check(df, rules):
    out = []
    for name, cond in rules.items():
        if cond is None:
            continue
        failed = df.filter(f"NOT ({cond})").count()
        out.append((name, df.count(), failed))
    return spark.createDataFrame(out, "rule STRING, rows LONG, failed LONG")

display(check(spark.table("orders"), rules))
Python
# referential check: every customer_id exists in customers
orphans = (spark.table("orders").join(spark.table("customers"), "customer_id", "left_anti")
                .filter("customer_id IS NOT NULL").count())
assert orphans == 0, f"{orphans} orders point to missing customers"
Rule type Example
Completeness customer_id IS NOT NULL
Validity quantity > 0, status IN (...)
Uniqueness one row per order_id
Referential every customer exists
Freshness max(order_ts) is recent
Consistency totals match between tables
  • The same rule text works in a CHECK constraint, a pipeline expectation (Module 9.8) and a test
  • Decide warn, drop or fail per rule (Module 8.5)
  • Write results to an audit table so you can chart quality over time

A rule has an owner and an action

Who fixes it, and what happens to the data meanwhile? Rules with no answer get ignored.

14.07Hands-on on Free Edition

Data Reconciliation Testing

By the end: reconcile numbers between layers as a test.

Python
bronze = spark.table("orders_raw")
silver = spark.table("orders")

checks = {
    "no rows lost after dedupe": silver.count() == bronze.select("order_id").distinct().count(),
    "keys match": bronze.select("order_id").distinct().exceptAll(silver.select("order_id")).count() == 0,
    "quantity sums match": (
        silver.agg(F.sum("quantity")).first()[0]
        == bronze.dropDuplicates(["order_id"]).agg(F.sum("quantity")).first()[0]
    ),
}
for name, ok in checks.items():
    print("PASS" if ok else "FAIL", name)
assert all(checks.values())
  • Compare counts, sums and key sets between source and target, and layer to layer
  • Allow a tolerance for floats; allow known differences (quarantined rows) explicitly
  • Run on every load and keep the results (Module 8.14)
  • A reconciliation test finds bugs unit tests can't: wrong joins, lost partitions, double loads
14.08Hands-on on Free Edition

Regression Testing

By the end: catch unintended changes with regression tests.

Idea: keep a known-good result (the baseline) and compare new output to it.

Python
# 1. one time: save today's output as the baseline
(spark.table("orders").groupBy("status").agg(F.count("*").alias("n"), F.sum("quantity").alias("qty"))
      .write.mode("overwrite").saveAsTable("baseline_orders_by_status"))

# 2. after a code change: recompute and compare
from pyspark.testing import assertDataFrameEqual
current = spark.table("orders").groupBy("status").agg(F.count("*").alias("n"), F.sum("quantity").alias("qty"))
assertDataFrameEqual(current, spark.table("baseline_orders_by_status"))
Technique Use
Golden dataset Small fixed input + expected output saved in the repo
Baseline table Summaries of a real table before a change
Time travel diff Compare a table with its earlier version
SQL
%sql
SELECT * FROM orders_delta VERSION AS OF 0
EXCEPT
SELECT * FROM orders_delta
  • A failing regression test is a question, not always a bug: is the change intended? If yes, update the baseline in the same pull request
  • Keep baselines small (aggregates, hashes), not whole tables
14.09Hands-on on Free Edition

CDC and SCD Testing

By the end: test merge logic for CDC and SCD tables with a case table.

Write the cases first, as data:

Case Input Expected
New key insert customer 201 one new row
Update customer 1 moves to XX old row closed, new row open (SCD 2)
Delete customer 2 deleted row removed (SCD 1) or ended (SCD 2)
Duplicate event same change twice no extra row
Out of order seq 5 arrives after seq 6 final state from seq 6
No change same values again no new version
Python
def test_scd2_update(spark):
    # arrange
    spark.createDataFrame([(1, "A", "NL", "2024-01-01", None, True)],
        "customer_id INT, name STRING, country STRING, valid_from STRING, valid_to STRING, is_current BOOLEAN"
    ).write.mode("overwrite").saveAsTable("dbx_test.dim_t")
    # act: apply your merge function with the change (customer 1 -> 'XX')
    # apply_scd2(spark, "dbx_test.dim_t", changes_df)
    # assert
    t = spark.table("dbx_test.dim_t")
    assert t.count() == 2
    assert t.filter("is_current").first()["country"] == "XX"
    assert t.filter("customer_id = 1 AND is_current").count() == 1      # exactly one current row
  • Invariants to assert on every SCD 2 table: one current row per key, no overlapping periods, no gaps
  • Apply the same change twice and check nothing changes (idempotent)
  • For pipelines: put the cases in a test feed table and run AUTO CDC (Module 9.9) against it
14.10Hands-on on Free Edition

Streaming Pipeline Testing

By the end: test a streaming job with files you control.

Python
# arrange: empty test landing folder + fresh checkpoint
catalog = spark.sql("SELECT current_catalog()").first()[0]
test_base = f"/Volumes/{catalog}/dbx_course/raw/stream_test"
dbutils.fs.rm(test_base, recurse=True)

(spark.range(0, 100).select("id", F.lit("buy").alias("event_type"))
      .coalesce(1).write.json(f"{test_base}/in/batch_1"))

def run():
    q = (spark.readStream.schema("id LONG, event_type STRING")
           .option("recursiveFileLookup", "true").option("pathGlobFilter", "*.json")
           .json(f"{test_base}/in")
           .writeStream.option("checkpointLocation", f"{test_base}/cp")
           .trigger(availableNow=True).toTable("dbx_test.events_out"))
    q.awaitTermination()
    return spark.table("dbx_test.events_out").count()

assert run() == 100          # first run reads everything
assert run() == 100          # second run: no new files, no duplicates
  • availableNow makes streams testable: the query ends, then you assert
  • Add a second batch of files and check that only the new rows arrive
  • Test late and duplicate events with files that contain them (Module 11)
  • Test failure recovery by starting a run with a bad file and checking where it goes (quarantine)
  • Remove the test checkpoint between test runs, or you test nothing
14.11Hands-on on Free Edition

Performance Testing

By the end: catch performance regressions with simple time and size budgets.

Python
import time

def run_job_step():
    return (spark.table("orders").join(spark.table("customers"), "customer_id")
                 .groupBy("country").agg(F.count("*").alias("n")).collect())

start = time.time()
run_job_step()
elapsed = time.time() - start

print(f"{elapsed:.1f}s")
assert elapsed < 60, f"step took {elapsed:.0f}s, budget is 60s"
Test Measures
Timing budget Does it still finish within X?
Data volume test Same code on 10x the rows (use a generator)
Plan test explain() still shows a broadcast join and pruning
File count DESCRIBE DETAIL file count stays low
Profile review Query profile after a big change (Module 12.15)
  • Timing on shared compute is noisy: use generous budgets, repeat the measurement, and look at trends
  • Test with realistic volume before release, not only 10 rows
  • Watch the Free Edition quota: scale the test data to what you can afford
14.12Concept

Great Expectations and Other Frameworks

By the end: recognise the main data quality frameworks and when to add one.

Tool Style
Great Expectations Define expectations (rules) on a DataFrame, get reports
Soda Checks written in YAML, scans tables
dbt tests Tests next to dbt models (if you use dbt)
Pandera / pydantic Schema and validation for Python data
Lakeflow expectations Rules built into pipelines
chispa DataFrame assertions for tests
  • Start with what you have: CHECK constraints, pipeline expectations, pyspark.testing
  • Add a framework when you need shared, reusable rules across many tables, or reports for non-engineers
  • Install with %pip install (package indexes are reachable on Free Edition)
  • Pin versions: these libraries change their APIs often
  • A tool does not replace deciding what to check

API changes

Great Expectations in particular changed its API significantly between major versions. Follow the current documentation of the version you pin.

14.13Concept

Automated Testing in CI/CD

By the end: run tests automatically on every change.

Pull requestUnit testsDeploy to testIntegrationMerge
Fast checks first. Slow checks only after the fast ones pass.
Stage Runs Time
Lint, format, type checks Every commit seconds
Unit tests Every commit seconds to a minute
Integration tests on test data Every pull request minutes
End-to-end test of the job Before release longer
Data quality checks Every production run with the job
  • Block the merge when tests fail
  • Unit tests run anywhere with Spark (Databricks Connect or a notebook job)
  • Keep the test report (JUnit XML) as a CI artifact
  • Test in a separate catalog or schema. Clean it up afterwards
  • Details of the pipeline: Module 15 (CI/CD) and Module 26 (project)

Fast tests first

A suite that takes an hour won't be run. Keep unit tests under a minute.

Recap

What you can do now

  • Unit, integration and end-to-end tests, and test data
  • Schema contracts, quality rules, reconciliation and regression tests
  • CDC, SCD, streaming and performance tests
  • Frameworks and running tests in CI