learn-analytics-engineering-with-phoebe / Builder session 7 of 10
Learn Analytics Engineering with Phoebe · Builder track · Session 7 of 10

The break-a-model lab

Tests are the only reason anyone should trust a warehouse that changes every day. This session proves it the hard way: pick a breakage - a retried load, a widened column, a key that stops resolving, a definition changed without a rename - and the lab applies it as real SQL, then runs the project's own 100 compiled dbt tests against the damage. With no tests, nothing is caught. With generic tests, four of six. With the five hand-written assertions as well, all six. The two the generic tests miss are the two where the shape stayed perfect and the meaning moved.

🟠 Builder track Analytics engineers · analysts · DE Signature lab · real DuckDB, real dbt tests 45 min
0-3 · Welcome 3-14 · What a dbt test actually is 14-40 · The lab: break it, run the suite, read the failures 40-45 · Q&A
Part 0

Why tests are the whole argument

Hand-written warehouse SQL is not untested because analysts are careless. It is untested because there was never anywhere to put a test. dbt's real contribution is not Jinja or the DAG - it is that a test is a file next to the model, it runs on every build, and a failure stops the build. Sessions b3 to b6 built the project. This session is about the part that makes it safe to change.

Live - presented in session Self-study - read after class ▶ Live lab - real DuckDB, real dbt tests Sources covered
★ What you walk out with today A working definition of generic versus singular tests, the four generic tests that earn their keep on almost every model, the two failure classes generic tests structurally cannot catch, and a measured answer to "how much does a test suite actually buy us".
Part 1 · covers dbt testing documentation

A dbt test is a query that must return nothing 6 min live

That is the entire mechanism, and its simplicity is why it works. Write a query that selects the rows that should not exist. If it returns rows, the test failed and the build stops. No framework, no assertions library, nothing to learn beyond SQL you already write.

Generic · 95 in this project One line in schema.yml, reused everywhere unique · not_null · relationships · accepted_values plus two written for this project: dbt_positive_value, unique_combination (grain enforcement) Catches: shape errors duplicates, NULLs, orphan keys, undeclared values Singular · 5 in this project A .sql file in tests/, one assertion each reconciles to source · net = gross - discount unknown-member share · mart agrees with fact payments never fan out revenue Catches: meaning errors a definition that moved, a source edited later Generic tests are cheap enough to add without thinking, and they catch the errors that have a shape. Singular tests cost a few minutes of thought each, and they are the only thing that catches an error whose shape is perfect - which the lab below demonstrates twice.
🔍 Click to zoom - two kinds of test, two classes of failure
LiveThe four generic tests worth putting on almost everything3 min▶
  • unique and not_null on every key. Together they are the grain claim, enforced. If order_item_sk is not unique, every SUM in the project is wrong and nothing else you test matters.
  • relationships on every foreign key. This is the test that catches a dimension lookup that stopped resolving - the failure that makes inner-join reports quietly lose rows.
  • accepted_values on every enum-ish column. Statuses, tiers, categories, bands. A new value appearing upstream is normal; a new value appearing upstream while every dashboard filters on the old list is a silent exclusion.

Two more are written into this project as custom generic tests, because they came up more than twice: dbt_positive_value for quantities and prices, and unique_combination for composite grain like (order_id, line_no). Both live in macros/ and are one line each to use.

The test budget question "How many tests should we have?" is the wrong question. The right one: for every model, can you name the failure that would embarrass you most, and is there a test for it? This project ends up at 100 tests over 23 models without anyone counting.
Self-studyWhere singular tests come from3 min read▶

Every singular test in tests/ started as an incident somewhere, and each one is a query you could have written by hand at any point in the last twenty years. What dbt adds is that it now runs on every build:

  • assert_star_reconciles_to_source.sql - does settled revenue in the fact still equal the source, to the cent? The single most valuable assertion in the project, and the one auditors ask for.
  • assert_net_amount_equals_its_parts.sql - a derived measure that drifted from its own definition means the model is internally inconsistent.
  • assert_mart_agrees_with_fact.sql - a mart that disagrees with the fact it aggregates is a second source of truth, which is worse than no mart.
  • assert_no_fact_rows_lost_to_unknown_members.sql - unknown members keep broken lookups countable; this fires if their share climbs past 1%.
  • assert_payments_never_fan_out_revenue.sql - joining the payments model must not change revenue. The fan-out guard, as a test.
Signature lab · 20 min · everyone builds

Break it, then run the suite ★ real DuckDB, real dbt tests

Each run builds the whole project from Bazaar's raw schema using the SQL dbt compiled, applies your chosen breakage as real DML, then runs whichever tests you have switched on. A dbt test fails when its query returns rows, so what you see below is the same pass or fail CI would report.

Start with "No tests". Break something, watch the build succeed anyway, and read what the numbers did. This is the state a hand-written SQL estate is in.

Switch to generic tests. Re-run the same breakage. Read which test fired and how many rows offended it - that row count is what a reviewer would see in CI.

Now try "A metric definition changes silently" with generic tests on. Everything passes. The revenue number is 3.5% higher and the shape is perfect. Then add the singular tests.

