What you will be able to do
- Explain why every dbt data test is a select statement that returns failing rows, and when such a test passes
- Apply the four built-in generic tests to model and source columns in YAML, putting test inputs under arguments and framework options under config
- Write a singular test as a one-off SQL assertion in the tests/ directory
- Build a custom generic test from a test block, give it defaults with config(), and override a built-in test
- Define a unit test with mocked given inputs and expected output rows, put its YAML in the right place, and know where unit tests cannot be used
Key concept
A data test is a query for failing rows — Every dbt data test, whether built-in, singular or custom, is a select that looks for rows that break the assertion. If it returns zero rows, the test passes. If it returns any, those rows are the failures.
1.Every data test is a query for failing rows
dbt has two families of tests, and they answer different questions. Data tests are assertions about resources dbt has already built or reads from: models, sources, seeds and snapshots. They ask whether the data is right. Unit tests check a model's SQL logic against small, hand-written inputs before the model is built. They ask whether the code is right. Most of this page is about data tests, which come in three forms (built-in generic, singular and custom generic). Unit tests come last.
A data test works the opposite way from what many newcomers expect. It is a select statement that hunts for the records that disprove your claim. A uniqueness test selects duplicates, and a not-null test selects nulls. When dbt test runs, a query that comes back empty is a pass. Every returned row is a failure. Once you hold onto this, everything else on the page follows from it. A singular test is a hand-written query for bad rows, and a generic test is the same kind of query with the model and column left as parameters.
The YAML key changed when unit tests arrived. What used to be tests: is now data_tests:, renamed in dbt v1.8 so the two families can be told apart. The old tests: key still works as an alias. A single resource, such as one model, cannot use both tests: and data_tests: at the same time.
Checkpoint 1 of 8· Fill the gap
This is the test block behind the built-in not_null test. Which token completes the WHERE clause so the query returns the failing rows?
{% test not_null(model, column_name) %}
select *
from {{ model }}
where {{ column_name }} is ?
{% endtest %}The query has to return the rows that break the assertion. For not_null, those are the rows where the column is null. A pass means no such rows came back.
Source: docs.getdbt.com2.The four built-in generic tests on models and sources
dbt ships with four generic tests: unique, not_null, accepted_values and relationships. They are called generic because each one is a parameterized query. You attach it by name in a .yml file, and dbt fills in the model and column for you. The same unique definition can therefore guard a hundred columns across fifty models. That reuse is why generic tests should make up most of a project's test suite.
models:
- name: orders
columns:
- name: order_id
data_tests:
- unique
- not_null
- name: status
data_tests:
- accepted_values:
arguments: # available in v1.10.5 and higher. Older versions can set the <argument_name> as the top-level property.
values: ['placed', 'shipped', 'completed', 'returned']
- name: customer_id
data_tests:
- relationships:
arguments:
to: ref('customers')
field: id| Test | Inputs under arguments | Assertion |
|---|---|---|
| unique | none | order_id has no duplicate values |
| not_null | none | order_id contains no nulls |
| accepted_values | values | status is one of 'placed', 'shipped', 'completed', 'returned' |
| relationships | to, field | every customer_id exists as an id in ref('customers'), also called referential integrity |
Two kinds of setting sit next to a test name, and they go in different places. Inputs to the test's own query, such as values, to and field, go under arguments:. That nesting is available from v1.10.5, and older versions put these inputs at the top level. Framework options such as severity, where and store_failures go under config:. unique and not_null need no inputs, because dbt already knows the model and column from where the test sits in the YAML.
Generic tests are not limited to models. The same data_tests: property works on sources, seeds and snapshots, at both table level and column level. Putting not_null or unique on a source column catches a broken raw table before any model reads from it. Analyses are the one resource type where it does not work. Deciding which assumptions are worth encoding is covered in [testing assumptions for models and sources](lesson:5.2).
sources:
- name: <source_name>
tables:
- name: <table_name>
data_tests:
- <test_name>
- <test_name>:
arguments: # available in v1.10.5 and higher. Older versions can set the <argument_name> as the top-level property.
<argument_name>: <argument_value>
config:
<test_config>: <config-value>Checkpoint 2 of 8· Check yourself
On dbt v1.10.5, you add accepted_values to a status column and want it to warn rather than error. Where do the values list and the severity setting go?
values is an input to the test's query, so it goes under arguments:. severity is a framework option, so it goes under config:.
“Use arguments: for inputs to the test macro (for example, values, to, and field).”Source: docs.getdbt.com
Checkpoint 3 of 8· Exam question
An analytics engineer on the orders team needs one assertion that checks a case specific to `fct_orders`: the sum of refunds should never exceed the sum of payments for a single `order_id`. The check will not be reused on any other model, and it needs a multi-table join that isn't expressible with `accepted_values` or `relationships`. Which testing approach fits this requirement?
Correct answer: A — Write a singular test as a .sql file under tests/ that joins fct_orders to the payment and refund tables and returns rows where refunds exceed payments.
- A. A standalone SQL file under the tests directory is exactly the right shape for a one-off, non-reusable assertion that needs a custom multi-table join. It runs automatically with dbt test without any YAML wiring, which matches a check that will never be applied to another model.
- B. Building a parameterized macro is unnecessary engineering for a check that will never be reused elsewhere; the model and column_name signature is designed for assertions applied across many models, not a one-off join across payments and refunds.
- C. The built-in relationships test only verifies that values in one column exist in a referenced column of another table; it has no mechanism for comparing aggregated sums across tables, so it cannot express this comparison.
- D. Unit tests validate a model's SQL transformation logic against static mocked input rows before deployment; they are not designed to assert aggregate relationships across live production tables the way this check requires.
3.Singular tests for one-off assertions
Some rules apply to only one model, and no reusable test captures them. A singular test is the tool for those cases. You write the exact SQL that returns the bad rows and save it as a .sql file in the tests/ directory. Each file holds one select statement and defines one test, and the file name becomes the test name. Jinja works here as it does in models, so ref and source are both available.
-- Refunds have a negative amount, so the total amount should always be >= 0.
-- Therefore return records where total_amount < 0 to make the test fail.
select
order_id,
sum(amount) as total_amount
from {{ ref('fct_payments') }}
group by 1
having total_amount < 0Three details cause most broken singular tests:
- Semicolons. A trailing semicolon can make the test fail, so leave it off.
- Wiring it up. You don't attach the test anywhere. dbt test picks up every file in the tests directory automatically. Listing a singular test under a model's data_tests: causes an error, because dbt does not treat it as a generic test.
- Documentation. To give it a description, add a .yml file in the tests directory, such as tests/schema.yml, with a top-level data_tests: entry that names the test.
Watch for the signal that a singular test has outgrown its form. If you keep copying the same query and changing only the model or column name, the test isn't singular any more. Turn it into a generic test.
Checkpoint 4 of 8· Check yourself
A teammate saves assert_total_payment_amount_is_positive.sql in tests/ and also lists it under data_tests: for fct_payments in models/schema.yml. What happens?
Singular tests run automatically from the tests directory. Referencing one in model YAML causes an error because dbt does not treat it as a generic test or a macro.
“as they are not treated as generic tests or macros, and doing so will result in an error.”Source: docs.getdbt.com
Sources1
4.Writing custom generic tests
A custom generic test is a test block that you write yourself. It is shaped like a macro and named with {% test <name>(...) %}. You can save the SQL file in either of two places. One is tests/generic/, a special subfolder of your test paths. The other is macros/, which used to be the only option and is still convenient when the test depends on complex macro logic in the same file. Before writing one, check packages such as dbt-utils and dbt-expectations, which already define many generic tests (see [dbt packages](lesson:1.7)).
The signature determines how the test is called. Every generic test takes one or both of the standard arguments, model and column_name, and dbt supplies both from the YAML context. The argument is always called model, even when the test sits on a source, seed or snapshot, and it is templated out to that resource's relation name. Any extra inputs are added to the signature after the standard ones. The built-in relationships test, for example, is defined as relationships(model, column_name, field, to), and you pass field and to under arguments: in YAML.
{% test warn_if_odd(model, column_name) %}
{{ config(severity = 'warn') }}
select *
from {{ model }}
where ({{ column_name }} % 2) = 1
{% endtest %}A config() block inside the test definition sets defaults for every use of the test. Here, every column tested with warn_if_odd warns instead of erroring, unless one instance overrides the setting in its own .yml properties. The same mechanism lets you replace a built-in test. If you add a test block named unique to your project, dbt uses your version instead of the global one.
Custom tests can be documented too. List the test under macros: in a YAML file, with a description for the test and for each argument. Use the macro's name, which is the test name with a test_ prefix: a test block called not_empty_string is documented as test_not_empty_string.
Checkpoint 5 of 8· Match them up
Match each piece of a custom generic test to what it does
Tap a term, then the definition that fits it.
Generic tests live in tests/generic/ or macros/. model is filled in from context for any resource type. config() in the definition sets overridable defaults. A same-named test block takes precedence over the built-in.
“dbt will favor your version over the global implementation!”Source: docs.getdbt.com
Checkpoint 6 of 8· Exam question
A team wants to override the built-in `unique` test with project-specific logic that treats soft-deleted rows as non-existent duplicates, without renaming the test in any YAML file. Which implementation lets `data_tests: - unique` continue to work unchanged while running the new logic?
Correct answer: A — Define a `{% test unique(model, column_name) %}` block under `tests/generic/` or `macros/`; a project test block with the same name overrides the built-in test.
- A. A project-defined or package-defined test block that shares a built-in test's name takes precedence over dbt's default implementation, so every existing `data_tests: - unique` reference picks up the new soft-delete-aware logic automatically.
- B. The `where` key is a sibling of `severity` under `config`, not nested beneath it, and simply filtering rows out of the built-in query changes which rows are checked rather than redefining what counts as a duplicate.
- C. A singular test file is its own standalone assertion with its own name; it does not intercept or replace calls to the generic `unique` test referenced in other models' YAML, so those references would keep running the original logic.
- D. There is no `tests: unique: enabled` key in `dbt_project.yml`, and built-in generic tests cannot be disabled or aliased through project configuration in this way.
Sources4
5.Unit tests: mocked inputs and expected output
Data tests have a timing problem: they can only check a model after it has been built, against whatever data happens to exist. A unit test turns this around. You supply a few static input rows for each upstream ref or source, along with the rows the model should produce from them. dbt runs the model's SQL against those mocks and compares the result with what you expected, all before the full model is materialized. This supports test-driven development, where the test is written before the logic.
unit_tests:
- name: test_is_valid_email_address # this is the unique name of the test
model: dim_customers # name of the model I'm unit testing
given: # the mock data for your inputs
- input: ref('stg_customers')
rows:
- {email: cool@example.com, email_top_level_domain: example.com}
- {email: cool@unknown.com, email_top_level_domain: unknown.com}
- {email: badgmail.com, email_top_level_domain: gmail.com}
- {email: missingdot@gmailcom, email_top_level_domain: gmail.com}
- input: ref('top_level_email_domains')
rows:
- {tld: example.com}
- {tld: gmail.com}
expect: # the expected output given the inputs above
rows:
- {email: cool@example.com, is_valid_email_address: true}
- {email: cool@unknown.com, is_valid_email_address: false}
- {email: badgmail.com, is_valid_email_address: false}
- {email: missingdot@gmailcom, is_valid_email_address: false}Each input in given names one upstream ref or source, and expect holds the output rows. The rows only need the columns the logic touches. Here, four email addresses cover the edge cases: a missing @, a missing dot, and an unknown domain. Rows can be written in dict format (the default), csv or sql, either inline under rows or in a separate file named with fixture. Every ref and source the model uses has to appear as an input, or compilation fails with "node not found". If the model joins tables, the tables must be aliased.
Unit tests have firm limits. They work only on SQL models, and only on models in the current project. Models that use the materialized view materialization, recursive SQL or introspective queries cannot be unit tested. If a model has several versions, the unit test runs on all of them by default.
Choose what to unit test based on where bugs are likely. Good candidates include:
- Regex, date math, window functions and case when statements with many branches
- Logic that has had bugs reported before
- Edge cases that don't yet appear in real data
- Logic you're about to refactor
- High-criticality models, such as public, contracted models or models directly upstream of an exposure
A plain min() is a poor candidate, because the warehouse already tests it extensively. Since the inputs are static, running unit tests in production wastes compute, so dbt Labs recommends running them only in development and CI. dbt test --select "test_type:unit" runs them on their own. Where they fit in a pipeline is covered in [testing steps in the workflow](lesson:5.3).
| Kind | Defined in | Checks |
|---|---|---|
| Singular data test | One .sql file in tests/ | A one-off assertion about built data |
| Generic data test (built-in or custom) | A test block in tests/generic/ or macros/, applied by name in .yml | A reusable assertion parameterized by model and column_name |
| Unit test | unit_tests: in a .yml file under model-paths | SQL logic, by comparing the model's output on given rows with the expect rows |
Checkpoint 7 of 8· Check yourself
Your project keeps CSV fixtures in tests/fixtures/. Where must the unit_tests: YAML that uses them live?
Unit tests are discovered only from model-paths. tests/ is reserved for data tests, even though fixture files go in a fixtures subdirectory of a test path.
“define them alongside your models in a .yml file under your model-paths”Source: docs.getdbt.com
Checkpoint 8 of 8· Exam question
An analytics engineer is adding unit tests to `dim_customers`, a SQL model with complex `case when` logic that classifies customer segments, and wants to validate that logic against fixed input rows rather than live warehouse data before deploying. Which statements correctly describe how to build these unit tests? (Select all that apply.)(Select 3)
Correct answers: A, B, D — Define the test under `unit_tests:` in a `.yml` file within the `models/` directory, using `model: dim_customers` to bind the test to that model.; Supply mock input rows for every `ref()` or `source()` the model reads via the `given` key, using `format: dict`, `csv`, or `sql` to shape the rows.; Assert the expected output using the `expect` key with the same row format as `given`, comparing the model's transformed columns against the fixture values.
- A. Unit tests live in YAML files under models/, not tests/, and the model key is how a unit test declares which model's SQL logic it is validating.
- B. Every ref() or source() the model reads must be mocked as an input under given, and dict, csv, or sql are the documented formats for shaping those fixture rows.
- C. Materialized views are explicitly unsupported for unit testing; the materialization has no bearing on whether static fixture rows can be substituted, so this step would fail rather than enable the test.
- D. The expect key defines the fixture rows the model's compiled logic must produce, and comparing it against the given inputs is exactly how the unit test framework validates the case-when classification logic.
- E. Unit tests are selected with test_type:unit, not test_type:singular, and they belong in the models/ directory rather than tests/, so this placement and selector would not surface the new tests.
Sources5
Exam traps
Each one states something that sounds right. Open it to see what is actually true.
1.Unit tests are tests, so their YAML belongs in the tests/ directory.Why is that wrong?
Unit test YAML is discovered from model-paths and must live there, for example in models/. The tests/ directory is reserved for data tests. Only fixture files go in tests/fixtures/.
2.A singular test has to be listed under the model's data_tests: before it will run.Why is that wrong?
Singular tests in the tests directory run automatically with dbt test. Referencing one in model YAML causes an error.
Covered in Singular tests for one-off assertions
3.severity is a test input, so it goes under arguments: along with values, to and field.Why is that wrong?
arguments: holds inputs to the test's query. severity, where and store_failures are framework options and go under config:.
Covered in The four built-in generic tests on models and sources
4.A custom generic test meant for sources needs a source argument instead of model.Why is that wrong?
The first standard argument is always named model, whatever the resource type, and dbt templates it to the relation name.
Covered in Writing custom generic tests
Sources
Every claim above is drawn from one of these pages, quoted as it was written on the date shown.
- 1.https://docs.getdbt.com/docs/build/data-testsOfficial docs
“Data tests return a set of failing records.”
↩︎ Every data test is a query for failing rows“you can't have a tests and a data_tests key associated with the same resource”
↩︎ Every data test is a query for failing rows“dbt ships with four generic data tests already defined: unique, not_null, accepted_values, and relationships.”
↩︎ The four built-in generic tests on models and sources“generic data tests tend to be much more common—they should make up the bulk of your dbt data testing suite”
↩︎ The four built-in generic tests on models and sources“Each .sql file contains one select statement, and it defines one data test”
↩︎ Singular tests for one-off assertions“Omit semicolons (;) at the end of the SQL statement in your singular test files, as they can cause your data test to fail.”
↩︎ Singular tests for one-off assertions“If the data test returns zero failing rows, it passes, and your assertion has been validated.”
↩︎ Key concept“Singular data tests placed in the tests directory are automatically executed when running dbt test.”
↩︎ Exam trap 2“Use config for framework options such as severity, where, and store_failures.”
↩︎ Exam trap 3“if you assert that a column is never null, the test seeks nulls.”
↩︎ Prediction“Use arguments: for inputs to the test macro (for example, values, to, and field).”
↩︎ Checkpoint“as they are not treated as generic tests or macros, and doing so will result in an error.”
↩︎ Checkpoint - 2.
“the tests: config has been renamed to data_tests:”
↩︎ Every data test is a query for failing rows - 3.
“This feature is not implemented for analyses.”
↩︎ The four built-in generic tests on models and sources - 4.
“tests/generic/: that is, a special subfolder named generic within your test paths (tests/ by default)”
↩︎ Writing custom generic tests“If your custom tests requires more than the standard arguments, include those arguments in the test signature”
↩︎ Writing custom generic tests“Values set there will set defaults for all specific instances of that generic test”
↩︎ Writing custom generic tests“add the test_ prefix to the macro name”
↩︎ Writing custom generic tests“Note that the argument is always named model, even when the resource is a source, seed, or snapshot.”
↩︎ Exam trap 4“dbt will favor your version over the global implementation!”
↩︎ Checkpoint - 5.https://docs.getdbt.com/docs/build/unit-testsOfficial docs
“validate your SQL modeling logic on a small set of static inputs before you materialize your full model”
↩︎ Unit tests: mocked inputs and expected output“Include all ref or source model references in the unit test configuration as inputs”
↩︎ Unit tests: mocked inputs and expected output“Store your fixture files in a fixtures subdirectory in any of your test paths.”
↩︎ Unit tests: mocked inputs and expected output“We currently only support unit testing SQL models.”
↩︎ Unit tests: mocked inputs and expected output“dbt Labs strongly recommends only running unit tests in development or CI environments.”
↩︎ Unit tests: mocked inputs and expected output“since these functions are tested extensively by the warehouse”
↩︎ Unit tests: mocked inputs and expected output“define them alongside your models in a .yml file under your model-paths”
↩︎ Exam trap 1“define them alongside your models in a .yml file under your model-paths”
↩︎ Checkpoint