Code and Run all Unit Test Case

Estimated read: 12 min

Three test files exist, covering gold_logic, silver_logic, and dq_helpers. One piece of real logic is still untested: Section 5’s dq_check.py threshold decision – and it has the same problem gold_daily_revenue had before Lecture 2’s refactor. This lecture extracts it, tests it, and runs the complete suite for real.

dq_check.py mixes a SQL query with a decision – again

Section 5, Lecture 2’s dq_check.py computes each source’s quarantine rate and decides pass/fail inline, in the same function that calls spark.sql(...):

# Before -- Section 5, Lecture 2
for source in SOURCES:
    valid = spark.sql(f"SELECT count(*) FROM ... WHERE date(_ingested_at) = '{args.run_date}'").collect()[0][0]
    quarantined = spark.sql(f"SELECT count(*) FROM ... WHERE date(_ingested_at) = '{args.run_date}'").collect()[0][0]
    total = valid + quarantined
    rate = quarantined / total if total > 0 else 0.0
    if rate > QUARANTINE_THRESHOLD:
        failures.append(f"{source}: {rate:.1%} exceeds {QUARANTINE_THRESHOLD:.0%} threshold")

The decision logic – given counts, is this a failure – doesn’t need Spark at all. Pulling it out means it can be tested with plain Python integers, no SparkSession fixture required:

# transformations/dq_logic.py
def quarantine_rate(valid_count: int, quarantine_count: int) -> float:
    total = valid_count + quarantine_count
    return quarantine_count / total if total > 0 else 0.0

def evaluate_thresholds(counts: dict, threshold: float) -> list[str]:
    """counts: {source_name: (valid_count, quarantine_count)}. Returns a list of failure messages."""
    failures = []
    for source, (valid, quarantined) in counts.items():
        rate = quarantine_rate(valid, quarantined)
        if rate > threshold:
            failures.append(f"{source}: {rate:.1%} exceeds {threshold:.0%} threshold")
    return failures
# transformations/dq_check.py -- now a thin wrapper, same shape as Lecture 2's pipeline refactors
import argparse
import sys
from databricks.sdk.runtime import spark
from dq_logic import evaluate_thresholds

SOURCES = ["customers", "orders", "order_items", "products", "inventory", "clickstream", "fulfillment"]
QUARANTINE_THRESHOLD = 0.05

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--run_date", required=True)
    args = parser.parse_args()

    counts = {}
    for source in SOURCES:
        valid = spark.sql(f"SELECT count(*) FROM dev.step_right.bronze_{source}_valid WHERE date(_ingested_at) = '{args.run_date}'").collect()[0][0]
        quarantined = spark.sql(f"SELECT count(*) FROM dev.step_right.bronze_{source}_quarantine WHERE date(_ingested_at) = '{args.run_date}'").collect()[0][0]
        counts[source] = (valid, quarantined)

    failures = evaluate_thresholds(counts, QUARANTINE_THRESHOLD)
    if failures:
        print("DQ CHECK FAILED:")
        for f in failures:
            print(f"  - {f}")
        sys.exit(1)
    print("DQ CHECK PASSED")

if __name__ == "__main__":
    main()

Notice dq_logic.py needs no SparkSession fixture at all – quarantine_rate and evaluate_thresholds operate on plain Python int, float, dict, and list, not DataFrames. Not every extraction in this project produces a Spark unit test; some produce an even simpler plain-Python one, which is a good sign, not a gap – the less machinery a test needs to set up, the faster and more reliable it runs.

Testing the threshold decision

# tests/test_dq_logic.py
from dq_logic import quarantine_rate, evaluate_thresholds

def test_quarantine_rate_basic_division():
    assert quarantine_rate(valid_count=95, quarantine_count=5) == 0.05

def test_quarantine_rate_with_zero_total_returns_zero_not_a_division_error():
    assert quarantine_rate(valid_count=0, quarantine_count=0) == 0.0

def test_rate_exactly_at_threshold_does_not_fail():
    # Section 5, Lecture 2 uses `> threshold`, not `>=` -- exactly 5% should pass
    failures = evaluate_thresholds({"orders": (95, 5)}, threshold=0.05)
    assert failures == []

def test_rate_just_over_threshold_fails():
    failures = evaluate_thresholds({"orders": (94, 6)}, threshold=0.05)
    assert len(failures) == 1
    assert "orders" in failures[0]

def test_only_the_offending_source_appears_in_failures():
    counts = {"orders": (99, 1), "clickstream": (60, 40)}
    failures = evaluate_thresholds(counts, threshold=0.05)
    assert len(failures) == 1
    assert "clickstream" in failures[0]
    assert "orders" not in failures[0]

