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 должен:
- Называться
test_<name>(если живёт вmacros/) или просто<name>(если вtests/generic/). - Иметь аргументы
model(обязательно) иcolumn_name(если применяется к колонке). - Возвращать 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
В 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
Можно посмотреть скомпилированный 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.
Итоги
- 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: сложные паттерны