Design Test Strategy for the Project
Estimated read: 11 min
Section 5 built a job that runs the whole pipeline on a schedule and gates it on a real quarantine
threshold – but nothing in this project yet proves that the transformation logic itself is
correct, independent of a live cluster, a running pipeline, or real data in dev.step_right. This
lecture designs what a unit test means for StepRight specifically, before Lecture 2 refactors
anything and Lectures 3-4 write real test cases.
Three different kinds of “testing” already exist in this project – and they’re not the same thing
| Layer | What it checks | Needs a cluster? | Built in |
|---|---|---|---|
| Job-level DQ gate | Is today’s actual data clean enough to proceed | Yes – reads real bronze tables | Section 5 |
| Unit tests | Does a transformation function produce the right output for a known input | No – pure Python/PySpark, runs locally or in CI | This section |
| Integration tests | Does the deployed pipeline, end to end, against a test environment, behave correctly | Yes – a real (if scaled-down) pipeline run | Section 8 |
Confusing these three is the single most common mistake in how a legacy ETL team thinks about
testing a new platform: dq_check proves today’s data was clean, not that gold_daily_revenue’s
formula is right, and no amount of production monitoring catches a discount calculation that was
wrong from the day it shipped, if every day’s data happens to be structurally valid. A unit test
is the only one of the three that would catch that class of bug, and it’s the only one that runs
in milliseconds without touching a workspace at all.
What a “unit” is here: a pure function, not a pipeline
A unit test, for this project, tests one Python function in isolation: given a known input
DataFrame, does it return the exact expected output DataFrame – no Databricks workspace, no Unity
Catalog table, no SDP runtime involved. That definition matters because of what it deliberately
excludes: the @dp.table- and @dp.materialized_view-decorated functions themselves aren’t
directly unit-testable as written. Two reasons why:
- The decorator changes what calling the function even means.
@dp.tableregisters a function with SDP’s pipeline runtime; callingbronze_orders_tagged()directly, outside a running pipeline, doesn’t behave the way it does when SDP’s scheduler actually invokes it. - Every decorated function reads live tables.
spark.read.table("dev.step_right.silver_orders")inside a function body means “testing” it actually means hitting real Unity Catalog data – which makes it an integration test wearing a unit test’s clothing: slow, dependent on whatever happens to be indev.step_righttoday, and not reproducible from a clean checkout.
What’s already testable, unchanged
Section 2’s tag_quality and Section 3’s tag_business_rules (both in transformations/dq_helpers.py)
were written from the start as pure functions: they take a DataFrame and a dictionary of named
checks as arguments, and return a DataFrame – no spark.read, no table names, no pipeline
decorator anywhere inside them. That wasn’t an accident; it’s what makes them directly unit-testable
today, with zero refactoring, the moment Lecture 3 writes a test against them. Every helper this
project built the same way inherits the same free win.
What isn’t testable yet, and why
Two pieces of real business logic currently live inside decorated, table-reading functions rather than in a separable helper:
gold_daily_revenue()(Section 4, Lecture 1) computesgross_revenue,discount_amount, andnet_revenueinline, in the same function body that callsspark.read.table(...)four times.silver_products()(Section 3, Lecture 5) computes its “latest wins” dedup with a window function, inline, in the same function body that readsbronze_products_validdirectly.
Neither piece of logic is wrong – both were verified by hand with SQL sanity checks when they
were built. But neither can be unit tested as written, because testing either one means also
standing up real Unity Catalog tables first. Lecture 2 extracts both into standalone functions
that accept DataFrames as arguments, the same shape tag_quality already has.
Local PySpark, not a live cluster, for this section
Unit tests in this section run against a local PySpark session – an ordinary SparkSession
created in-process by conftest.py, not Databricks Connect and not a workspace cluster. This is a
deliberate boundary: local PySpark is free, starts in seconds, and needs no Databricks credentials
at all, which is exactly what makes it viable to run on every commit, in CI, without provisioning
compute. Databricks Connect and real cluster-backed runs are what Section 8’s integration tests
use instead, for the different question of whether the actually-deployed pipeline works end to
end – a legacy analogy worth keeping in mind here: this is the same split a well-run ETL team
already had between a fast, in-memory unit test suite for transformation logic and a slower,
environment-dependent integration suite that only ran nightly or pre-release.
What tests/ will hold
steprightproject/
└── tests/
├── conftest.py # Shared local SparkSession fixture (Lecture 3)
├── test_dq_helpers.py # tag_quality, tag_business_rules (Lecture 3)
├── test_gold_logic.py # Revenue and discount calculation (Lecture 3)
├── test_silver_logic.py # Dedup and latest-wins logic (Lecture 4)
└── test_dq_check.py # Section 5's quarantine-rate threshold logic (Lecture 4)
Five files, each scoped to one module’s pure logic – matching the tests/ folder Section 1
planned from the project’s first lecture, filled in for real starting with Lecture 3.
The bug class only a unit test catches
Concretely: suppose a future engineer, six months from now, “fixes” gold_daily_revenue’s
discount formula – rounds discount_pct to the nearest whole percent before multiplying, say, to
match a finance request – and introduces a subtle off-by-a-fraction-of-a-cent error across every
row. Every bronze table Section 5’s dq_check inspects is still perfectly valid: real orders, real
customers, real product IDs, quarantine rate well under 5%. The job runs green every single night.
Finance’s dashboard shows a wrong number, quietly, for as long as it takes someone to notice a
number that doesn’t reconcile against another system – which for a metric checked weekly rather
than daily, can be a long time. A unit test asserting discount_amount == pytest.approx(expected)
against a known input would have caught this the moment it was written, in CI, before it ever
reached dev.step_right, let alone production.
The cost of skipping this section
None of this is free – writing and maintaining unit tests is real engineering time, same as
writing the pipeline itself. The trade StepRight is making here is the same one any legacy team
weighing “do we really need a test suite for a stored procedure” eventually has to make: a few
hours writing tests against compute_daily_revenue once, versus an unbounded amount of time
someday spent debugging why finance’s numbers don’t reconcile, with no test suite narrowing down
which of a dozen transformation steps is actually at fault. For logic this central to what every
downstream consumer trusts, that trade is worth making deliberately, in Section 6, rather than
reactively, during an incident.
The pyramid, drawn out
flowchart TD
A[Unit tests<br/>dozens, seconds, local PySpark] --> B[Integration tests<br/>few, minutes, real pipeline + test env]
B --> C[Job-level DQ gate<br/>one, every scheduled run, real prod data]
Each layer up costs more time and infrastructure to run, which is exactly why the shape matters: many cheap unit tests catch logic bugs before they ever reach a pipeline run; a smaller number of slower integration tests (Section 8) catch deployment and wiring bugs the unit layer can’t see by design; the single job-level gate (Section 5) catches whatever both earlier layers couldn’t – genuine bad data arriving from a source system, which no amount of testing code in this repo could have predicted in advance.
How this fits into CI, later
Every test this section writes runs from a plain pytest command with no Databricks connection
required – which is precisely what makes it possible for Section 8’s CI/CD pipeline to run the
full suite automatically on every pull request, before anything gets deployed to uat or prod.
Nothing about that requires foresight or extra design work now; it’s a direct consequence of the
“no cluster, no workspace” constraint this lecture already committed to.
Common mistakes
- Treating “the pipeline ran successfully” as proof the logic is correct. A pipeline run succeeding only means no exception was thrown – it says nothing about whether the numbers it produced are the right numbers, which is exactly the gap unit tests exist to close.
- Deciding test strategy after the code is already written, for every future section. This lecture’s placement – right after Section 5’s orchestration, before Section 7’s monitoring and Section 8’s packaging – is deliberate: designing the strategy once, here, means Sections 7 and 8 can each assume a working test suite already exists to build on, rather than every later section needing to reinvent testing conventions from scratch.
What’s next
Lecture 2 does the refactoring this strategy calls for: extracting gold_daily_revenue’s
calculation and silver_products’s dedup logic into standalone, DataFrame-in/DataFrame-out
functions, without changing what either pipeline actually produces.
| ← Previous: StepRight - Unit Testing | Next: Refactoring Your Code and Getting Ready for Unit Testing → | |:—|—:|