DBTBuilding a Project

Unit Tests: Proving the Logic Before the Data Arrives

How analytics engineers turn raw warehouse tables into trusted models — pick a topic on the left and its full breakdown loads here: the mental model, the compiled SQL dbt actually issues, live runs you can drive yourself, and the failure modes that quietly ship wrong numbers.

18 min readTopics chapter readerLevel · Medium
01 · Two mechanisms, not one stricter test

A Data Test Runs on Rows You Did Not Choose

real Jinja, real macros, real adapter.dispatch, real type coercion, real compute, real cost.

4 min · Topic 1 of 10

Every test in Chapter 7 shares one shape: a compiled anti-query run against rows that already exist in the warehouse, whose returned row count decides the verdict. That means each of them can only ever judge the rows a model actually produced — which is why ep_1008’s implausible 1,980ms was a coverage fact, not a bug in the suite.

unit_tests: inverts every part of that. dbt takes the model’s real SQL, replaces every ref()/source() with a CTE built from rows you write, runs the whole thing for real, and compares the result to rows you also write. Operate two dials below before reading one more word about how.

Two dials, one data test, one unit test. Before touching either dial, predict which verdict each one moves.
Drop stg_episodes from 9 rows to 0 — does the DATA TEST verdict change?
Drop stg_episodes from 9 rows to 0 — does the UNIT TEST verdict change?
Flip the model's threshold from >= to > — does the DATA TEST verdict change?
Flip the model's threshold from >= to > — does the UNIT TEST verdict change?
rows read from your tables: 0 — queries sent: not 0A unit test never reads real table data, but it is not offline. Three sourced reasons it still needs a live warehouse connection: every direct parent (ref/source) the model uses must physically exist as a relation; a fixture's column types are read from that real relation via adapter.get_columns_in_relation — you cannot declare a fixture column's type yourself (dbt-core #11868, closed wontfix); and dbt's own docs recommend excluding unit tests from prod runs with dbt build --exclude-resource-type unit_test (or DBT_EXCLUDE_RESOURCE_TYPES in CI) specifically to save the compute they still cost.
Core mental model

A data test runs on rows you did not choose. A unit test runs on rows you did. Everything downstream of that one substitution is still real: real Jinja, real macros, real adapter.dispatch, real type coercion, real compute, real cost.

Why it matters

Confusing “the tests are green” with “nothing here connects to the warehouse” is easy precisely because the fixture data is fake while everything around it — the connection, the relations, the compute — is real. That gap between what looks fake and what is actually real is exactly what makes “where do unit tests earn their keep” in Section 8 a real question instead of a rhetorical one.

unit_tests:
A top-level YAML key in a properties file under models/ — native since dbt Core 1.8, current in 1.12.0. given supplies fixture rows per input; expect supplies the rows the compiled model should produce.
given / expect
given: a list of {input, rows} pairs, one per ref()/source() the model calls. expect: the rows (and only the columns) the model should produce from them.
No arguments: key
Chapter 7 established test arguments under arguments: and behavior under config:. unit_tests: has no arguments: key at all — it is a different schema, not a stricter version of the same one.
Still a warehouse connection
No table data is read, but a live connection and real compute are required — direct parents must physically exist as relations, and fixture column types are read from them.
--exclude-resource-type unit_test
dbt’s documented way to skip unit tests in prod and keep the compute: dbt build --exclude-resource-type unit_test, or the DBT_EXCLUDE_RESOURCE_TYPES env var for CI without touching the invocation. Applies to dbt build, dbt test, dbt list, and dbt clone alike.
Common mistake

Reading “no real rows are read” as “this runs offline.” Chapter 7’s own handoff line — “before a single real row ever reaches it” — is true about data and gets misread as true about compute. A live connection, real relations, and real queries are still required; only the row-level data is fixture-supplied.

Stubbing a dispatched macro’s adapter-specific arm — e.g. snowflake__local_day() — with overrides.macros to force a unit test through. overrides.macros only replaces macros the model calls by name; a dispatched call like local_day() (Chapter 6) resolves to its adapter arm at compile time, which overrides.macros cannot see or intercept (dbt-core #9624). Reserve overrides.macros for the non-deterministic and environment-dependent — run_started_at, current_timestamp — never for the logic under test. If you are stubbing your own business macro, you have written a test of nothing.

Better habit

Before writing a unit test, name which of the two dials the defect you are worried about actually depends on — the rows in the table, or the model’s own code.

Read “unit tests connect to the warehouse” as a cost fact, not a contradiction — dbt build --exclude-resource-type unit_test (or DBT_EXCLUDE_RESOURCE_TYPES in CI) is the documented way to skip that cost in prod.

Keep Chapter 7’s arguments:/config: shape and this chapter’s given/expect shape mentally separate — reaching for the wrong one produces a compile error, not a subtle bug, so it is cheap to catch but easy to make on a first unit test.

Chapter 7, in two sentences

Every test in Chapter 7 is a compiled anti-query run against rows that already exist in the warehouse, judged purely by the row count it returns. A unit test keeps the “compiled and run for real” part and replaces the rows with fixtures you write — a different mechanism, not a stricter version of the same one.

Remember this

A data test is blind to the model’s code and sees only its rows; a unit test is blind to the table’s rows and sees only the model’s code — hold that inversion and every trap in this chapter follows from it.