dbtтестыdata qualityPostgreSQLдата-инженерия

dbt Tests: generic + custom + dbt-utils (полный гайд с примерами)

2026-06-01 11 мин

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

Что делает каждый:

Запуск:

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

Хитрости:

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


Что дальше

Источники

Тесты — это not optional. Открой SQL-тренажёр и тренируй SQL для writing better dbt models с правильной test coverage.

dbt + SQL = средний+ уровень data
480+ SQL-задач в тренажёре, 3000+ вопросов с собесов. Прокачай навыки для middle/senior offer.
Открыть тренажёр →