# 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))
)
Module 14: Testing & Data Quality Engineering
Test PySpark code and pipelines, and enforce data quality.
Topics in this module
- Unit Testing PySpark Transformations
- Integration Testing Data Pipelines
- End-to-End Pipeline Testing
- Test Data Generation
- Schema and Contract Testing
- Data Quality Rules and Expectations
- Data Reconciliation Testing
- Regression Testing
- CDC and SCD Testing
- Streaming Pipeline Testing
- Performance Testing
- Great Expectations and Other Frameworks
- Automated Testing in CI/CD
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).
- Practice function (same rules as Module 8), used in the unit tests below:
- The testing tools used here (
pytest,pyspark.testing) are standard. Run them from a notebook on serverless
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):
# 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:
%pip install pytest
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)
assertDataFrameEqualignores 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).
Integration Testing Data Pipelines
By the end: test a pipeline stage against real tables in a test schema.
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_testschema 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.
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
| 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.
Test Data Generation
By the end: generate small, deterministic test data.
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
Schema and Contract Testing
By the end: test schemas and data contracts.
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:
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
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
Data Quality Rules and Expectations
By the end: write data quality rules once and reuse them everywhere.
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))
# 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
CHECKconstraint, 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.
Data Reconciliation Testing
By the end: reconcile numbers between layers as a test.
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
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.
# 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
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
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 |
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
Streaming Pipeline Testing
By the end: test a streaming job with files you control.
# 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
availableNowmakes 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
Performance Testing
By the end: catch performance regressions with simple time and size budgets.
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
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:
CHECKconstraints, 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.
Automated Testing in CI/CD
By the end: run tests automatically on every change.
| 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.
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