Перейти к содержанию
Learning Platform

Главная мысль: тест в dbt — это запрос на поиск нарушителей

Самое полезное, что можно понять про тестирование в dbt, формулируется одной фразой: тест — это SELECT, который ищет плохие строки, и который должен вернуть ноль строк. Не «правило», не «проверка свойства», не сравнение с эталоном — а буквально запрос, который материализуется и выполняется внутри вашего хранилища. Если запрос вернул 0 строк — данные чистые, тест прошёл. Если вернул N > 0 — это и есть N нарушителей, и тест падает.

Эта инверсия (мы ищем не «правильное», а «неправильное») — ключ ко всему остальному. Как только она сидит в голове, исчезает магия: и generic-тесты, и singular-тесты, и даже freshness становятся вариациями одного и того же SELECT. А model contracts встают рядом как принципиально другой механизм — проверка не данных, а формы таблицы.

Тяжёлую работу — сканирование, GROUP BY, анти-джойны — делает само хранилище: DuckDB, Postgres, Snowflake, BigQuery. dbt лишь генерирует SQL, оборачивает его в count(*) и сравнивает результат с нулём.

Generic-тесты: четыре встроенных шаблона

generic test — это параметризованный SQL-шаблон, который вешается на колонку или модель в YAML. dbt поставляется с четырьмя: 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 testdbt 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 под конкретную бизнес-логику

Когда проверку нельзя выразить готовым шаблоном, пишут singular test — обычный .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: проверка формы, а не данных

Всё, что выше, проверяет значения в уже построенной таблице. model contracts (dbt 1.5+) решают другую задачу — гарантируют схему: набор колонок, их типы и constraints. Это build-time gate: при 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) объявленные constraintsmetadata-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 NULLYAML, колонкавернулась 1+ строка
uniqueнет повторовGROUP BY ... HAVING COUNT(*) > 1YAML, колонкаесть дубликаты
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 на compileconfig.contractформа не совпала

Где это встаёт в CI

Типичный pipeline pull-request’а выстраивается так, чтобы дешёвые проверки шли первыми:

  1. dbt parse / dbt compile — синтаксис, валидность ref(), контракты на этапе компиляции. Сюда же попадает breach контракта.
  2. dbt source freshness — отдельный gate: если сырьё протухло, останавливаемся, не тратя compute на витрины.
  3. dbt build --select state:modified+ — сборка изменённых моделей и сразу их тесты. build = run + test с правильным порядком: если тесты модели упали, downstream-модели на DAG не строятся, чтобы не множить плохие данные. Селектор state:modified+ через сравнение с manifest.json целевой ветки гоняет только затронутую часть графа — это slim 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.

Ещё в направлении · Data Engineering

Все материалы направления →