Refactoring Your Code and Getting Ready for Unit Testing
Estimated read: 7 min
Lecture 1 named two pieces of logic that can’t be unit tested as written: gold_daily_revenue’s
inline revenue math and silver_products’s inline dedup. This lecture pulls both out into
standalone functions – without changing what either pipeline actually produces.
The refactoring pattern: separate “what to compute” from “where the data comes from”
Every extraction in this lecture follows the same shape: the decorated @dp.table /
@dp.materialized_view function keeps doing I/O (spark.read.table(...)) and nothing else; a new,
plain function takes the DataFrames that I/O produced as arguments and does the actual
transformation, with a return and no table references anywhere inside it. The decorated function
becomes a thin wrapper – read, then delegate.
Extracting gold_daily_revenue’s calculation
Before (Section 4, Lecture 1 – one function doing both I/O and math):
@dp.materialized_view(name="gold_daily_revenue", comment="...")
def gold_daily_revenue():
order_items = spark.read.table("silver_order_items").filter("__END_AT IS NULL")
orders = spark.read.table("silver_orders").filter("__END_AT IS NULL").filter("status != 'CANCELLED'")
customers = spark.read.table("silver_customers").filter("__END_AT IS NULL")
products = spark.read.table("silver_products")
priced = (
order_items.join(orders, "order_id").join(customers, "customer_id").join(products, "product_id")
.withColumn("gross_revenue", col("unit_price") * col("quantity"))
.withColumn("discount_amount", col("gross_revenue") * (col("discount_pct") / 100))
.withColumn("net_revenue", col("gross_revenue") - col("discount_amount"))
)
return priced.groupBy(...).agg(...)
After (transformations/gold_logic.py – new, pure):
# transformations/gold_logic.py
from pyspark.sql.functions import col, sum as spark_sum, count
def compute_daily_revenue(order_items, orders, customers, products):
"""Pure: four DataFrames in, one aggregated DataFrame out. No spark.read anywhere."""
priced = (
order_items
.join(orders, "order_id")
.join(customers, "customer_id")
.join(products, "product_id")
.withColumn("gross_revenue", col("unit_price") * col("quantity"))
.withColumn("discount_amount", col("gross_revenue") * (col("discount_pct") / 100))
.withColumn("net_revenue", col("gross_revenue") - col("discount_amount"))
)
return (
priced
.groupBy(col("order_date").alias("revenue_date"), col("category"), col("region"))
.agg(
spark_sum("gross_revenue").alias("gross_revenue"),
spark_sum("discount_amount").alias("discount_amount"),
spark_sum("net_revenue").alias("net_revenue"),
count("order_item_id").alias("line_item_count"),
)
)
After (transformations/gold_reporting.py – now a thin wrapper):
import dlt as dp
from gold_logic import compute_daily_revenue
@dp.materialized_view(name="gold_daily_revenue", comment="...")
def gold_daily_revenue():
order_items = spark.read.table("silver_order_items").filter("__END_AT IS NULL")
orders = spark.read.table("silver_orders").filter("__END_AT IS NULL").filter("status != 'CANCELLED'")
customers = spark.read.table("silver_customers").filter("__END_AT IS NULL")
products = spark.read.table("silver_products")
return compute_daily_revenue(order_items, orders, customers, products)
Every filter Section 4 was careful about (__END_AT IS NULL, status != 'CANCELLED') stays where
it was, on the I/O side – the extraction moves the math, not the correctness-critical filtering
decisions that belong to reading the source tables.
Extracting silver_products’s dedup logic
Same pattern, applied to the window-function dedup from Section 3, Lecture 5:
# transformations/silver_logic.py
from pyspark.sql.window import Window
from pyspark.sql.functions import row_number, col
def dedupe_latest(df, partition_col, order_col):
"""Pure: keep exactly one row per partition_col, the one with the max order_col."""
window = Window.partitionBy(partition_col).orderBy(col(order_col).desc())
return (
df.withColumn("_rn", row_number().over(window))
.filter(col("_rn") == 1)
.drop("_rn")
)
Generalizing to partition_col and order_col arguments, rather than hardcoding product_id and
_ingested_at, is a deliberate small step beyond a pure copy-paste extraction – silver_products
calls it as dedupe_latest(df, "product_id", "_ingested_at"), but the same function is now
reusable the moment a second file-sourced table ever needs identical “latest wins” logic, without
a second near-duplicate window-function block.
After (transformations/silver_files.py – now a thin wrapper, same shape as gold_reporting.py’s):
import dlt as dp
from silver_logic import dedupe_latest
from dq_helpers import tag_business_rules
@dp.table(name="silver_products", comment="Deduplicated, validated current view of the product catalog")
def silver_products():
deduped = dedupe_latest(
spark.read.table("dev.step_right.bronze_products_valid"), "product_id", "_ingested_at"
)
warnings = {
"invalid_list_price": col("list_price") <= 0,
"unknown_category": ~col("category").isin(KNOWN_CATEGORIES),
}
return tag_business_rules(deduped, critical_checks={}, warning_checks=warnings)
The tagging step stays inline here rather than moving into silver_logic.py – it’s already a call
to a pure, already-tested helper (tag_business_rules), so wrapping it in a second layer of
indirection would add a file without adding any actual testability.
Why this refactor was cheap
Neither extraction required restructuring how StepRight’s pipelines are organized, adding a new
dependency, or touching a single table name, schema, or SQL statement Sections 2-4 already
verified by hand. That’s not luck – it’s the payoff of tag_quality and tag_business_rules
having already established the DataFrame-in/DataFrame-out shape as this project’s norm from
Section 2 onward, even before “unit testing” was an explicit goal. A codebase that already leans on
small, composable helper functions refactors toward testability in an afternoon; one where every
transformation is one long inline block, mixing I/O and business logic freely, would have needed a
far larger rewrite to reach the same starting line Lecture 3 now begins from.
What doesn’t move
tag_quality and tag_business_rules needed no refactoring at all – they were already pure the
day Section 2 and Section 3 wrote them. Nothing about the pipeline’s behavior changes here: same
tables, same columns, same values, verified by rerunning Section 4’s existing verification queries
against steprightproject-silver-gold after deploying the refactored files.
Freezing known-good output before refactoring anything
Before extracting a single function, capture what the current, unrefactored pipeline actually produces – a snapshot to diff the refactored version against, rather than trusting “the code looks equivalent” alone:
CREATE TABLE dev.step_right._pre_refactor_gold_daily_revenue AS
SELECT * FROM dev.step_right.gold_daily_revenue;
After deploying the refactored gold_reporting.py and letting steprightproject-silver-gold
rebuild gold_daily_revenue, a diff against that snapshot is a direct, mechanical answer to “did
the refactor change anything,” independent of how carefully the extraction was read by eye:
SELECT * FROM dev.step_right.gold_daily_revenue
EXCEPT
SELECT * FROM dev.step_right._pre_refactor_gold_daily_revenue;
-- Should return zero rows
Drop the snapshot table once the diff comes back empty – it’s a temporary verification step, not a permanent fixture of the schema.
One file at a time, not one giant refactor commit
Extract gold_logic.py first, deploy, diff against the frozen snapshot, and commit – then repeat
the same sequence for silver_logic.py as a second, independent commit. Two small, independently
verifiable changes are easier to review, and easier to revert individually if one diff surfaces an
unexpected difference, than one large commit touching both pipelines’ files at once, where a
regression in either one is harder to isolate from the diff alone.
Common mistakes
- Extracting the function but leaving a
spark.read.tablecall inside it “just in case.” A function with even one table reference isn’t pure anymore – the whole point of the extraction is a function pytest can call directly with an in-memory DataFrame, no workspace required. - Changing behavior while refactoring. Renaming a column, reordering a
groupBy, or “cleaning up” a filter while extracting a function turns a should-be-safe refactor into an unverified behavior change wearing a refactor’s disguise – extract first, verify identical output, and only then consider any actual logic changes as a separate, deliberate step.
What’s next
gold_logic.py and silver_logic.py now hold pure, testable functions. Lecture 3 writes the
actual pytest test cases against them, starting with a local Spark fixture in conftest.py.
| ← Previous: Design Test Strategy for the Project | Next: Designing and Developing Unit Test Cases → | |:—|—:|