Course 4 · The Craft

Data Quality & Testing

You've modeled, moved, transformed, stored, streamed, and scheduled. None of it matters if people don't believe the numbers. This final chapter of the craft is about proof — building quality into the pipeline as a first-class system so that when a dashboard says "revenue: $48,200," everyone trusts it. It's the difference between a pipeline that runs and one people stake decisions on.

Setup — do this first

You'll extend the dbt marketplace project from Chapter 07. Open a terminal in that project (the folder with dbt_project.yml), confirm dbt build still runs clean, and keep this page beside it. Everything here is plain dbt and SQL — no new installs.

Trust is the product

Here's a hard truth that takes most engineers a painful incident to learn: the output of a data platform is not tables — it's trust. A pipeline can be elegantly modeled, perfectly orchestrated, and lightning fast, and still be worthless the moment a stakeholder catches a single wrong number.

And the damage is not local. When someone opens a dashboard, sees revenue that's obviously too high, and traces it to a duplicated row, they don't conclude "that one metric is broken." They conclude "I can't trust any of these numbers" — and they go back to their own spreadsheet. One bad cell poisons confidence in the whole platform, and rebuilding that confidence takes months. Trust is expensive to earn and cheap to destroy.

Quality is a system, not a vibe

Beginners treat quality as something you "check" at the end — eyeball the output, looks fine, ship it. Professionals treat it as a system: explicit assertions that run automatically every time data moves, so bad data is caught by a machine before a human ever sees it. The goal is not "we looked carefully." The goal is "it is structurally impossible for this class of error to reach the dashboard." That mindset is the whole chapter.

The dimensions of quality

"Good data" sounds vague until you break it into the specific ways data goes bad. Each failure mode is a dimension of quality, and each one is something you can write an assertion about. Here's where they sit along the pipeline you already built:

SOURCE ──────▶ INGEST ──────▶ TRANSFORM ──────▶ SERVE │ │ │ │ schema freshness validity uniqueness contract volume values referential not-null integrity │ │ │ │ "did the "did data "are the "is each key shape arrive, and values unique, and do change?" enough of it?" sane?" joins line up?"

Six dimensions cover the vast majority of real incidents. Learn them as a checklist — when something breaks, it's almost always one of these:

DimensionThe question it answersWhat it catches
FreshnessIs the data recent enough?A stalled upstream job — yesterday's numbers shown as today's.
VolumeIs the row count in a normal range?A half-loaded file (too few rows) or a duplicated load (too many).
SchemaAre the columns & types what we expect?An upstream rename or dropped column that silently breaks a join.
Validity / valuesAre individual values sane?Negative revenue, a status of "banana", a NULL where a key must exist.
UniquenessIs each key represented once?Duplicate rows that double-count and inflate every metric.
Referential integrityDo foreign keys point to real rows?A rental referencing a customer that doesn't exist — orphaned facts.

Notice these map directly onto problems you've already seen: uniqueness is why deduplication mattered in the medallion silver layer; referential integrity is what dimensional modeling's keys are for. Testing isn't a new topic bolted on — it's verifying the very properties your modeling promised.

Testing in the pipeline

A test in data engineering is a query that returns the rows that violate a rule. Zero rows returned means the test passes; any rows returned means it fails. That's the entire mental model, and dbt builds on it. You met four built-in tests in Chapter 07 — here they are as the front line of defense:

models/marts/_marts.yml
models:
  - name: fct_rentals
    columns:
      - name: rental_id
        tests:
          - not_null          # every fact must have a key
          - unique            # …and it must appear exactly once
      - name: customer_id
        tests:
          - not_null
          - relationships:    # referential integrity
              to: ref('dim_customers')
              field: customer_id
      - name: status
        tests:
          - accepted_values:  # validity — the only allowed values
              values: ['active', 'completed', 'cancelled']

Those four — not_null, unique, relationships, accepted_values — handle the common cases declaratively. But some rules don't fit a column dropdown. For those, dbt gives you two escape hatches.

A singular test is just a hand-written SQL file in tests/ that selects the bad rows. Want to assert revenue is never negative? Write the query that finds negatives:

tests/assert_revenue_non_negative.sql
-- Passes only if this returns zero rows.
-- Any row here is a rental with impossible revenue.
select
    rental_id,
    revenue
from {{ ref('fct_rentals') }}
where revenue < 0

A generic test wraps that pattern in a reusable macro so you can apply it by name to any column (e.g. a non_negative test) — handy once you're writing the same check in several models. And separate from both is unit testing your transform logic: feeding a model a tiny set of fixed input rows and asserting the exact output, so you catch a broken CASE statement or off-by-one window function regardless of what today's real data happens to look like:

models/marts/_marts.yml (unit test)
unit_tests:
  - name: revenue_is_hours_times_rate
    model: fct_rentals
    given:
      - input: ref('stg_rentals')
        rows:
          - {rental_id: 1, hours: 2, hourly_rate: 5.00}
    expect:
      rows:
        - {rental_id: 1, revenue: 10.00}   # 2 × 5.00