test_rate_exactly_at_threshold_does_not_fail is the one worth pausing on – it’s testing the boundary condition of a > comparison, exactly the kind of off-by-one mistake (> vs. >=) that’s easy to introduce silently in a future edit and that a test asserting only “clearly under” and “clearly over” values would never catch.

One more edge case: dedupe_latest’s tie-breaking gap

Section 3, Lecture 5’s own “Verifying the result” section flagged a real, known gap in dedupe_latest: two rows sharing the exact same _ingested_at timestamp break ties arbitrarily, since row_number() needs some deterministic order and timestamp alone doesn’t guarantee one. Rather than leave that as a comment, this is worth a test that documents the current behavior honestly, so a future fix has something concrete to change:

def test_tied_timestamps_pick_one_row_but_which_one_is_not_guaranteed(spark):
    df = spark.createDataFrame(
        [("PROD-1", "2026-08-10 09:00:00", 29.99), ("PROD-1", "2026-08-10 09:00:00", 34.99)],
        "product_id string, _ingested_at string, list_price double",
    )
    result = dedupe_latest(df, "product_id", "_ingested_at").collect()
    # Known gap (Section 3, Lecture 5): exactly one row survives, but which one is unspecified.
    # This test locks in "exactly one row," not "the right one" -- a real tie-break fix would need
    # a secondary sort column, tracked as a follow-up rather than silently assumed correct here.
    assert len(result) == 1

A test like this isn’t asserting the code is bug-free – it’s asserting the known limitation stays exactly as documented, so a future change to dedupe_latest that accidentally makes ties non-deterministic in a new way (returning zero or two rows instead of one) still gets caught, even though the underlying “which one wins” ambiguity remains open.

Configuring pytest

# pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
addopts = -v --strict-markers

testpaths = tests means a bare pytest command, run from the repo root, finds everything without needing the tests/ argument spelled out every time – worth having in the repo from the first test file, not added later once the suite has grown large enough to make omitting it annoying.

Measuring coverage

pip install pytest-cov
pytest tests/ --cov=transformations --cov-report=term-missing
Name                         Stmts   Miss  Cover   Missing
----------------------------------------------------------
transformations/dq_helpers.py    18      0   100%
transformations/dq_logic.py       9      0   100%
transformations/gold_logic.py    12      0   100%
transformations/silver_logic.py   7      0   100%
----------------------------------------------------------
TOTAL                            46      0   100%

100% coverage here is a meaningful number specifically because the scope is narrow – these four files are exactly the pure logic Sections 2 through 5 extracted for testability, not the entire transformations/ folder. The @dp.table-decorated wrapper functions correctly show up as untested by this report, and that’s expected: their I/O is what Section 8’s integration tests exist to cover, not this suite. A coverage report is only useful read against what it was actually scoped to check.

The full tests/ directory

tests/
├── conftest.py           # spark fixture, path setup, shared DataFrame fixtures
├── test_dq_helpers.py    # tag_quality, tag_business_rules
├── test_dq_logic.py      # quarantine_rate, evaluate_thresholds (this lecture)
├── test_gold_logic.py    # compute_daily_revenue
└── test_silver_logic.py  # dedupe_latest

Five files, roughly twenty test functions total across the section – small individually, but together covering every piece of pure business logic this capstone has built since Section 2.

Running one test at a time during development

The full suite in under five seconds is fast enough to run on every save, but a single failing test during active development doesn’t need the other nineteen re-run alongside it:

pytest tests/test_gold_logic.py::test_discount_allocated_proportionally_not_evenly -v

Scoping to one file, or one specific test function within it, is the normal day-to-day rhythm while writing or fixing a test – run the whole suite before committing, but iterate against just the one test actually being worked on in between.

Local PySpark vs. Databricks Connect, one more time

  Local PySpark (this section) Databricks Connect (Section 8)
Startup cost ~2 seconds, once per session Cluster start: minutes, or reuse an already-running one
Data In-memory fixtures, defined in the test itself Real or test-environment Unity Catalog tables
Runs in CI without a workspace Yes No – needs live workspace credentials
What it proves The function’s logic is correct for a known input The deployed pipeline behaves correctly against real infrastructure

Neither column is strictly “better” – they answer different questions, which is why Section 8 adds Databricks Connect-based integration tests rather than replacing this section’s suite with them. A change that breaks compute_daily_revenue’s math fails here, in seconds, on a laptop with no Databricks access at all; a change that breaks how steprightproject-silver-gold is actually wired together in Unity Catalog fails in Section 8’s suite instead, since no local fixture could ever catch a deployment-configuration mistake this section’s tests were never scoped to see.

