Learning Platform
Глоссарий Troubleshooting
Урок 10.04 · 18 мин
Начальный
dbttestsdbt_utilspackage tests

dbt_utils tests: тур по must-know пакетным тестам

Когда вы пишете dbt-проект, рано или поздно понимаете: 80% custom generic tests, которые хочется написать, уже написаны до вас. Большинство из них живёт в пакете dbt_utils — официальной библиотеке от dbt Labs, де-факто стандарт в любом dbt-проекте. В этом уроке — тур по тестам из dbt_utils, которые junior должен знать, чтобы не изобретать велосипед.

Установка dbt_utils

Подробно тема пакетов — в модуле 16. Сейчас краткий рецепт: в корне проекта создаёте packages.yml:

packages:
  - package: dbt-labs/dbt_utils
    version: 1.3.0

И выполняете:

$ dbt deps
14:22:11  Installing dbt-labs/dbt_utils
14:22:13    Installed from version 1.3.0
14:22:13    Up to date!

После этого все macros и tests из dbt_utils доступны через префикс dbt_utils.:

data_tests:
  - dbt_utils.expression_is_true:
      expression: "> 0"

Топ-5 тестов dbt_utils для junior

Мы сосредоточимся на пяти, которые покрывают 80% реальных кейсов. Это expression_is_true, equal_rowcount, fewer_rows_than, recency и at_least_one.

Есть ещё много (mutually_exclusive_ranges, cardinality_equality, unique_combination_of_columns, relationships_where, sequential_values) — про них в курсе dbt II. На junior достаточно пятёрки выше.

1. expression_is_true — швейцарский нож

Самый универсальный тест. Принимает SQL-выражение и проверяет, что оно TRUE для каждой строки.

columns:
  - name: total_amount
    data_tests:
      - dbt_utils.expression_is_true:
          expression: "> 0"
      - dbt_utils.expression_is_true:
          expression: "<= 1000000"

В compile эти превратятся в:

select * from "jaffle_shop"."main"."stg_jaffle__orders" where not (total_amount > 0)
select * from "jaffle_shop"."main"."stg_jaffle__orders" where not (total_amount <= 1000000)

Можно сравнивать колонки между собой — тест на уровне модели:

models:
  - name: marts__orders
    data_tests:
      - dbt_utils.expression_is_true:
          expression: "shipping_cost <= total_amount"
      - dbt_utils.expression_is_true:
          expression: "discount_amount >= 0"

Это покрывает огромный класс инвариантов: «net = gross - tax», «shipping не больше total», «order_date раньше ship_date». Сильно сокращает потребность в singular tests.

TIP

Если выражение становится длиннее одной строки — лучше singular test. Граница примерно: helper-сравнение из 2-3 операторов — expression_is_true; полноценная агрегация или join — singular.

2. equal_rowcount — после трансформации не должны теряться строки

Самый частый bug в staging: вы делаете WHERE status IS NOT NULL и теряете 5% записей. equal_rowcount проверяет, что две модели имеют одинаковое количество строк:

models:
  - name: stg_jaffle__orders
    data_tests:
      - dbt_utils.equal_rowcount:
          compare_model: source('jaffle', 'raw_orders')

Если stg_jaffle__orders отфильтровал строки — тест упадёт. Это идеальный guard для staging-уровня: «1-к-1 с source».

Можно сравнивать и две модели между собой:

models:
  - name: marts__orders
    data_tests:
      - dbt_utils.equal_rowcount:
          compare_model: ref('stg_jaffle__orders')

Полезно, если бизнес-инвариант «маrt = staging по числу записей» (т.е. нет JOIN, который дублирует).

3. fewer_rows_than — обратный сценарий

«Aggregated mart должен иметь строго меньше строк, чем staging». Например, marts__revenue_daily = aggregation по дням; в нём должно быть меньше строк, чем в stg_jaffle__orders:

models:
  - name: marts__revenue_daily
    data_tests:
      - dbt_utils.fewer_rows_than:
          compare_model: ref('stg_jaffle__orders')

Если по какой-то причине группировка не сработала и в revenue_daily оказалось столько же или больше строк — тест ловит.

Парная пара equal_rowcount / fewer_rows_than хорошо описывает инварианты «преобразования по типам»:

