CertSafari
    DBT Analytics Engineering· Lessons

    Domain 5 · Lesson 25/31

    dbt tests: generic, singular, custom generic and unit tests

    Using generic, singular, custom, custom generic, and unit tests on a wide variety of models and sources

    18 min read
    3.23% of exam
    5 sources
    Published 12 Oct 2026
    Docs as of 12 Oct 2026

    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 %}

    Sources12

    2.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.

    All four built-in generic tests on an orders modelyaml
    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
    What each built-in test asserts in the orders example, and which inputs it needs
    TestInputs under argumentsAssertion
    uniquenoneorder_id has no duplicate values
    not_nullnoneorder_id contains no nulls
    accepted_valuesvaluesstatus is one of 'placed', 'shipped', 'completed', 'returned'
    relationshipsto, fieldevery 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).

    The data_tests property on a source tableyaml
    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?

    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?

    Sources13

    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.

    A singular test saved as assert_total_payment_amount_is_positive.sqlsql
    -- 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 < 0

    Three 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?

    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.

    A custom generic test whose config() block sets a default severitysql
    {% 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.

    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?

    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.

    A unit test for the is_valid_email_address logic in dim_customersyaml
    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).

    Where each kind of test is defined, and what it checks
    KindDefined inChecks
    Singular data testOne .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 .ymlA reusable assertion parameterized by model and column_name
    Unit testunit_tests: in a .yml file under model-pathsSQL 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?

    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)

    Sources5

    Exam traps

    Each one states something that sounds right. Open it to see what is actually true.

    1. 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/.

      Covered in Unit tests: mocked inputs and expected output

    2. 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. 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. 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. 1.
      “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. 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
    3. 5.
      “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

    Spotted a mistake, or was something unclear? Tell us.