Главная мысль: тест в dbt — это запрос на поиск нарушителей
Самое полезное, что можно понять про тестирование в dbt, формулируется одной фразой: тест — это SELECT, который ищет плохие строки, и который должен вернуть ноль строк. Не «правило», не «проверка свойства», не сравнение с эталоном — а буквально запрос, который материализуется и выполняется внутри вашего хранилища. Если запрос вернул 0 строк — данные чистые, тест прошёл. Если вернул N > 0 — это и есть N нарушителей, и тест падает.
Эта инверсия (мы ищем не «правильное», а «неправильное») — ключ ко всему остальному. Как только она сидит в голове, исчезает магия: и generic-тесты, и singular-тесты, и даже freshness становятся вариациями одного и того же SELECT. А
Тяжёлую работу — сканирование, GROUP BY, анти-джойны — делает само хранилище: DuckDB, Postgres, Snowflake, BigQuery. dbt лишь генерирует SQL, оборачивает его в count(*) и сравнивает результат с нулём.
Generic-тесты: четыре встроенных шаблона
not_null, unique, accepted_values, relationships. Они покрывают примерно 80% базовых проверок качества.
version: 2
models:
- name: fct_orders
columns:
- name: order_id
tests:
- not_null
- unique
- name: status
tests:
- accepted_values:
values: ['pending', 'shipped', 'delivered', 'cancelled']
- name: customer_id
tests:
- not_null
- relationships:
to: ref('dim_customers')
field: customer_id
Чтобы по-настоящему понять generic-тесты, надо увидеть, во что они компилируются. dbt разворачивает каждый из них в SELECT нарушителей и кладёт результат в target/compiled/<project>/.../<test_name>.sql. Вот к чему приводит YAML выше (приблизительно, реальный текст зависит от адаптера):
-
not_nullнаorder_id:SELECT order_id FROM fct_orders WHERE order_id IS NULL -
uniqueнаorder_id— группировка и поиск повторов:SELECT order_id FROM fct_orders GROUP BY order_id HAVING COUNT(*) > 1 -
accepted_valuesнаstatus— всё, что вне списка:SELECT status FROM fct_orders WHERE status NOT IN ('pending', 'shipped', 'delivered', 'cancelled') -
relationshipsнаcustomer_id— анти-джойн к родителю, эквивалент foreign key:SELECT customer_id FROM fct_orders WHERE customer_id IS NOT NULL AND customer_id NOT IN (SELECT customer_id FROM dim_customers)
Заметьте: параметр field в relationships указывает колонку в родительской модели (dim_customers), а не в текущей — это частый источник путаницы. И ещё важная деталь про unique: его вешают только на ключ, уникальный на уровне строк модели. Повесить unique на customer_id в fct_orders — классическая ошибка джуна: один клиент делает много заказов, тест будет падать каждый раз, хотя ничего не сломано.
При выполнении dbt не возвращает сами строки в Python — он оборачивает скомпилированный SELECT во внешний count(*):
SELECT COUNT(*) AS failures
FROM (
SELECT order_id FROM fct_orders WHERE order_id IS NULL
) AS dbt_internal_test
Если failures = 0 — PASS, иначе — FAIL, и число выводится как FAIL N. Финальную обёртку всегда можно посмотреть в target/run/....
Где живут нарушители: store_failures
По умолчанию dbt не сохраняет сами плохие строки — он считает только их количество через внешний count(*) и выкидывает подзапрос. Это дёшево, но неудобно при разборе: вы знаете, что тест упал на 47 строках, но не видите, какие именно. Флаг --store-failures (или config: {store_failures: true} в YAML) меняет поведение: вместо count(*) dbt материализует результат SELECT-нарушителей в таблицу в отдельной схеме (по умолчанию <schema>_dbt_test__audit).
dbt test --select fct_orders --store-failures
После этого нарушителей можно просто SELECT * FROM dbt_test__audit.unique_fct_orders_order_id и увидеть конкретные дубликаты. Это стандартный приём дебага: упал тест в CI — включаешь store_failures локально на том же селекторе, смотришь строки, чинишь источник. Для шумных WARN-тестов хранение нарушителей ещё и даёт исторический след: можно строить дашборд «сколько грязных строк приезжает каждый день» поверх audit-схемы.
Severity: warn против error
По умолчанию провал теста — это error: dbt test (и dbt build) завершается с exit code 1, что в CI блокирует pipeline. Но не каждое нарушение должно ронять сборку. Для этого есть severity.
- name: email
tests:
- not_null:
severity: warn # просто WARN, exit code остаётся 0
- unique:
severity: error
error_if: ">= 100" # error только при 100+ нарушителях
warn_if: ">= 1" # иначе warn
Связка error_if / warn_if превращает бинарный тест в пороговый: count(*) нарушителей сравнивается с порогом, и dbt сам решает, это WARN или ERROR. Это рабочий приём для «грязных, но терпимых» источников: вы видите деградацию в логах, не останавливая поставку данных.
Singular tests: одноразовый SELECT под конкретную бизнес-логику
Когда проверку нельзя выразить готовым шаблоном, пишут .sql-файл в каталоге tests/. Это просто SELECT, который должен вернуть ноль строк. Никакого YAML, никакой параметризации — голая бизнес-логика.
-- tests/assert_revenue_non_negative.sql
-- Нарушители: заказы с отрицательной выручкой
SELECT
order_id,
revenue
FROM {{ ref('fct_orders') }}
WHERE revenue < 0
Тот же контракт «ноль строк = PASS». Singular test берёт ref()/source(), поэтому встраивается в DAG как полноценный узел и запускается тем же dbt test. Используйте его для проверок, завязанных на отношения между несколькими таблицами или на агрегаты: «сумма строк-деталей равна шапке заказа», «нет заказов с датой доставки раньше даты создания». Если вы ловите себя на копировании одного singular-теста с заменой имени таблицы — пора превратить его в собственный generic-тест через макрос test.
Свои generic-тесты: тот же шаблон, но ваш
Четыре встроенных теста — это не финал, а пример. Generic-тест — это просто макрос с именем test_<name>, который принимает model и column_name и возвращает SELECT нарушителей. Вы можете написать собственный и переиспользовать его декларативно по всему проекту. Канонический пример — проверка положительности:
-- tests/generic/test_positive_value.sql
{% test positive_value(model, column_name) %}
SELECT {{ column_name }}
FROM {{ model }}
WHERE {{ column_name }} < 0
{% endtest %}
Теперь в любом YAML доступно tests: [positive_value]. Это превращает разовый singular-тест в переиспользуемый строительный блок — ровно тот момент, когда копипаста должна стать абстракцией. Готовые наборы таких тестов поставляет пакет dbt_utils (unique_combination_of_columns, accepted_range, not_null_proportion и десятки других) — добавляете его в packages.yml, и каталог проверок резко расширяется без написания SQL руками. Принцип тот же: каждый из них в итоге компилируется в SELECT, который должен вернуть ноль строк.
Source freshness: тест на «свежесть», а не на значения
dbt test проверяет содержимое таблиц. Но есть отдельный класс отказов: данные технически валидны, просто устарели — loader сломался, и в источник вторые сутки ничего не приезжает. Для этого есть отдельная команда dbt source freshness и блок freshness в декларации source.
sources:
- name: jaffle_shop
loaded_at_field: _loaded_at # колонка с временем загрузки
freshness:
warn_after: {count: 12, period: hour}
error_after: {count: 24, period: hour}
tables:
- name: raw_orders
Под капотом это снова SELECT — но не нарушителей, а максимума по loaded_at_field:
SELECT MAX(_loaded_at) AS max_loaded_at, MAX(CURRENT_TIMESTAMP) AS snapshotted_at
FROM jaffle_shop.raw_orders
dbt считает разницу между snapshotted_at и max_loaded_at и сравнивает с порогами. Старше warn_after — WARN, старше error_after — ERROR. Freshness обычно ставят отдельным шагом CI до основной сборки: если сырьё протухло, нет смысла перестраивать витрины. Артефакт sources.json затем можно скормить в мониторинг.
Тесты как документация
Декларации тестов лежат в том же YAML, что и description колонок, и попадают в один и тот же manifest.json, из которого генерируется dbt docs. Поэтому тест — это исполняемая документация: строка tests: [unique, not_null] рядом с order_id сообщает читателю «это первичный ключ» не хуже комментария, и при этом ещё и проверяется на каждом прогоне. relationships рисует рёбра в графе линиджа. Это редкий случай, когда документация не врёт по определению — потому что если бы врала, тест бы упал.
Model contracts: проверка формы, а не данных
Всё, что выше, проверяет значения в уже построенной таблице. contract.enforced: true dbt во время run сверяет фактические колонки и типы скомпилированного SQL с YAML и отказывается материализовать модель при расхождении.
models:
- name: fct_orders
config:
contract:
enforced: true
columns:
- name: order_id
data_type: bigint
constraints:
- type: not_null
- type: primary_key
- name: revenue
data_type: numeric(12, 2)
constraints:
- type: not_null
Если SQL вернёт revenue как float, или добавит незадекларированную колонку, сборка упадёт с явным сообщением: Column 'revenue' has type 'double precision' but YAML declares 'numeric(12, 2)'. Без enforced: true поля data_type и constraints — лишь декоративная документация; dbt не возражает, и схема дрейфует молча.
Важная оговорка про честность: в большинстве облачных хранилищ (Snowflake, BigQuery) объявленные constraints — metadata-only. dbt выпускает DDL вроде PRIMARY KEY/CHECK, но движок не блокирует вставку строк-нарушителей. Это даёт ложное чувство защищённости: NOT NULL в контракте не значит, что в данных нет NULL. Реальную проверку значений по-прежнему делают data-тесты. Поэтому правильная связка для production-витрины — contract (форма) + data tests (значения) + unit tests (логика трансформации).
Контракт не нужен на каждой модели: на staging и intermediate он добавляет YAML-оверхед без выгоды. Эвристика — если модель используют 3+ downstream-потребителя, контракт оправдан.
Стратегия по слоям: где какие проверки ставить
Распространённая ошибка — лить все тесты в один слой. На практике проверки распределяются по слоям dbt-проекта, и в каждом слое у них разная роль.
На sources ставят раннее обнаружение: not_null/unique на естественных ключах сырых таблиц и freshness. Идея в том, чтобы поймать сломанный loader до того, как мусор протечёт в staging. Тест на source — это dbt test --select source:jaffle_shop, и его часто гоняют отдельным шагом, раньше основной сборки.
На staging (stg_*) тестов минимум — обычно unique + not_null на ключе после очистки. Эти модели часто меняются, контракты тут не нужны: лишний YAML без отдачи. Их задача — нормализовать сырьё, а не быть стабильным интерфейсом.
На intermediate (int_*) тесты, как правило, не ставят вовсе: это эфемерные шаги трансформации, не consumer-facing. Если очень хочется что-то проверить — это сигнал, что логику пора покрыть unit-тестом, а не data-тестом.
На marts (fct_*, dim_*) — максимум защиты: generic-тесты на ключи и enum-поля, singular-тесты на бизнес-инварианты, и contract.enforced: true со всеми constraints. Именно эти модели читают дашборды и ML, поэтому здесь стоит и форма (контракт), и значения (тесты).
Так выстраивается пирамида: дёшево и широко на входе (sources/staging), дорого и строго на выходе (marts). Полностью покрытая production-витрина имеет три независимых слоя защиты, которые ловят разные классы отказов: contract ловит дрейф схемы на этапе сборки, data tests ловят грязные значения после материализации, а unit tests ловят ошибку в самой логике трансформации ещё до контакта с реальными данными.
Сводная таблица: какой инструмент когда
| Инструмент | Что проверяет | Как реализован | Где живёт | Когда падает |
|---|---|---|---|---|
not_null | нет NULL в колонке | WHERE col IS NULL | YAML, колонка | вернулась 1+ строка |
unique | нет повторов | GROUP BY ... HAVING COUNT(*) > 1 | YAML, колонка | есть дубликаты |
accepted_values | значения из списка | WHERE col NOT IN (...) | YAML, колонка | значение вне списка |
relationships | ссылочная целостность (FK) | анти-джойн к родителю | YAML, колонка | висячая ссылка |
| singular test | произвольная бизнес-логика | свой SELECT в tests/ | .sql-файл | вернулась 1+ строка |
| source freshness | возраст данных | MAX(loaded_at) vs порог | freshness: у source | данные старше порога |
| model contract | схема: колонки, типы, constraints | сверка SQL vs YAML на compile | config.contract | форма не совпала |
Где это встаёт в CI
Типичный pipeline pull-request’а выстраивается так, чтобы дешёвые проверки шли первыми:
dbt parse/dbt compile— синтаксис, валидностьref(), контракты на этапе компиляции. Сюда же попадает breach контракта.dbt source freshness— отдельный gate: если сырьё протухло, останавливаемся, не тратя compute на витрины.dbt build --select state:modified+— сборка изменённых моделей и сразу их тесты.build=run+testс правильным порядком: если тесты модели упали, downstream-модели на DAG не строятся, чтобы не множить плохие данные. Селекторstate:modified+через сравнение сmanifest.jsonцелевой ветки гоняет только затронутую часть графа — этоslim CIslim CI .
Любой FAIL отдаёт exit code 1 и роняет проверку PR. WARN-и (через severity: warn) видны в логах, но сборку не блокируют — это пространство для деградаций, которые надо наблюдать, а не лечить экстренно.
Хотите пощупать всё это на реальном проекте — в курсе dbt I есть встроенная DuckDB-песочница: пишете тест в YAML, жмёте «запустить», ломаете модель UNION-ом с дубликатом и видите FAIL 1, открываете скомпилированный SELECT и выполняете его руками. Это бесплатно. Тестирование данных — часть более широкого пути аналитического инженера в направлении Data, где dbt стоит рядом с warehouse, оркестрацией и моделированием.
Что стоит унести с собой
- Любой data-тест — это SELECT нарушителей;
0строк = PASS. Это объясняет поведение всех четырёх generic-тестов и singular-тестов разом. severity(warn/error) и порогиerror_if/warn_ifотделяют «уроним сборку» от «просто запишем в лог».- Freshness — не про значения, а про возраст:
MAX(loaded_at)против порога, отдельная команда, отдельный gate. - Contracts проверяют форму, а не данные, и в облачных хранилищах constraints часто metadata-only — реальную проверку значений делают тесты.
- В CI порядок дешёвый-к-дорогому: compile/contracts → freshness →
dbt buildсо slim-селектором по изменённым моделям.
Готовы перейти от теории к запуску dbt test в браузере — бесплатный курс с песочницей ждёт: dbt I.