Running the suite

pip install -r requirements-test.txt   # pytest, pyspark
pytest tests/ -v
tests/test_dq_helpers.py::test_row_passing_all_checks_is_marked_valid PASSED
tests/test_dq_helpers.py::test_row_failing_a_check_is_tagged_with_the_rule_name PASSED
tests/test_dq_helpers.py::test_row_can_fail_multiple_checks_at_once PASSED
tests/test_dq_helpers.py::test_critical_failure_sets_dq_critical_failed_true PASSED
tests/test_dq_helpers.py::test_warning_only_does_not_set_dq_critical_failed PASSED
tests/test_dq_logic.py::test_quarantine_rate_basic_division PASSED
tests/test_dq_logic.py::test_rate_exactly_at_threshold_does_not_fail PASSED
tests/test_dq_logic.py::test_rate_just_over_threshold_fails PASSED
tests/test_gold_logic.py::test_gross_revenue_by_line_item_sums_correctly PASSED
tests/test_gold_logic.py::test_discount_allocated_proportionally_not_evenly PASSED
tests/test_gold_logic.py::test_net_revenue_reconciles_against_gross_minus_discount PASSED
tests/test_silver_logic.py::test_keeps_only_the_most_recent_row_per_partition PASSED
============================== 20 passed in 4.87s ==============================

Under five seconds for the whole suite – the direct payoff of local PySpark over a live cluster: no cluster startup, no network round trip, nothing but the two-second cost of starting Spark in-process, paid once thanks to conftest.py’s session-scoped fixture.

What a genuine failure looks like

Suppose a future edit accidentally changes compute_daily_revenue’s discount formula to round discount_pct before multiplying – the exact bug Lecture 1’s motivating scenario described:

tests/test_gold_logic.py::test_discount_allocated_proportionally_not_evenly FAILED

    def test_discount_allocated_proportionally_not_evenly(spark, order_items_df, orders_df, customers_df, products_df):
        result = compute_daily_revenue(order_items_df, orders_df, customers_df, products_df).collect()[0]
>       assert result["discount_amount"] == 34.80
E       assert 35.00 == 34.80

tests/test_gold_logic.py:15: AssertionError
========================== 1 failed, 19 passed in 4.91s ==========================

This is the whole point of Section 6: a discount-formula bug like this one gets caught here, locally, in under five seconds, with a stack trace pointing at the exact function and the exact expected-vs-actual numbers – not weeks later, in a finance dashboard that quietly stopped reconciling, with dq_check still reporting a perfectly healthy 1-2% quarantine rate every single night.

Common mistakes

  • Writing dq_logic.py’s tests against Spark DataFrames when plain Python types would do. quarantine_rate and evaluate_thresholds never touch a DataFrame – wrapping their test inputs in spark.createDataFrame anyway would only add unnecessary Spark session overhead to tests that don’t need it.
  • Letting the test suite go stale as pipelines change. A test suite is only as valuable as its last run – if Section 7 or Section 8 later changes compute_daily_revenue’s signature without updating test_gold_logic.py to match, a suite nobody runs anymore, or that’s been silently skipped, provides exactly zero of the protection this section spent four lectures building.

Why every test name in this section is a full sentence

test_discount_allocated_proportionally_not_evenly, not test_discount_1. Every test function across all four lectures follows the same convention: a name long enough to state the specific claim being verified, not just which function is under test. The payoff shows up exactly in the failure output above – pytest’s -v flag prints the test name itself as part of the result line, which means a failing test’s name alone, before reading a single line of the traceback, already says what broke: “discount allocated proportionally, not evenly” failing is immediately more informative than “test_discount_1” failing, especially months from now when whoever’s reading the CI output didn’t write the test and has no memory of what “case 1” was supposed to mean.

Section wrap-up

Twenty-odd tests, five files, one Spark fixture, and a five-second run time – StepRight’s transformation logic is now provably correct against known inputs, independent of whatever real data happens to be sitting in dev.step_right on a given day. Section 7 turns to a different concern: tracking data quality trends over time, the rolling-average, threshold-drift monitoring this section’s dq_check deliberately left for later.


| ← Previous: Designing and Developing Unit Test Cases | Next: StepRight - Data Quality Monitoring → | |:—|—:|


Back to top

Independent, self-authored Databricks companion notes.

This site uses Just the Docs, a documentation theme for Jekyll.