Learning Platform
Глоссарий Troubleshooting
Урок 10.02 · 20 мин
Начальный
dbttestsgeneric testsmacros

Custom generic tests: переиспользуемая проверка через macro

Singular tests из прошлого урока — точечные. Хороши, когда инвариант уникален. Но что делать, если одну и ту же логику нужно применить к десятку колонок? Например: «значение в колонке должно соответствовать regex для email» — это правило подойдёт и customers.email, и vendors.contact_email, и users.work_email. Копипастить SQL — путь в ад поддержки. Здесь нужен custom generic test.

Custom generic test — это Jinja macro особого вида, который dbt вызывает при dbt test для каждой колонки/модели, в YAML которой он подключён. По сути, вы пишете шаблон проверки с параметрами, а dbt подставляет конкретные имена таблиц и колонок в момент компиляции.

Anatomy: macro с двумя обязательными вещами

Custom generic test должен:

  1. Называться test_<name> (если живёт в macros/) или просто <name> (если в tests/generic/).
  2. Иметь аргументы model (обязательно) и column_name (если применяется к колонке).
  3. Возвращать SQL, который возвращает failing rows, как и singular test.

Пример — тест «значение не отрицательное»:

-- tests/generic/test_not_negative.sql

{% test not_negative(model, column_name) %}

select *
from {{ model }}
where {{ column_name }} < 0

{% endtest %}

Этот файл лежит в tests/generic/. Каталог generic/ внутри tests/ — это конвенция dbt 1.7+, по которой dbt автоматически распознаёт макросы как generic tests без test_ префикса в имени. Альтернативная локация — macros/ с префиксом test_:

-- macros/test_not_negative.sql

{% test not_negative(model, column_name) %}
...
{% endtest %}

Оба варианта работают идентично. Конвенция последних лет — держать тесты в tests/generic/, оставляя macros/ для утилитарных макросов и dispatch-логики. Если у вас есть свобода выбора — лучше так.

Подключение в YAML

Где бы ни лежал macro, в YAML он подключается одинаково — по имени без префикса test_:

# models/staging/_models.yml
version: 2

models:
  - name: stg_jaffle__orders
    columns:
      - name: total_amount
        data_tests:
          - not_negative
      - name: tax_amount
        data_tests:
          - not_negative
NOTE

В dbt 1.8+ ключ tests: переименован в data_tests: (чтобы не путать с unit tests). Старый tests: ещё работает, но deprecated. В курсе используем актуальный data_tests:.

После dbt test оба теста запустятся как два отдельных проверки. В логе вы увидите имена вида:

not_negative_stg_jaffle__orders_total_amount
not_negative_stg_jaffle__orders_tax_amount

dbt автоматически генерирует уникальные имена по схеме <test_name>_<model>_<column>.

Аргументы и defaults

Generic test может принимать произвольные аргументы — и это превращает его в гибкий инструмент. Возьмём более интересный пример: «не более N% NULL в колонке».

-- tests/generic/test_not_null_proportion.sql

{% test not_null_proportion(model, column_name, max_null_pct=0.05) %}

with summary as (
    select
        count(*) as total_rows,
        sum(case when {{ column_name }} is null then 1 else 0 end) as null_rows
    from {{ model }}
)

select *
from summary
where (null_rows::float / nullif(total_rows, 0)) > {{ max_null_pct }}

{% endtest %}

В YAML аргументы передаются как именованные параметры:

columns:
  - name: shipping_country
    data_tests:
      - not_null_proportion:
          max_null_pct: 0.10   # позволяем до 10% NULL
  - name: customer_email
    data_tests:
      - not_null_proportion   # default 5%

Если аргумент не передан в YAML, берётся default из macro signature (max_null_pct=0.05). Это удобно: тест работает «из коробки» с разумным значением, но при необходимости настраивается.

Запись сложнее: тест на уровне модели

