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.
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.
LiveThe four generic tests worth putting on almost everything3 min▶
uniqueandnot_nullon every key. Together they are the grain claim, enforced. Iforder_item_skis not unique, every SUM in the project is wrong and nothing else you test matters.relationshipson 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_valueson 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.
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.
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.
Finish with "Score all 6 breakages" for the full matrix: 0 of 6, then 4 of 6, then 6 of 6.
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.
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.
Try it yourself - this week ◐ 25-35 min total
- Run the lab's full matrix once and screenshot the three scoreboxes. That image is the most persuasive test-budget argument you will ever put in front of a stakeholder.
- On your own project, count the models with zero tests. Add
uniqueandnot_nullon their keys - that alone usually moves the needle more than anything clever. - Pick the metric your business would most hate to be wrong about, and write one singular test that would catch it silently changing. Not a shape test - a meaning test.
- Find one test in your suite that has never failed and that nobody would act on. Delete it or downgrade it to a warning, and note why.
- Break your own project on a branch: change a definition without renaming anything, run the suite, and see whether it survives. Bring the result to session b8.
Sources covered
This page covers:
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.