Data tests vs. unit tests

They answer different questions. A data test asks "is today's data clean?" — it runs against whatever loaded. A unit test asks "is my code correct?" — it runs against fixed fake rows and would catch a logic bug even on a perfect day. Mature pipelines use both: unit tests guard the transform, data tests guard the data flowing through it.

The final piece is when tests run. Tests sitting in your repo that nobody runs are decoration. The professional move is to run dbt build (which runs models and their tests, stopping on failure) in CI — the automated check on every pull request, from your Git & GitHub chapter. Now a teammate literally cannot merge a change that breaks a test. Bad data is caught before it ships, not after a stakeholder reports it.

Data observability — the unknown-unknowns

Every test above guards a rule you thought of. But the failures that hurt most are the ones nobody anticipated: a vendor quietly changes a timezone, a marketing campaign triples sign-ups overnight, a column starts arriving as a string instead of a number. You can't write a test for a problem you haven't imagined.

Data observability fills that gap. Instead of asserting specific rules, observability tools watch your tables over time and learn what normal looks like, then alert when reality drifts:

Tests (known-knowns)Observability (unknown-unknowns)
You declare the rule explicitly.The monitor learns the baseline automatically.
"status must be in this list.""This table usually gets ~10k rows/day; today it got 200 — that's anomalous."
Catches violations you predicted.Catches drift you never thought to check.
Pass / fail, binary.Anomaly score — "this looks off, take a look."