Тип трансформацииОжиданиеТест
Staging (1-к-1 с source)rows = source rowsequal_rowcount vs source
Filter / deduprows ≤ sourcefewer_rows_than (или equal_rowcount if no filter)
Aggregationrows < sourcefewer_rows_than
JOIN (потенциальный fanout)rows ≥ left side(нужен custom)

4. recency — данные не должны протухать

Тест проверяет, что в модели есть хотя бы одна запись новее, чем X периодов назад. Идеален для marts, которые должны обновляться ежедневно:

models:
  - name: marts__revenue_daily
    data_tests:
      - dbt_utils.recency:
          datepart: day
          field: revenue_date
          interval: 2

Тест возвращает FAIL, если нет записей с revenue_date >= current_date - interval 2 day. То есть: «данные не старше двух дней».

Это главный страж против сломанного пайплайна. Если ваш ETL упал и в marts не пришли новые данные, recency сразу скажет. Без него можно несколько недель работать на устаревших данных и не заметить.

WARNING

Внимание: recency считает по календарю UTC (по default). Если у вас в данных локальное время, можно получить false positive из-за timezone gap. В таких случаях лучше использовать current_timestamp at time zone 'UTC' или явный custom test.

5. at_least_one — хотя бы один не-NULL

Проверяет, что в колонке есть хотя бы одно не-NULL значение. Полезно для опциональных колонок, которые в принципе могут быть NULL, но если все NULL — что-то сломалось:

columns:
  - name: discount_code
    data_tests:
      - dbt_utils.at_least_one

Сценарий: discount_code опционален, но в любой выгрузке должен быть хотя бы один промо-заказ. Если все NULL — баг в ETL (например, fail join).

Тест не заменяет not_null — там «ни одной NULL», тут «хотя бы один не-NULL». Это разные семантики.

Как dbt_utils tests интегрируются с DAG

dbt deps скачал dbt_utilsMacros и tests легли в dbt_packages/dbt_utils/
В YAML модели: dbt_utils.expression_is_trueПрефикс dbt_utils — указание на namespace пакета
dbt компилирует testПодставляет model, column_name и параметры из YAML
Тест добавляется в DAG моделиdbt test --select model+ запускает зависимые модели И тесты
SELECT runsВозвращает failing rows — тест PASS если 0, FAIL если >0

С точки зрения DAG dbt_utils tests идентичны custom generic tests. Единственная разница — namespace через префикс.

Реальный пример: набор тестов для staging модели

# models/staging/_models.yml
version: 2

models:
  - name: stg_jaffle__orders
    description: "Заказы, источник — raw_orders из Jaffle Shop sandbox."
    data_tests:
      - dbt_utils.equal_rowcount:
          compare_model: source('jaffle', 'raw_orders')
      - dbt_utils.expression_is_true:
          expression: "total_amount >= 0"
    columns:
      - name: order_id
        data_tests:
          - unique
          - not_null
      - name: customer_id
        data_tests:
          - not_null
          - relationships:
              to: ref('stg_jaffle__customers')
              field: customer_id
      - name: order_date
        data_tests:
          - not_null
      - name: status
        data_tests:
          - accepted_values:
              values: ['completed', 'pending', 'returned', 'cancelled']
      - name: total_amount
        data_tests:
          - not_null
          - dbt_utils.expression_is_true:
              expression: "> 0"
              config:
                where: "status != 'cancelled'"

Что мы здесь покрыли:

  • Целостность объёма: equal_rowcount vs source.
  • Бизнес-правило: total_amount >= 0 на уровне модели (быстрый sanity).
  • Уникальность ключа: unique + not_null на order_id.
  • FK: relationships на customer_id.
  • Enum: accepted_values на status.
  • Условный инвариант: > 0 только для не-cancelled заказов (через where).

Это достаточный набор для junior staging модели. Plain English: «строки не теряются, ключ уникален, FK целые, status корректный, суммы положительные».

Tradeoff: больше тестов != лучше

Junior часто впадает в крайность и пишет десяток тестов на каждую колонку. Это антипаттерн:

  • Тесты замедляют dbt build. Каждый = отдельный SELECT.
  • Не все тесты одинаково ценны. Дубли (> 0 и >= 0) проверяют похожее.
  • Высокая «шумность» лога -> команда перестаёт реагировать на FAIL.

