Write a data test with a test action¶
A test action is the fourth Lakehouse Plumber action kind, alongside load,
transform, and write. Where a data_quality transform gates rows inside a
flow, a test action is a standalone unit test for your data: it asserts a
property you care about — uniqueness, a row count, a foreign key — runs on every
pipeline update, and reports pass or fail.
Let’s assert something concrete: every order in the bronze orders table has a
unique order_id. Not in a notebook you run by hand after the fact, but as a
check that lives in the pipeline, runs on every update, and reports its result
alongside the data it guards.
You could hand-write this. You’d author a @dp.table(temporary=True) function
that runs a GROUP BY ... HAVING COUNT(*) > 1 duplicate query, then decorate
it with @dp.expect_all_or_fail carrying the exact expression
duplicate_count == 0 — and remember the temporary=True flag so the check
table never persists. Or you declare type: test with test_type:
uniqueness, name the columns, and let Lakehouse Plumber write the query, the
temporary table, and the expectation. That is the whole idea: declare your
data tests, don’t hand-write the assertion code.
Before you start¶
You need a Lakehouse Plumber project (see the Get Started course for
lhp init) and a table to test — here, a bronze orders table another
flowgroup produces. A test action references its target by name, so the tested
table does not have to live in the same flowgroup; keep your data tests in a
dedicated flowgroup and point them at the tables they guard.
Declare the test¶
A test is one test action. Give it a test_type, point source at the
table to check, list the columns whose combined value must be unique, and
choose what happens on a violation. Create pipelines/orders_dq.yaml:
# A data-quality test flowgroup: assert that every order in the bronze
# orders table has a unique order_id. Test actions are emitted only when
# `lhp generate` is run with --include-tests.
pipeline: bronze_dq
flowgroup: orders_dq
actions:
# Uniqueness test: no two bronze rows may share an order_id.
- name: orders_id_unique
type: test
test_type: uniqueness
source: "${catalog}.${bronze_schema}.orders"
columns: [order_id]
on_violation: fail
description: "Every bronze order has a unique order_id"
The ${catalog} and ${bronze_schema} tokens resolve per environment from
substitutions/dev.yaml, so the same test ships unchanged from dev to
prod. on_violation: fail makes a duplicate stop the pipeline; warn records
the result and lets the run continue. This guide stays on the single
uniqueness case — the eight other test types (row_count,
referential_integrity, completeness, range, schema_match,
all_lookups_found, custom_sql, custom_expectations) take the same
shape and are covered in the test action reference.
Generate with tests included¶
Test actions are opt-in. Both lhp validate and lhp generate skip them by
default, so a plain generate on this flowgroup writes nothing. Pass
--include-tests to emit the test code:
$ lhp validate --env dev --include-tests
✓ discover (0.01s)
✓ preflight (0.00s)
✓ bronze_dq 0 files
✓ validate (0.33s)
1 validated · 0.3s
$ lhp generate --env dev
✓ discover (0.01s)
✓ preflight (0.00s)
✓ bronze_dq 0 files
✓ generate (0.39s)
✓ format (0.05s)
✓ monitoring (0.00s)
1 pipeline generated · 0 files · 0.4s
$ lhp generate --env dev --include-tests
✓ discover (0.01s)
✓ preflight (0.00s)
✓ bronze_dq 1 file
✓ generate (0.35s)
✓ format (0.02s)
✓ monitoring (0.00s)
1 pipeline generated · 1 file · 0.4s
The plain generate reports 0 files — the test is there but held back. With
--include-tests the same flowgroup writes 1 file.
Read what Lakehouse Plumber wrote¶
Open generated/dev/bronze_dq/orders_dq.py. This is the entire output —
nothing is hidden behind a runtime:
# Generated by LakehousePlumber
# Pipeline: bronze_dq
# FlowGroup: orders_dq
from pyspark.sql.functions import *
from pyspark import pipelines as dp
# Pipeline Configuration
PIPELINE_ID = "bronze_dq"
FLOWGROUP_ID = "orders_dq"
# ============================================================================
# DATA QUALITY TESTS
# ============================================================================
@dp.expect_all_or_fail({"no_duplicates": "duplicate_count == 0"})
@dp.table(
name="tmp_test_orders_id_unique",
comment="Every bronze order has a unique order_id",
temporary=True,
)
def tmp_test_orders_id_unique():
"""Every bronze order has a unique order_id"""
return spark.sql("""
SELECT order_id, COUNT(*) as duplicate_count
FROM dev_catalog.bronze.orders
GROUP BY order_id
HAVING COUNT(*) > 1
""")
Lakehouse Plumber turned the check into a temporary Lakeflow table. It wrote the
duplicate-finding query — SELECT order_id, COUNT(*) AS duplicate_count ...
GROUP BY order_id HAVING COUNT(*) > 1 — with your ${...} tokens resolved to
dev_catalog.bronze.orders, wrapped it in @dp.table(...) marked
temporary=True so the check never persists, and attached the expectation
@dp.expect_all_or_fail({"no_duplicates": "duplicate_count == 0"}). When the
pipeline runs, Databricks evaluates that expectation and records the pass/fail
count in the pipeline’s event log and data-quality metrics — that is where the
result is reported.
Note
on_violation decides the decorator: fail emits
@dp.expect_all_or_fail (a violation stops the flow), warn emits
@dp.expect_all (the result is recorded, the run continues), and drop
emits @dp.expect_all_or_drop. Choose fail for a hard gate. Choose
warn when you forward results to an external system, because a fail
aborts the flow before its metrics are recorded and the reporting hook never
sees them.
What you just did¶
You wrote a seven-line test action and Lakehouse Plumber generated the 31-line
Lakeflow test table: zero lines of the assertion came from you — not the
GROUP BY ... HAVING duplicate query, not the @dp.table(temporary=True)
wrapper, not the @dp.expect_all_or_fail expression. The rule you own —
order_id is unique — is the one thing you typed. Change on_violation or
add a filter and regenerate, and Lakehouse Plumber rewrites the table to
match; you never touch the Python.
What’s next¶
Reach for another test type — compare counts across two tables with
row_count, check foreign keys withreferential_integrity, or bound a column withrange. The full type list and every field lives in the test action reference.Publish results to an external system — forward every test outcome to a Delta audit table, Azure DevOps Test Plans, or your own endpoint. See Report test results to an external system.