dbt модели без тестов — это поезд без тормозов. Один день и ты в проде с задвоенными строками, NULL в ключевом поле, или status = "unknown" который сломает downstream-dashboard.
Этот гайд — про четыре уровня тестов в dbt: generic (built-in), dbt-utils, custom singular, и CI/CD интеграция.
Шаг 1: Generic tests — встроенные
dbt из коробки даёт 4 теста:
# models/marts/dim_users.yml
version: 2
models:
- name: dim_users
columns:
- name: user_id
tests:
- not_null
- unique
- name: email
tests:
- not_null
- name: status
tests:
- accepted_values:
values: ['active', 'churned', 'paused']
- name: country_id
tests:
- relationships:
to: ref('dim_countries')
field: country_id
Что делает каждый:
not_null—SELECT * FROM model WHERE column IS NULL(fail если есть результат)unique—SELECT column FROM model GROUP BY 1 HAVING COUNT(*) > 1accepted_values— value NOT IN list = failrelationships— foreign key check (LEFT JOIN на target, есть NULL в target — fail)
Запуск:
dbt test --select dim_users
Шаг 2: dbt-utils tests — расширение
Установка через packages.yml:
packages:
- package: dbt-labs/dbt_utils
version: 1.1.0
dbt deps — установка. Теперь доступны:
equal_rowcount
- name: fct_orders
tests:
- dbt_utils.equal_rowcount:
compare_model: ref('stg_orders')
Проверка, что транс модель не «теряет» строки относительно staging. Polezno для join-heavy моделей.
expression_is_true
- name: fct_orders
tests:
- dbt_utils.expression_is_true:
expression: "amount >= 0"
- dbt_utils.expression_is_true:
expression: "shipped_at >= ordered_at"
Произвольное SQL-условие на каждую строку. Удобно для business rules.
at_least_one
- name: dim_categories
columns:
- name: category_id
tests:
- dbt_utils.at_least_one
Защита от пустой таблицы (rowcount = 0). Если dbt model build'ится без ошибок, но возвращает 0 строк — тихий bug.
unique_combination_of_columns
- name: fct_user_event
tests:
- dbt_utils.unique_combination_of_columns:
combination_of_columns: ['user_id', 'event_date', 'event_type']
Композитный unique key — built-in unique работает только на одной колонке.
not_constant
- name: stg_payments
columns:
- name: amount
tests:
- dbt_utils.not_constant
Колонка не должна быть константой (все строки одинаковые). Catches data pipeline bugs.
recency
- name: stg_events
tests:
- dbt_utils.recency:
datepart: hour
field: event_time
interval: 24
Проверка свежести: MAX(event_time) должен быть в последние 24 часа. Алерт если source перестал писать.
Шаг 3: Custom singular tests
Generic tests параметризованы. Singular — это single SQL-запрос для уникального бизнес-правила.
-- tests/orders_above_zero_amount.sql
SELECT order_id, amount
FROM {{ ref('fct_orders') }}
WHERE amount <= 0;
Логика: если запрос вернул хоть одну строку — тест fail. dbt автоматически считает rows.
Запустить:
dbt test --select test_orders_above_zero_amount
Более сложный пример: revenue consistency
-- tests/revenue_matches_payments.sql
WITH orders_total AS (
SELECT SUM(amount) AS total FROM {{ ref('fct_orders') }} WHERE status = 'completed'
),
payments_total AS (
SELECT SUM(amount) AS total FROM {{ ref('fct_payments') }} WHERE status = 'success'
)
SELECT
o.total AS orders_revenue,
p.total AS payments_revenue,
ABS(o.total - p.total) AS difference
FROM orders_total o, payments_total p
WHERE ABS(o.total - p.total) > 0.01; -- допуск на округление
Возвращает строки только если revenue не совпадает между двумя моделями.
Шаг 4: Custom generic tests (reusable)
Singular ок для одного места. Если нужен переиспользуемый тест:
-- macros/test_column_within_range.sql
{% test column_within_range(model, column_name, min_value, max_value) %}
SELECT *
FROM {{ model }}
WHERE {{ column_name }} < {{ min_value }} OR {{ column_name }} > {{ max_value }}
{% endtest %}
Использование:
- name: fct_users
columns:
- name: age
tests:
- column_within_range:
min_value: 0
max_value: 120
Теперь применять на любых моделях с возрастом.
Шаг 5: Severity, store_failures, и thresholds
severity
- name: stg_events
tests:
- not_null:
severity: warn # вместо error — пройдёт CI, но залогирует
Полезно для не-критических проверок (data quality drift), которые не должны блокировать deploy.
warn_if / error_if
- name: fct_orders
columns:
- name: amount
tests:
- not_null:
config:
error_if: ">100" # error если 100+ NULL значений
warn_if: ">10" # warn если 10-99
store_failures
- name: fct_orders
columns:
- name: status
tests:
- accepted_values:
values: ['pending', 'paid', 'refunded']
config:
store_failures: true
schema: dbt_test_failures # отдельная схема для исторических failures
Failures сохраняются в dbt_test_failures.accepted_values_fct_orders_status. Можно через SQL смотреть «какие именно строки fail'нулись», вместо просто «10 rows».
Шаг 6: CI/CD pipeline с dbt tests
GitHub Actions примерт:
# .github/workflows/dbt_ci.yml
name: dbt CI
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: pip install dbt-snowflake
- run: dbt deps
- run: dbt seed --target ci
- run: dbt run --select state:modified+ --target ci
env:
DBT_VARS: '{"is_ci": true}'
- run: dbt test --select state:modified+ --target ci
Хитрости:
state:modified+— только изменённые модели + downstream (быстро в CI)--defer+ state — берёт неизменённые модели из prod, не пересборки--store-failuresв CI — для debug через SQL потом
Slim CI с manifest from prod
# В CI:
dbt run --select state:modified+ --defer --state ./prod-manifest --target ci
Это копирует prod'овый manifest.json (через S3/GCS), сравнивает с PR'ной версией, run'ит только diff + downstream. На 1000+ моделях экономит часы CI-time.
Шаг 7: Test selection в Airflow
# airflow DAG
test_critical = BashOperator(
task_id='dbt_test_critical',
bash_command='dbt test --select tag:critical', # только critical tests
)
run_models = BashOperator(
task_id='dbt_run',
bash_command='dbt run',
)
test_all = BashOperator(
task_id='dbt_test_all',
bash_command='dbt test',
trigger_rule='all_done', # тесты даже если build fail
)
test_critical >> run_models >> test_all
Tag в schema.yml:
- name: fct_revenue
columns:
- name: amount
tests:
- not_null:
tags: ['critical'] # упадёт DAG если fail
Шаг 8: Coverage — сколько тестов нужно
Не «тесты на каждую колонку». Минимальный baseline:
| Тип модели | Обязательные тесты |
|---|---|
Staging (stg_*) | not_null + unique на PK |
Intermediate (int_*) | row count vs source (equal_rowcount) |
Marts/Facts (fct_*) | not_null + unique на PK + accepted_values на status + relationships на FK |
Marts/Dimensions (dim_*) | not_null + unique + at_least_one |
| Snapshot | not_null на updated_at |
Источник: dbt best practices.
FAQ
Тесты замедляют production deploy?
Yes. На 500+ моделях полный test suite — 10-30 минут. Решения: tag'и (critical running every deploy, nightly ночью), Slim CI с state:modified+, parallel testing через dbt 1.5+.
Что делать с false positives?
severity: warn или error_if: ">N" (порог). Slack-алерт без блокировки CI, инвестигируем root cause.
dbt test vs Great Expectations?
dbt test проще, intgegrirovan, для standart checks (uniqueness, not_null, refs). Great Expectations для сложных distributions / advanced anomaly detection. Большинство кейсов покрывается dbt-utils + custom tests.
Тестировать staging или marts?
Оба. Staging — структура данных (типы, NULL). Marts — бизнес-правила (revenue consistency, status enum).
Как тестировать incremental модели?
Те же тесты + дополнительно dbt_utils.expression_is_true на MAX(updated_at) >= CURRENT_DATE (свежесть). Тесты прогоняются на ВСЕЙ таблице после run, не только incremental window.
Что дальше
- 🧪 SQL-тренажёр — практика SQL для dbt
- 🧠 3000+ вопросов с собесов — много про data quality
- 📚 dbt практический гайд — first steps
- 🔄 dbt Incremental Models — как масштабировать модели
- ⚡ Airflow DAG patterns — оркестрация dbt + tests
- 🔥 10 SQL антипаттернов — bugs которые тесты ловят
Источники
- docs.getdbt.com/docs/build/tests — официальные доки
- github.com/dbt-labs/dbt-utils — основной test-package
- docs.getdbt.com/best-practices/how-we-structure/4-marts — best practices
Тесты — это not optional. Открой SQL-тренажёр и тренируй SQL для writing better dbt models с правильной test coverage.