08Testing · SQL
PySpark tests prove the transform. They cannot prove the warehouse, because the warehouse is where eleven million rows meet a rule that held on the twelve rows in the factory. This route decides what a SQL assertion looks like, which eight this warehouse ships with, and how two result sets get compared without the comparison quietly agreeing with itself.
The pattern
Two ideas carry the route: a test is a query that should return nothing, and a directory of such queries becomes a suite once twelve lines of Python have looked at it.
#Zero rows means pass
SELECT that returns the rows violating a rule. Empty result set, pass. There is no library and no DSL — the query is simultaneously the check and the error message, because the rows it returns on failure are the evidence needed to fix it.-- SCD2 dimension: the business key plus the interval start must be unique.
-- Zero rows = pass.
SELECT
customer_id,
_tech_valid_from,
COUNT(*) AS versions
FROM gold.dim_customer
GROUP BY customer_id, _tech_valid_from
HAVING COUNT(*) > 1SELECT CASE WHEN COUNT(*) = 0 THEN 'PASS' ELSE 'FAIL' END AS result
FROM (
SELECT customer_id FROM gold.dim_customer
GROUP BY customer_id, _tech_valid_from HAVING COUNT(*) > 1
)<table>_<property>.sql. The file name becomes the test name, so a red build says dim_customer_scd2_no_overlap rather than test_sql_3. Nothing inside is catalog-qualified — the harness sets the catalog, which is what lets one file run against a seeded CI schema and against production.silver.fx_rate had no row for a bank holiday, because a job was re-run and a MERGE matched twice.Doing it
- Write the assertion in the same pull request as the table. One added later is calibrated against data that already exists, which is the opposite of a test.
- Select the identifying columns plus the values that explain the violation — key alone is reproducible, key plus expected and actual is diagnosable.
- Run the suite as a job task after the nightly load, not only in CI. Production data breaks assertions that seeded fixtures never will.
Defaults
- One rule per file; the file name is the test name.
- Return the offending rows, never a verdict.
- Never catalog-qualify inside the .sql file — the harness owns the catalog.
- An assertion that has never been red is unproven. Break it deliberately once.
Gotchas
- An assertion written after the table exists is fitted to the data, not to the rule. It stays green through the bug it was written for, because that bug was already in the table when the author calibrated it.
- NOT IN with a nullable column is the quietest failure here.
country NOT IN ('DE','AT','CH','NL')is NULL when country is NULL, so the row is not returned and the check passes on exactly the rows that are most broken. Every domain assertion needs an explicitIS NULL ORarm. - Assertions that only run in CI. CI runs against fixtures, and fixtures never contain the duplicate SAP sent at 03:00 on a Sunday.
- Selecting only the key column. The failure is reproducible but not diagnosable, so the on-call engineer re-runs it by hand with more columns — every time, at 02:00, forever.
#Twelve lines that turn a directory into a suite
import pathlib
import pytest
SQL_DIR = pathlib.Path(__file__).parent
CASES = sorted(SQL_DIR.glob("*.sql"))
assert CASES, f"no .sql assertions found under {SQL_DIR}"
@pytest.mark.parametrize("case", CASES, ids=[c.stem for c in CASES])
def test_assertion(spark, catalog, case):
spark.sql(f"USE CATALOG {catalog}")
bad = spark.sql(case.read_text()).take(20)
assert not bad, "{}: {} violating row(s) shown, capped at 20\n{}".format(
case.stem, len(bad), "\n".join(str(r.asDict()) for r in bad)
)ids=[c.stem ...] makes the report read test_assertion[fct_order_line_orphans], so the failure names the property. take(20) bounds the blast radius. And assert CASES exists because pytest skips an empty parametrize list — without it, a packaging change that leaves the files out produces a green run with zero tests.spark and catalog fixtures come from the same conftest.py as the PySpark suite. One difference matters: this suite has no local mode. No local session contains gold.dim_customer, so it always runs remotely — against dwh_test after CI has loaded, or against dwh_prod on a schedule.resources:
jobs:
dwh_assertions:
name: "dwh · SQL assertions · ${bundle.target}"
tasks:
- task_key: assertions
# NO environment_key here. Serverless notebook tasks are reported to
# reject it — register key serverless-environment-key, status hot and
# explicitly UNVERIFIED. Reproduce on the client workspace before you
# either rely on it or design around it.
notebook_task:
# thin wrapper: runs pytest over tests/sql and writes the JUnit XML
# to the ci Volume. All the logic is in test_assertions.py above.
notebook_path: ../tests/sql/run_assertions.py
base_parameters:
catalog: ${var.catalog}
schedule:
quartz_cron_expression: "0 30 5 * * ?"
timezone_id: "Europe/Zurich"
email_notifications:
on_failure: ["${var.oncall_rota}"]Doing it
- Reuse the conftest.py from the PySpark suite. Two session builders drift, and the drift shows up as a test that passes in one suite and fails in the other.
- Write the JUnit XML to the ci Volume in dwh_test so the report survives the cluster.
- Point the scheduled job at the on-call rota, never at an individual and never at your own address.
Defaults
- Twelve lines. If the harness grows, the growth belongs in the .sql files.
- File stem as test ID, so failures are self-describing.
- Always remote — there is no local mode for warehouse assertions.
- Bounded collection: take(n), never collect().
Gotchas
- collect() instead of take(). A missing join predicate turns an orphan check into a cross join, the driver pulls millions of rows, and the suite dies of an OOM twenty minutes in — which reads as infrastructure flakiness and gets retried rather than fixed.
- An empty glob is a silent green. pytest skips a zero-length parametrize list, so a packaging change produces a passing run with no tests and nobody reads the collected-count line.
- A missing table or parse error raises, so pytest reports ERROR rather than FAILURE. A CI gate that greps the summary for 'failed' reports green while every assertion errored — gate on the exit code.
- Running assertions before the load in the same job. They pass against yesterday's data and the job is green while today's load is still failing. Depend on the load task explicitly.
- Assuming the serverless task takes an environment_key. It is reported to be rejected and the report is not doc-confirmed, so a copied job definition can fail at deploy time with a message about a field that is documented elsewhere. Deploy the job to a scratch target once before you promise a schedule.
The eight assertions
Four are transcriptions of something already declared — the key, the grain, the foreign keys, the country list. Four are the seven business rules made executable. Between them they cover every way this warehouse has actually been wrong.
#Four the model already declared
gold.dim_customer above, and the declared grain of gold.fct_order_line.SELECT
order_id,
line_number,
COUNT(*) AS rows_at_grain,
COUNT(DISTINCT order_line_sk) AS distinct_sks
FROM gold.fct_order_line
GROUP BY order_id, line_number
HAVING COUNT(*) > 1distinct_sks is not decoration. If it is 1, the load ran twice and inserted the same surrogate key twice — an idempotency bug. If it equals rows_at_grain, the hash inputs differ, so the business key is not what the model says it is. Two causes, told apart by one column in the failure output.customer_sk derives from customer_id alone, resolving it means joining on the interval:SELECT
f.order_line_sk,
f.customer_sk,
f.order_ts
FROM gold.fct_order_line f
LEFT JOIN gold.dim_customer d
ON d.customer_sk = f.customer_sk
AND f.order_ts >= d._tech_valid_from
AND (d._tech_valid_to IS NULL OR f.order_ts < d._tech_valid_to)
-- IS DISTINCT FROM, not <>: a NULL customer_sk must be REPORTED, and
-- f.customer_sk <> 'UNKNOWN' is NULL on exactly those rows.
WHERE f.customer_sk IS DISTINCT FROM 'UNKNOWN'
AND d.customer_sk IS NULLorder_ts — while the report that joins on the interval silently loses the row. The assertion has to join the way the report joins.SELECT
country,
COUNT(*) AS affected_rows,
MIN(customer_id) AS example_customer
FROM gold.dim_customer
WHERE country IS NULL
OR country NOT IN ('DE', 'AT', 'CH', 'NL')
GROUP BY countrydim_customer, so the total comes back smaller — unnoticed, because nobody knows what it should be.Doing it
- Generate the uniqueness, grain and orphan assertions from the declared PK/FK metadata; hand-write only the interval predicates.
- Put the UNKNOWN-member exclusion in the WHERE clause explicitly, so the reader sees that unresolved rows are a policy and not a leak.
- Add the diagnostic column — distinct_sks, example_customer — to every grouped assertion.
- Assert the domain against the same literal list the transform uses. If the list appears twice in the repository it will disagree with itself within a quarter.
Defaults
- Uniqueness, grain and orphans are generated from declarations, not typed per table.
- Every orphan check joins on the validity interval, with an exclusive end.
- Domain lists live in one place, referenced by both transform and assertion.
- The UNKNOWN member is excluded explicitly, never by a NOT NULL on the key.
Gotchas
- Trusting a declared PRIMARY KEY. UC constraints are informational — declare one, insert a duplicate, it succeeds. With RELY the optimizer believes the declaration and can eliminate a join, so a violation returns wrong results faster than before you declared it.
- An orphan check joining on customer_sk alone. It stays green while the report that joins on the interval quietly drops every late-arriving row — so assertion and dashboard disagree, and the assertion is the one that is wrong.
- Checking COUNT(*) on the surrogate key instead of the business key. A key that normalises differently —
'4711'versus' 4711'— hashes to two different sks, and the check is blind to the case it was written for. - A grain assertion without the distinct_sks column. You learn there are duplicates, then spend an hour deciding whether the load is not idempotent or the key is wrong. The answer was one column away.
#Four the business rules declared
-- Intervals are [_tech_valid_from, _tech_valid_to) — end is EXCLUSIVE.
-- The asymmetric join reports each offending pair once, not twice.
SELECT
a.customer_id,
a._tech_valid_from AS earlier_from,
a._tech_valid_to AS earlier_to,
b._tech_valid_from AS later_from
FROM gold.dim_customer a
JOIN gold.dim_customer b
ON b.customer_id = a.customer_id
AND b._tech_valid_from > a._tech_valid_from
WHERE a._tech_valid_to IS NULL
OR b._tech_valid_from < a._tech_valid_toSELECT
customer_id,
COUNT_IF(_tech_is_current) AS flagged_current,
COUNT_IF(_tech_valid_to IS NULL) AS open_intervals,
COUNT_IF(_tech_is_current AND _tech_valid_to IS NULL) AS both_on_same_row
FROM gold.dim_customer
GROUP BY customer_id
HAVING COUNT_IF(_tech_is_current) <> 1
OR COUNT_IF(_tech_valid_to IS NULL) <> 1
OR COUNT_IF(_tech_is_current AND _tech_valid_to IS NULL) <> 1_tech_is_current return the superseded version and queries filtering on _tech_valid_to IS NULL return the right one. Two dashboards, two answers, no error anywhere.SELECT
order_line_sk,
currency,
fx_rate,
quantity,
unit_price,
amount_eur,
status,
ROUND(quantity * unit_price * fx_rate, 2) AS expected_eur
FROM gold.fct_order_line
WHERE amount_eur IS DISTINCT FROM ROUND(quantity * unit_price * fx_rate, 2)
OR (currency = 'EUR' AND fx_rate IS DISTINCT FROM 1)
OR (status IS DISTINCT FROM 'CANCELLED' AND COALESCE(amount_eur, 0) <= 0)
OR currency IS NULL
OR currency NOT IN ('EUR', 'CHF')IS DISTINCT FROM rather than <> throughout: a NULL amount_eur compared with <> yields NULL, the row is not returned, and the assertion passes on the rows where conversion failed hardest.silver.fx_rate — (currency, rate_date, fx_rate), one row per currency per day — rather than reusing the fx_rate gold already stored. Reusing gold's own rate would make the test tautological: it would prove that multiplication works.WITH silver_month AS (
SELECT
DATE_TRUNC('MONTH', o.order_ts) AS month,
-- EUR is the base currency and is absent from the ECB file, so it is 1 by
-- definition. Rule 2 rounds PER LINE; rounding the sum drifts by cents.
SUM(ROUND(l.quantity * l.unit_price *
CASE WHEN l.currency = 'EUR' THEN CAST(1 AS DECIMAL(18,6))
ELSE r.fx_rate END, 2)) AS silver_eur,
-- a missing rate makes BOTH sides smaller by the same amount, so the delta
-- stays zero. This column is the only place that defect is visible.
COUNT_IF(l.currency IS DISTINCT FROM 'EUR'
AND r.fx_rate IS NULL) AS lines_without_rate
FROM silver.sales_order_line l
JOIN silver.sales_order o ON o.order_id = l.order_id
LEFT JOIN silver.fx_rate r ON r.currency = l.currency
AND r.rate_date = DATE(o.order_ts)
WHERE l.status IS DISTINCT FROM 'CANCELLED'
GROUP BY 1
),
gold_month AS (
SELECT
DATE_TRUNC('MONTH', order_ts) AS month,
SUM(amount_eur) AS gold_eur
FROM gold.fct_order_line
WHERE status IS DISTINCT FROM 'CANCELLED'
GROUP BY 1
)
SELECT
COALESCE(s.month, g.month) AS month,
s.silver_eur,
g.gold_eur,
COALESCE(g.gold_eur, 0) - COALESCE(s.silver_eur, 0) AS delta_eur,
COALESCE(s.lines_without_rate, 0) AS lines_without_rate
FROM silver_month s
FULL OUTER JOIN gold_month g ON g.month = s.month
WHERE ABS(COALESCE(g.gold_eur, 0) - COALESCE(s.silver_eur, 0)) > 0.01
OR COALESCE(s.lines_without_rate, 0) > 0Doing it
- Assert both SCD2 properties, including the both-on-same-row arm. The pair is the test; either alone is a false sense of coverage.
- Use IS DISTINCT FROM in every predicate, without deciding case by case whether a column can be NULL.
- Recompute the reconciliation from silver.fx_rate, never from the rate the fact already stored, and LEFT JOIN it so an absent rate becomes a counted column rather than a deleted row.
- Reconcile at month grain and also for the current month to date — a monthly-only reconciliation finds yesterday's break in four weeks.
Defaults
- Exclusive interval ends everywhere; assertion and transform share the convention or both are wrong.
- IS DISTINCT FROM by default in assertions.
- Reconciliations are FULL OUTER and independent of the value they check.
- Rules 1–7 map one-to-one onto assertions. A rule with no assertion is a comment.
Gotchas
- An inclusive interval end. If _tech_valid_to equals the next version's _tech_valid_from and the join uses
<=, every boundary instant matches two versions and every temporal join double-counts — small enough to look like rounding, large enough to fail an audit. - The SCD2 flag and the open interval drifting apart. Both single-property assertions pass, and the warehouse serves two different 'current' customers depending on which predicate the query used.
- Rounding at the wrong point. Rule 2 rounds per line; summing unrounded values and rounding the total drifts by cents per thousand lines — just inside a naive tolerance and just outside a finance reconciliation.
- A percentage tolerance. It scales with the error it is meant to catch, so a conversion bug costing 0.3 % of revenue is invisible at every volume.
- Reconciling against gold's own stored fx_rate. Green by construction, through a completely wrong rate table.
- An INNER JOIN from silver.sales_order_line to silver.fx_rate. The ECB file has no EUR row — EUR is the base currency — so every euro line vanishes from the silver side, and a reconciliation between DE/AT/NL revenue and total revenue reports a delta of exactly the euro business. It looks like a catastrophic transform bug and is a join.
- A missing rate for a real currency. It removes the same lines from silver and from gold, the delta stays zero, and the test is green on a month that is short by one currency for one day. Only the lines_without_rate column sees it.
Comparing two result sets
Half the assertions above are one question — do these two queries return the same thing — and the SQL for that question has three traps in it, each of which produces a green test.
#EXCEPT, symmetrically and with counts
A EXCEPT B returns rows in A that are not in B and says nothing about rows in B that are not in A. A one-directional comparison of a deployed table against a rebuild therefore catches missing rows and is blind to extra ones — the more common failure, because extra rows are what a re-run produces.EXCEPT is EXCEPT DISTINCT. It deduplicates both inputs before subtracting, so one copy of a row and three copies compare as identical. Whenever counts matter — in a warehouse, always — the operator is EXCEPT ALL.WITH rebuilt AS (
SELECT
-- make_sk() verbatim: trim, upper, coalesce to the sentinel, concat_ws,
-- sha2. Any deviation and EVERY row reports as both missing and extra.
sha2(concat_ws('||', COALESCE(UPPER(TRIM(customer_id)), '~')), 256)
AS customer_sk,
customer_id,
customer_name,
country,
customer_segment,
CAST(_tech_valid_from AS TIMESTAMP) AS _tech_valid_from,
CAST(_tech_valid_to AS TIMESTAMP) AS _tech_valid_to
FROM silver.customer
),
deployed AS (
SELECT
customer_sk, customer_id, customer_name, country, customer_segment,
_tech_valid_from, _tech_valid_to
FROM gold.dim_customer
)
SELECT 'missing_from_gold' AS side, *
FROM (SELECT * FROM rebuilt EXCEPT ALL SELECT * FROM deployed)
UNION ALL
SELECT 'extra_in_gold' AS side, *
FROM (SELECT * FROM deployed EXCEPT ALL SELECT * FROM rebuilt)side column matters more than it looks: missing and extra have different causes and different fixes.EXCEPT is already NULL-safe. An equality join is not. Rewriting this as LEFT JOIN ... WHERE b.key IS NULL — which reads as the same check — changes the answer for every nullable column, because NULL = NULL is NULL and the row never matches:-- same WITH rebuilt AS (...), deployed AS (...) header as above
SELECT d.customer_sk, d._tech_valid_from
FROM deployed d
LEFT JOIN rebuilt r
ON r.customer_sk IS NOT DISTINCT FROM d.customer_sk
AND r._tech_valid_from IS NOT DISTINCT FROM d._tech_valid_from
AND r.customer_segment IS NOT DISTINCT FROM d.customer_segment
-- customer_sk is a hash and is never NULL, which is the ONLY reason it can
-- serve as the did-it-match sentinel. Pick a nullable column here and the
-- anti-join reports matched rows as missing.
WHERE r.customer_sk IS NULLDoing it
- Compare in both directions, always, with a side column naming the direction.
- EXCEPT ALL by default. Reach for EXCEPT DISTINCT only when you can say out loud why duplicates are acceptable.
- Cast both sides to the same declared types inside the CTEs, before the set operator sees them.
- Keep the column list explicit on both sides. SELECT * over two tables that have drifted by one column fails with a positional type error naming the wrong column.
Defaults
- Symmetric EXCEPT ALL with a side column, as one file.
- Explicit column lists and explicit casts in both CTEs.
- IS NOT DISTINCT FROM in join predicates; IS DISTINCT FROM in filters.
- Parity assertions are temporary by design — delete them when the migration is done.
Gotchas
- One-directional EXCEPT. It catches missing rows and is blind to extra ones, so a re-run that duplicated a day's load passes the parity check written specifically to catch it.
- Plain EXCEPT when counts matter. It deduplicates both sides first; one row versus three compares as identical, and the report double-counts revenue with a green test behind it.
- Implicit coercion across the set operator. DECIMAL(18,2) on one side and DOUBLE on the other coerce silently to DOUBLE, cent-level differences appear that exist nowhere in the data, and you spend a day looking for a rounding bug in a correct transform.
- Column order. Set operators match by position, not by name, so two SELECTs with the same columns in a different order compare successfully and compare the wrong things whenever the types line up.
- Retyping the key recipe in the rebuild CTE. TRIM(UPPER(x)) where make_sk does COALESCE(UPPER(TRIM(x)), '~') hashes every row to a different value, so the parity check reports 100 % missing and 100 % extra. That reads as a catastrophic migration and is a paraphrased expression — paste the recipe with a comment naming its source, or call the function.
#Testing a metric view against an oracle
MEASURE() function. gold.mv_sales is where revenue is defined once for AI/BI dashboards, Genie, notebooks and Power BI — so a mistake in it is a mistake in all of them at once.gold.fct_order_line, compared symmetrically with the view.WITH from_view AS (
SELECT
country,
CAST(month AS DATE) AS month,
CAST(MEASURE(revenue) AS DECIMAL(18,2)) AS revenue,
CAST(MEASURE(order_count) AS BIGINT) AS order_count
FROM gold.mv_sales
GROUP BY country, month
),
oracle AS (
SELECT
c.country,
CAST(DATE_TRUNC('MONTH', f.order_ts) AS DATE) AS month,
CAST(SUM(f.amount_eur) AS DECIMAL(18,2)) AS revenue,
CAST(COUNT(DISTINCT f.order_id) AS BIGINT) AS order_count
FROM gold.fct_order_line f
JOIN gold.dim_customer c
ON c.customer_sk = f.customer_sk
-- the view joins the CURRENT version (see the mv_sales YAML), so the
-- oracle does too. Join on the interval here and the oracle is red for
-- every customer who ever moved country — a real question, but a
-- different assertion, not this one.
AND c._tech_is_current
WHERE f.status IS DISTINCT FROM 'CANCELLED'
GROUP BY 1, 2
)
SELECT 'metric_view_only' AS side, *
FROM (SELECT * FROM from_view EXCEPT ALL SELECT * FROM oracle)
UNION ALL
SELECT 'oracle_only' AS side, *
FROM (SELECT * FROM oracle EXCEPT ALL SELECT * FROM from_view)status <> 'CANCELLED'; the oracle filters with IS DISTINCT FROM. A row whose status is NULL is therefore counted by the oracle and dropped by the view, so the gap between them is the exact revenue that three-valued logic is deleting — invisible in the view itself, because a metric view has no rows to count.gold.mv_sales resolves country through _tech_is_current, which means a customer who moves from AT to DE takes three years of history with them, retroactively. That is a business decision, not a bug, and the oracle must join the way the view joins or it reports the decision as a defect every night. Write the interval-join version as a separate file whose returned rows quantify the shift — then take that number to the business rather than to the engineer.Doing it
- Write one oracle per measure — revenue, order_count, average_order_value, active_customers — not one for the view as a whole.
- Test average_order_value separately: AVG of line amounts and revenue over order_count are different numbers, and the view has to say which it means.
- Test active_customers on a month containing a cancelled-only customer. Whether they count is a business decision, and this is where you find out it was never made.
- Write the oracle's filter NULL-safely even though the view's is not. The difference between the two is the measurement of the problem; making them identical hides it.
Defaults
- One oracle per measure, written by someone other than the view's author.
- Symmetric EXCEPT ALL with explicit casts on both sides.
- The oracle joins dimensions exactly as the metric view does. A different join is a different question and belongs in its own file.
- Oracles run on metric-view change as well as on schedule — a definition moves without any data moving.
Gotchas
- The author writing their own oracle. The test proves internal consistency, passes forever, and gives false confidence to five downstream tools at once.
- Type coercion across the set operator. MEASURE() returning DOUBLE against an oracle's DECIMAL(18,2) produces differences of 0.000000001 on every row — red on correct data, and within a fortnight someone adds a tolerance that hides a real break.
- Testing only the total. Revenue can be right in aggregate and wrong per country when a dimension resolves through the wrong customer version, so the oracle must group by the dimensions people slice by.
- Metric-view materialization is Public Preview, and metric import from third-party tools is Beta — neither belongs in a sizing estimate. If the oracle suite starts failing intermittently after someone enables materialization, suspect a stale materialization before you suspect the data.
Straight to the documentation
The generic platform material lives in the official docs and is not restated here. These are the pages worth having open while you work through this route.
- Set operators: EXCEPT, INTERSECT, UNION — the DISTINCT-by-default semantics, and confirmation that set operators match by position rather than by name
- NULL semantics — read before writing any assertion — the NOT IN and comparison-operator behaviour that makes checks pass on broken rows
- count_if — the aggregate that makes the SCD2 one-current assertion three lines instead of three subqueries
- Constraints on Databricks — confirms primary and foreign keys are informational and what RELY changes — the reason the orphan assertion exists at all
- Unity Catalog metric views — the YAML definition and MEASURE() syntax the oracle is written against