Generic test не обязательно привязан к колонке. Можно написать тест на уровне всей модели — тогда у него не будет column_name, только model. Пример: «количество строк в этой модели не упало больше чем на N% по сравнению со вчера».

Это уже сложновато для junior — там нужен run_query и доступ к историческому состоянию, что мы разберём позже. Простой пример уровня модели — «у модели не пустой результат»:

-- tests/generic/test_has_rows.sql

{% test has_rows(model, min_rows=1) %}

with row_count as (
    select count(*) as cnt from {{ model }}
)

select * from row_count
where cnt < {{ min_rows }}

{% endtest %}

В YAML тест применяется не к колонке, а к модели:

models:
  - name: dim_customers
    data_tests:
      - has_rows:
          min_rows: 100

Как dbt находит и компилирует custom generic test

dbt сканирует tests/generic/ и macros/Все *.sql файлы с конструкцией test (...) считаются generic tests
Парсит YAML моделейНаходит data_tests: - имя_теста в _models.yml для каждой модели/колонки
Резолвит test по имениИщет macro с этим именем в проекте -> потом в packages, через dispatch
Подставляет аргументыmodel = ref(текущая модель), column_name = имя колонки, плюс параметры из YAML
Компилирует в SQLРезультат — обычный SELECT в target/compiled/tests/
Запускает: rows = FAILТа же модель, что и для singular: вернул строки = упал

Можно посмотреть скомпилированный SQL:

$ dbt compile --select not_negative_stg_jaffle__orders_total_amount
$ cat target/compiled/jaffle_shop/models/staging/_models.yml/not_negative_stg_jaffle__orders_total_amount.sql

Там будет:

select *
from "jaffle_shop"."main"."stg_jaffle__orders"
where total_amount < 0

{{ model }} развернулся в "jaffle_shop"."main"."stg_jaffle__orders", а {{ column_name }} — в total_amount. dbt сам обернул в нужный quoting под адаптер.

Несколько generic tests на одну колонку

Никаких ограничений нет — можно навесить хоть десять. Каждый запустится отдельно:

columns:
  - name: order_amount
    data_tests:
      - not_null
      - not_negative
      - not_null_proportion:
          max_null_pct: 0.01
      - dbt_utils.expression_is_true:
          expression: "<= 1000000"

Последний — generic test из пакета dbt_utils, до которого мы дойдём через урок. Принцип тот же — generic test, но из external package.

severity, store_failures, where для generic test

Generic test — это просто macro, и config() в YAML работает так же:

columns:
  - name: total_amount
    data_tests:
      - not_negative:
          config:
            severity: warn
            store_failures: true
            where: "order_date >= current_date - interval 7 day"

Параметр where ОЧЕНЬ полезен: он добавляет фильтр к подзапросу теста. Это позволяет:

  • тестировать только свежие данные (where: "order_date >= ..."),
  • исключать legacy строки (where: "created_at > '2024-01-01'"),
  • разделять test на «warn для всей истории» и «error только для нового».

Tradeoff: когда писать custom generic vs использовать dbt_utils

Перед тем как писать свой generic test, проверьте — может быть, он уже есть в пакете dbt_utils или dbt_expectations. Например, dbt_utils содержит:

  • expression_is_true — универсальный «выражение должно быть TRUE» (column > 0, col_a = col_b * 2, …).
  • equal_rowcount — две модели должны иметь одинаковое количество строк.
  • recency — у модели есть запись свежее, чем X.
  • at_least_one — хотя бы одна не-NULL запись в колонке.

Эти и другие пакетные тесты разбираются в уроке 4 этого модуля. Если ваш generic test сводится к одному из них — берите из пакета.

Custom generic стоит писать, когда:

  • Логика специфична для вашего домена (например, «sum по группе = total в другой модели», но шаблон применяется к разным группам).
  • Готовые тесты есть, но их сигнатура неудобна.
  • Хочется выработать вокабуляр для команды (is_positive_currency, is_valid_phone_ru).

Хорошие практики