Хорошее правило: на staging — несколько фундаментальных тестов (unique, not_null на ключ, relationships на FK, equal_rowcount). На marts — больше, потому что bug в mart-логике дороже. На intermediate — минимум, обычно достаточно not_null на ключи.

Попробуй сам

Возьмите проект Jaffle Shop (или эмуляцию). Для модели stg_jaffle__orders напишите YAML с тестами:

  1. unique + not_null на order_id.
  2. relationships от customer_id к stg_jaffle__customers.customer_id.
  3. accepted_values для status (например, ['completed', 'pending', 'returned']).
  4. dbt_utils.expression_is_true на total_amount > 0.
  5. dbt_utils.equal_rowcount vs source.

Запустите dbt test --select stg_jaffle__orders. Намеренно нарушьте одно из правил в seed-данных и убедитесь, что нужный тест ловит.

Проверка знанийKnowledge check
В чём разница между dbt_utils.at_least_one и встроенным not_null? Приведи пример колонки, для которой имеет смысл at_least_one, но НЕ имеет смысла not_null.
ОтветAnswer
not_null проверяет: 'НИ ОДНА строка в колонке не NULL'. Если в колонке хотя бы одна NULL — тест FAIL. at_least_one проверяет: 'ХОТЯ БЫ ОДНА строка не NULL'. Если все строки NULL — тест FAIL. Если есть смесь NULL и значений — тест PASS. Это противоположные семантики: - not_null = 'колонка обязательна'. - at_least_one = 'колонка опциональна, но не должна быть полностью пустой'. Пример: discount_code — опциональное поле, большинство заказов без скидки, но если ВСЕ NULL — это баг в ETL (например, не подтянули таблицу промо). Для discount_code: - not_null был бы false positive — реально много заказов без скидки. - at_least_one ловит сценарий 'pipeline вообще не подтянул discount_code'. Эти тесты могут жить вместе на разных колонках одной модели: order_id -> not_null (PK обязателен), discount_code -> at_least_one (sanity).
Проверка знанийKnowledge check
У вас staging-модель stg_orders. Source имеет 50,000 строк. Какие два-три теста стоит навесить, чтобы поймать самые частые баги ETL/staging уровня, и почему именно эти?
ОтветAnswer
Top-3 теста для staging: 1. dbt_utils.equal_rowcount vs source('...', 'raw_orders'). Самый ценный тест staging: ловит accidental filter, dedup, который потерял строки. Если 50000 на входе и 49500 в staging — тест FAIL, видим diff сразу. 2. unique + not_null на PK (order_id). Если в source есть дубли (например, два upsert'а), они дойдут до staging и сломают всё downstream. Тест unique ловит до того, как mart-модели начнут считать дубль за две продажи. 3. relationships от customer_id к stg_jaffle__customers.customer_id. FK-инвариант: каждый заказ должен иметь существующего customer. Если customer удалили или в join потерян — relationships FAIL, видим конкретные orphan строки. Эти три теста покрывают: 'объём не потерян', 'ключ уникален', 'FK целые'. 90% bug'ов staging — про эти три инварианта. Дополнительные тесты (accepted_values, expression_is_true) добавляются по специфике колонок.

Итоги

  • dbt_utils — стандартный пакет, без него никуда. Ставится через packages.yml и dbt deps.
  • Top-5 тестов для junior: expression_is_true, equal_rowcount, fewer_rows_than, recency, at_least_one.
  • Перед написанием custom generic — проверь, нет ли готового в dbt_utils.
  • Tests интегрируются в DAG как обычные tests, namespace через префикс dbt_utils..
  • Правило проксимальности: больше тестов на marts, меньше на intermediate.

Модуль 8 закрыт. Вы умеете писать singular tests, custom generic, unit tests и использовать пакетные тесты dbt_utils. В следующем модуле — Jinja, без которой dbt был бы просто переменными в SQL. Начнём с базового синтаксиса.

Топ-15 dbt-expectations тестов: расширенный тур dbt-utils в глубину: production patterns за тестами

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 7. Что делает тест dbt_utils.equal_rowcount и для какого слоя моделей он наиболее полезен?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4