✗ Generic tests only revenue reads 3.5% high the shape is perfect every generic test passes ✓ Add the singular test assert_star_reconciles_to_source checks the fact against the source mismatch found, build fails A definition drift is not a shape error - it takes a test somebody sat down and thought about.
🔍 Click to zoom - the shape can be perfect while the number is still wrong

Finish with "Score all 6 breakages" for the full matrix: 0 of 6, then 4 of 6, then 6 of 6.

Real world

The middle column of that scorecard is the whole lesson. Teams add generic tests quickly, because they are one line and feel like progress - and they genuinely catch the shape errors. Then a definition changes without a rename, or somebody backfills a source after the nightly build, and every test passes while the number is wrong. Those two are not edge cases; they are the two most common ways a mature warehouse loses trust. They need an assertion somebody sat down and thought about, which is what the five files in tests/ are.

Six scripted breakages, three levels of safeguard No tests 0 of 6 caught Generic tests only 4 of 6 caught Generic + singular tests 6 of 6 caught Generic tests catch shape errors; the last two breakages need a singular test somebody wrote on purpose.
🔍 Click to zoom - generic tests get you to 4 of 6, singular tests close the rest
Demo 2 of 2

Your turn: read and write the tests themselves ★ 8 min · build your own

The lab runs them; now look at what it is running. These editors are the same DuckDB engine, seeded with the built project.

LiveQ1 · Run a singular test by hand, and make it fail4 min▶

This is tests/assert_star_reconciles_to_source.sql, exactly as dbt compiled it. Zero rows means pass. Uncomment the DELETE to make it fail, and notice the test needs no change to catch it.

-- DELETE FROM raw.order_items WHERE order_id < 30;

with fact as (
    select round(sum(net_amount), 2) as amount from main_marts.fct_order_items
),
source as (
    select round(sum(quantity * unit_price - discount_amt), 2) as amount
    from raw.order_items
)
select fact.amount as fact_amount, source.amount as source_amount
from fact cross join source
where abs(fact.amount - source.amount) > 0.01;
LiveQ2 · Write the test this project is missing4 min▶

Nothing currently asserts that a cart's conversion flag agrees with whether it has an order id. Write it: select the rows that should not exist.

select cart_id, order_id, is_converted
from main_marts.fct_carts
where (order_id is null and is_converted)
   or (order_id is not null and not is_converted);

Zero rows, so the model is consistent today. Saved as a file in tests/, it stays consistent tomorrow - which is the only difference between a query and a test.

Self-studyQ3 · Which tests would you delete?3 min▶

No SQL. A hundred tests on twenty-three models sounds thorough, and some of them are noise: a not_null on a column that is part of a unique key, an accepted_values on a column the business changes monthly, a relationships test between two models one owner controls end to end.

Noisy tests are worse than missing ones, because a suite that cries wolf gets skipped, and a skipped suite is a suite you do not have. The question to ask of each test: if this fails at 3am, would anybody act on it? If not, delete it - and if the answer is "we would investigate but probably ignore it", make it a warning rather than an error (severity: warn in the config), which is exactly what that setting is for.

Homework

Try it yourself - this week ◐ 25-35 min total

Source material

Sources covered

This page covers:

✓dbt testing documentation - generic tests, singular tests, custom generic tests, severityPart 1 + the lab · running the project's own 100 compiled tests
✓Data-quality practice - reconciliation, additivity, referential integrity, grain assertionsSelf-study · the five singular tests, each a real incident class
◐dbt packages that ship test libraries (dbt_utils, dbt_expectations)Named only · this project deliberately has zero package dependencies
◐Observability and alerting on test failuresOut of scope by design - that is learn-dataops
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · What makes a dbt test fail?

That is the whole mechanism, and its simplicity is why tests can live next to models and run on every build. It also means any SQL you can write is a test you can write - including the five in tests/.

2 · A mart's WHERE clause loses its settled-money filter. The column is still net_revenue, every row is well-formed, and revenue is 3.5% higher. Which test catches it?

The shape is perfect: no nulls, no duplicates, no orphan keys. Only an assertion about MEANING catches a definition that moved, which is why this project ships five hand-written ones alongside its 95 generic tests.

3 · Why is a noisy test worse than a missing one?

The test to keep is the one somebody would act on at 3am. For everything else there is severity: warn, which reports without failing the build - and using it honestly is what keeps the errors meaningful.

Builder session 7 cheat sheet · pin this

What a test isA query that must return zero rows. That is the entire mechanism.
GenericOne line in schema.yml: unique, not_null, relationships, accepted_values. 95 here.
Custom genericAny test written twice belongs in macros/. This project adds dbt_positive_value and unique_combination.
SingularA .sql file in tests/, one assertion. 5 here, each a real incident class.
Shape vs meaningGeneric catches shape. Only singular catches a definition that moved or a source edited later.
The measured answer0 of 6 breakages caught with no tests, 4 of 6 with generic, 6 of 6 with both.
Noise costs trustKeep the tests somebody would act on. Everything else is severity: warn.
Grain as a testunique_combination on (order_id, line_no) is the grain claim, enforced. Next: b8, the marts.