Creating a Data Model w/ dbt: Tests (Part 3/3)
Jul 16, 2025Building the tables is only the first step, because data and the custom logic you write on top of it are rarely perfect. The cheapest useful safety net in dbt is a pair of generic tests, unique and not_null, declared on the surrogate key of every warehouse model. You write them in the schema YAML file that already sits beside your models, run dbt test, and dbt compiles a small SQL query per test, sends it to the warehouse, and tells you what failed. Once that is in place, dbt build replaces running dbt run and dbt test separately by deploying and testing in one pass. That is enough to catch the obvious problems a new star schema introduces without overcomplicating things on day one.
Key takeaways
- Test the keys first.
uniqueandnot_nullon every surrogate key catch most of what goes wrong in a new model. - Tests live in the schema YAML beside the models, not in separate files. That is what those YAML files were waiting for.
- You do not need to list every column. Listing one key column per model is more useful than exhaustive coverage nobody reads.
- A generic test compiles to a SQL query that counts the rows breaking the rule. Zero rows passes, anything else fails.
dbt buildruns and tests in one command, in dependency order, instead of two separate passes.- A failing test stops the run and names the test and the model, which is what makes this worth wiring into automation.
Why tests before you push the warehouse models
This is the last of three parts. Part 1 created the dimensions and Part 2 created the fact table and the joins between them.
Those joins are custom logic we wrote by hand. Custom logic is exactly where duplicates and missing keys sneak in, and nobody notices until a number looks wrong in a report.
So one more step before the branch gets merged. A sanity check on the primary keys, which here means the surrogate keys generated in the last two parts.
dbt has this built in. If you use a different tool, the idea travels: write a query that returns the bad rows and fail when it returns any.
The risk is quiet, not loud
A broken join rarely errors. It returns rows, just the wrong number of them, and the model builds green.
With a small practice dataset you can scroll through the output and see that. With real volumes you cannot, so the check has to be something the pipeline performs for you.
The built-in generic tests
A generic test is a reusable test you declare by name rather than write as SQL. dbt ships with four of them out of the box:
unique: no duplicate values in the column.not_null: no missing values in the column.accepted_values: every value comes from a list you supply.relationships: every value exists in a column of another model.
The first two are what I care about here. Together they assert that a surrogate key is a real primary key, which is the single most valuable claim you can make about a warehouse table.
You declare them by adding a tests block to a column. dbt reads that, compiles a small piece of SQL for each entry, and runs it against your database.
No test framework to install and nothing to configure. The whole thing is a few lines of YAML in a file you already have.
Where the YAML goes
Back in Part 1 we created schema YAML files alongside the models and left them mostly empty. This is what they were for.
Add one per subfolder, so the tests sit next to the models they describe:
models/warehouse/dimensions/_dimensions__models.ymlmodels/warehouse/facts/_facts__models.yml
We are not testing the staging models here. You could, and on a larger project you probably should, but the keys we just invented are the higher risk.
What the file looks like
Same shape as before, with column level detail added underneath each model.
version: 2
models:
- name: dim_teams
description: One row per team.
columns:
- name: team_sk
description: Surrogate key for a team record.
tests:
- unique
- not_null
- name: dim_games
columns:
- name: game_sk
tests:
- unique
- not_null
- name: dim_calendar_dates
columns:
- name: calendar_date_sk
tests:
- unique
- not_null
You could technically list every column and attach tests everywhere. It is not necessary right now and probably not helpful, so the only column listed is the surrogate key.
The fact table file
The fact model gets the same treatment, pointed at the key we generated in Part 2.
version: 2
models:
- name: fct_games_played
description: One row per game played.
columns:
- name: game_played_sk
tests:
- unique
- not_null
Two tests, one column, one model. That is the baseline, and it is deliberately small so it actually gets written.
Run the tests
dbt test
dbt looks for any YAML with a tests block and runs what it finds. It is fast, and the output is one line per test with a pass or fail.
You can scope it the same way you scope a run, which is handy once the project grows.
dbt test -s warehouse.facts
Read the compiled SQL
Each test becomes a real query. Look under target/compiled/ and you can open the exact SQL that was sent to the database.
select team_sk as unique_field,
count(*) as n_records
from analytics.dbt_dev.dim_teams
where team_sk is not null
group by team_sk
having count(*) > 1
That is the whole mechanism. The test counts records that fit the failure condition, and the result decides pass or fail.
Worth opening once. Seeing that a test is just a query removes the mystery and makes it obvious how to write your own later.
Use dbt build instead of two commands
Most of the time you want to deploy and then immediately test. Running dbt run and then dbt test every time gets old.
dbt build -s warehouse
One command, both jobs. dbt deploys each model and tests it, in dependency order, so a model whose tests fail does not quietly feed the things downstream of it.
For the warehouse folder that means three dimensions and one fact built and checked in a single pass.
It is also the command I point at a schedule. One entry point, one exit code, and the orchestration layer does not need to know how many models are behind it.
What a failure looks like
It is worth seeing a red result on purpose. Hard code one of the key columns to null, rebuild, and the not_null test fails on every row.
dbt build -s fct_games_played
The output names the failing test and the model it belongs to, and points you at the query so you can inspect the offending rows.
Undo the change, rebuild, and it is green again. The reason to do this once is automation: in a scheduled run that failure is how you find out early, instead of hearing it from a stakeholder.
There is a second reason to break it on purpose. You learn where the failure shows up in the output, which is the thing you will be hunting for at eight in the morning.
Commit and merge
One more commit, something like add data quality tests, and the branch is ready.
What is in the development environment now is a complete first pass. Staging models as views, a star schema of three dimensions and one fact table, and a quality check on every key in it.
From here the marts layer gets built on top, and it inherits both the joins and the guarantees underneath them.
Key terms
Generic test
A reusable dbt test you attach to a column by name in YAML, which dbt expands into a SQL query at run time.
unique test
A generic test that fails if a column contains any duplicate value, used to assert that a surrogate key identifies exactly one row.
not_null test
A generic test that fails if a column contains any null, used alongside unique to make a column behave as a primary key.
Compiled test SQL
The query dbt writes to the target directory for each test, which counts the rows that break the rule and is what actually runs in the warehouse.
dbt build
The command that materializes a model and runs its tests in one pass, in dependency order, instead of a separate dbt run and dbt test.
Common questions
What tests should I add to a new dbt model?
Start with unique and not_null on the primary or surrogate key. That one pair catches duplicate rows and missing keys, which is most of what goes wrong when you first write joins.
Where do dbt tests go?
In the schema YAML file that sits beside the models, under a tests block on the column. One YAML per model subfolder keeps the tests next to what they describe.
What is the difference between dbt test and dbt build?
dbt test only runs tests against models that already exist. dbt build materializes each model and tests it in the same pass, following dependency order, so a failure stops the chain early.
Do I need to test every column?
No, and trying to usually produces noise nobody reads. Test the keys, then add targeted tests where a specific assumption matters, such as an accepted list of status values.
What happens when a dbt test fails?
The run reports a failure naming the test and the model, and the compiled query is available so you can pull the offending rows. In an automated schedule that is the signal that something upstream changed.
Should I test staging models too?
You can. On a small project the warehouse keys are the higher risk because you generated them yourself. As the project grows, source and staging tests are worth adding so bad data is caught earlier.
Related reading
- Creating a Data Model w/ dbt: Dimensions (Part 1/3)
- Creating a Data Model w/ dbt: Facts (Part 2/3)
- The "Key" To Building A Reliable Data Model
- How to Create a 3 Layer Data Model Pipeline
Final takeaway
Two tests on one column per model is a small amount of work for the amount of trust it buys. Every warehouse layer I help a team stand up gets these before the branch merges, because the first duplicate key is always cheaper to find in a test than in a dashboard.
Additional Free Resources
Starter Guides & Checklists
Explore additional free resources built on the same patterns I use with real clients so you can build your own with structure and confidence. Topics include data architecture, modeling and more specifically for small data teams.