Observability typically watches the same dimensions from the table above — freshness (did this table update on schedule?), volume (is today's row count within the normal band?), and schema (did columns or types change?) — but as automated monitors rather than hand-written assertions. When a metric strays far enough from its learned baseline, it fires an alert. You don't need a paid platform to start: even a scheduled query that logs daily row counts and flags a sudden 50% drop is observability in spirit.

Tests and observability are partners

Don't pick one. Tests encode the rules you're sure about and fail loud and early. Observability is the safety net underneath, catching the surprises. Together they cover both what you know to check and what you don't — the complete picture of "is this data okay right now?"

Data contracts — pushing quality left

Everything so far reacts to bad data after it enters your pipeline. The best quality strategy is to stop it at the door. That's a data contract: an explicit, versioned agreement with the team (or system) that produces a source you depend on.

A contract spells out, in writing and in code, three things about a source:

  • Schema — the exact columns, types, and which fields are guaranteed non-null. (The customer_id will always be a non-null integer.)
  • Semantics — what the values mean. (status = 'completed' means the rental ended and was billed — not "in progress".)
  • SLA — the freshness and availability promise. (This table updates by 06:00 UTC daily; we'll give 30 days' notice before any breaking change.)

The point of writing it down is that an upstream change now becomes a contract violation — something the producing team agreed not to do silently — instead of a surprise that breaks your dashboard at 2 a.m. Where tests catch bad data after it's loaded, a contract catches a breaking change before it's even published. That's what people mean by "shifting quality left": moving the check as close as possible to where data is produced, where it's cheapest to fix.

Why "left" is cheaper

Picture the pipeline as a line, source on the left, dashboard on the right. A schema break caught at the source costs the producer a code review. The same break caught at the dashboard costs you a fire drill, a stakeholder's trust, and a backfill of every downstream table. Same bug, wildly different price — and the only variable is how far left you caught it.

Hands-on: add a test suite, then break it

Time to make this real. You'll add a suite of tests to the marketplace project, then deliberately introduce a bad row and watch the suite catch it — the full fail→fix→pass loop that defines test-driven data work.

Step 1 — declare the tests. Add (or extend) the schema file for your marts so the keys, the relationship, and the value set are all covered:

models/marts/_marts.yml
version: 2

models:
  - name: fct_rentals
    description: One row per GPU rental, with revenue.
    columns:
      - name: rental_id
        tests: [not_null, unique]
      - name: customer_id
        tests:
          - not_null
          - relationships:
              to: ref('dim_customers')
              field: customer_id
      - name: status
        tests:
          - accepted_values:
              values: ['active', 'completed', 'cancelled']

Step 2 — add a freshness check. dbt's source freshness watches a raw source's load time. Point it at your rentals source and set a threshold:

models/staging/_sources.yml
sources:
  - name: marketplace
    tables:
      - name: rentals
        loaded_at_field: loaded_at      # a timestamp column on the table
        freshness:
          warn_after:  {count: 12, period: hour}
          error_after: {count: 24, period: hour}   # fail if no load in 24h

Step 3 — run the suite on clean data. Run the tests and the freshness check:

terminal
dbt test                    # run all data tests
dbt source freshness        # run the freshness check
✓ You should see — everything green

Each test prints PASS, ending with a line like Done. PASS=6 WARN=0 ERROR=0 SKIP=0 TOTAL=6, and freshness reports PASS. Green across the board — your data satisfies every rule you declared. Now let's prove the tests actually work by breaking one.

Step 4 — introduce a bad row. Insert a duplicate rental_id into the source so the unique test should catch it:

terminal
# duplicate an existing rental_id straight into the source table
docker compose exec -T postgres psql -U postgres -c \
  "INSERT INTO rentals (rental_id, customer_id, status, hours, hourly_rate)
   SELECT rental_id, customer_id, status, hours, hourly_rate
   FROM rentals ORDER BY rental_id LIMIT 1;"

dbt build                   # rebuild models, then run their tests
✓ You should see — a FAILING test

The run goes red. Among the output you'll see something like:

FAIL 1 unique_fct_rentals_rental_id
  Got 1 result, configured to fail if != 0
Done. PASS=5 WARN=0 ERROR=1 SKIP=0 TOTAL=6

This is the moment of value: a machine caught a duplicate that would have double-counted revenue, and it caught it before the number reached anyone. In CI, this exact failure would have blocked the pull request.

Step 5 — fix it and confirm green. Remove the duplicate and re-run:

terminal
# delete the duplicate, keeping one copy of each rental_id
docker compose exec -T postgres psql -U postgres -c \
  "DELETE FROM rentals a USING rentals b
   WHERE a.ctid < b.ctid AND a.rental_id = b.rental_id;"

dbt build                   # rebuild + re-test
✓ You should see — back to green

Done. PASS=6 WARN=0 ERROR=0 SKIP=0 TOTAL=6. The duplicate is gone, the unique test passes again, and you've watched a single rule go fail→fix→pass. That red-to-green loop is exactly what runs on every commit once these tests live in CI — your platform now defends itself.

Wrapping up the craft

Take a moment, because you just finished something substantial. Look back at what's now sitting in your mini-griddp repo:

MODELED ──▶ INGESTED ──▶ TRANSFORMED ──▶ STORED ──▶ STREAMED ──▶ ORCHESTRATED ──▶ TESTED ch 01–04 ch 05–06 ch 07 ch 08 ch 09 ch 10 ch 11 relational ingestion + dbt lakehouse Redpanda / Dagster quality & dimensional change-data- Kafka testing SCD / medallion capture

That is not a toy. It's every component of a real data platform — the same shape as systems running in production at companies you've heard of, just scaled to your laptop. You designed the data's shape, got it in reliably, cleaned it with version-controlled SQL, stored it in open formats, handled it as both batch and stream, scheduled it to run on its own, and proved it correct. The craft is no longer abstract; it's in your repo and it works.

Two courses remain, and they change register. Course 5 (Tooling) hardens the professional workflow around these components — environments, packaging, CI/CD, secrets, the engineering hygiene that turns "it runs on my machine" into "it runs reliably for a team." Course 6 (Capstone) then assembles everything you've built into one coherent, end-to-end system you can put your name on.

Commit your work

Before you leave the craft, commit the test suite to mini-griddp with a clear message — git commit -m "Add data quality tests to marketplace project". A repo with tests is a repo that signals seriousness. This is the work that makes a portfolio convincing.

✓ Check yourself

  • Can you name the six dimensions of data quality, and give an incident each one catches?
  • What's the difference between a data test and a unit test — which one would catch a logic bug on a day when the data is perfectly clean?
  • Why do tests and observability complement each other rather than compete?
  • What does a data contract do that a downstream test can't, and why is catching a problem "to the left" cheaper?
Exercise — Guard revenue and freshness (15 minutes)

Two assertions every revenue pipeline should have. (1) Write a singular test that fails if any rental has negative revenue. (2) Add a freshness check that fails if no rentals data loaded today. Then prove each one by introducing a violation, running it, and fixing it.

tests/assert_revenue_non_negative.sql
-- (1) Passes only if zero rows come back.
select rental_id, revenue
from {{ ref('fct_rentals') }}
where revenue < 0
models/staging/_sources.yml
# (2) Fail if the newest load is more than a day old.
sources:
  - name: marketplace
    tables:
      - name: rentals
        loaded_at_field: loaded_at
        freshness:
          error_after: {count: 24, period: hour}   # nothing loaded today -> ERROR
terminal
# prove the revenue test catches a bad value
docker compose exec -T postgres psql -U postgres -c \
  "UPDATE rentals SET hourly_rate = -5 WHERE rental_id = (SELECT min(rental_id) FROM rentals);"
dbt build       # -> assert_revenue_non_negative FAILs (1 result)

# fix it and re-run
docker compose exec -T postgres psql -U postgres -c \
  "UPDATE rentals SET hourly_rate = 5 WHERE hourly_rate < 0;"
dbt build       # -> PASS again

# prove freshness: an old loaded_at trips the check
dbt source freshness   # ERROR if loaded_at is older than 24h

If you wrote the negative-revenue query as "select the rows that violate the rule" without being told to, the core instinct of data testing has landed: a test is a query for the bad rows, and clean means none. That single idea generalizes to every assertion you'll ever write.

Next

That's a wrap on Course 4 — The DE Craft. You've built every component of a data platform with your own hands and proven it correct. Genuinely: well done. The next stop is Course 5 — Tooling & the Modern Stack, which hardens the professional workflow around everything you've built — and it's ready now. → Continue to Tooling