1. Имя test описывает условие, не процесс. not_negative лучше, чем check_amount. is_valid_email — лучше, чем email_validation.

2. Defaults для типичного случая. Если 95% времени параметр будет 0.05, делайте default 0.05. Только в редких случаях передавайте явно.

3. Один файл — один test. Не сваливайте десять {% test %} в один .sql. dbt парсит, но читать невозможно.

4. Документируйте параметры в комментариях. Через год вы забудете, что значит tolerance=0.001.

5. Тестируйте свои custom generic. Пишите специально кривые данные в seed-таблицу и проверяйте, что тест ловит. Иначе custom generic — это код без тестов, в проекте, посвящённом тестам.

Попробуй сам

Напишите custom generic test is_within_range, который принимает аргументы min_value и max_value (оба обязательные). Тест должен возвращать строки, в которых значение колонки выходит за [min_value; max_value].

Скелет:

-- tests/generic/test_is_within_range.sql

{% test is_within_range(model, column_name, min_value, max_value) %}

select *
from {{ model }}
where {{ column_name }} < {{ min_value }}
   or {{ column_name }} > {{ max_value }}

{% endtest %}

Подключите в YAML:

columns:
  - name: order_priority
    data_tests:
      - is_within_range:
          min_value: 1
          max_value: 5

Запустите dbt test --select is_within_range_* и убедитесь, что тест PASS на корректных данных. Намеренно вставьте строку с order_priority = 99 и убедитесь, что тест FAIL.

Проверка знанийKnowledge check
В YAML модели подключён generic test 'not_negative' к колонке 'total_amount'. Где dbt будет искать macro для этого теста и в каком порядке?
ОтветAnswer
Порядок поиска (dispatch): 1. В текущем проекте — каталоги tests/generic/ и macros/. Ищется macro с именем not_negative или test_not_negative. 2. Если не найден, dbt ищет в установленных packages — в порядке, в котором они объявлены в packages.yml. 3. Если есть префикс пакета в YAML (например, dbt_utils.expression_is_true), dbt идёт сразу в указанный пакет. Конвенция: в tests/generic/ файлы называются БЕЗ префикса test_, в macros/ — С префиксом test_. Внутри файла обязателен блок ''{% test name(args) %}''. Если macro не найден ни в проекте, ни в packages — dbt при parse выдаст ошибку 'Server error: macro not_negative not found'.
Проверка знанийKnowledge check
Custom generic test 'not_null_proportion' имеет signature: (model, column_name, max_null_pct=0.05). В YAML подключён без передачи параметров: '- not_null_proportion'. Что произойдёт?
ОтветAnswer
Тест запустится с max_null_pct=0.05 — это default из macro signature. Защёлки таковы: если параметр имеет default, его можно не передавать в YAML; если default нет (например, min_value, max_value в is_within_range из примера 'попробуй сам') — dbt выдаст ошибку 'missing required argument'. Default параметров — главный инструмент удобства generic test: разумное значение из коробки, override только когда нужно. В compile SQL подставится 0.05: '...where (null_rows::float / nullif(total_rows, 0)) > 0.05'.

Итоги

  • Custom generic test — Jinja macro {% test name(model, column_name, ...) %}, возвращает failing rows.
  • Хранится в tests/generic/ (без test_ префикса в имени файла) или в macros/test_ префиксом).
  • Подключается через data_tests: в YAML, как и встроенные.
  • Аргументы с defaults делают тест переиспользуемым и удобным.
  • config: блок задаёт severity, store_failures, where.
  • Если правило универсальное — сначала ищем в dbt_utils / dbt_expectations, потом пишем свой.

В следующем уроке — unit tests, новый тип тестов из dbt 1.8, который проверяет логику самой модели на mocked-данных.

Production-grade custom generic tests: сложные паттерны

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 7. Custom generic test 'not_negative' лежит в tests/generic/test_not_negative.sql. Как dbt идентифицирует его как generic test?

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

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

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

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