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.
Если выражение становится длиннее одной строки — лучше 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 rows | equal_rowcount vs source |
| Filter / dedup | rows ≤ source | fewer_rows_than (или equal_rowcount if no filter) |
| Aggregation | rows < source | fewer_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 сразу скажет. Без него можно несколько недель работать на устаревших данных и не заметить.
Внимание: 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
С точки зрения 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_rowcountvs 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 с тестами:
unique+not_nullнаorder_id.relationshipsотcustomer_idкstg_jaffle__customers.customer_id.accepted_valuesдляstatus(например,['completed', 'pending', 'returned']).dbt_utils.expression_is_trueнаtotal_amount > 0.dbt_utils.equal_rowcountvs source.
Запустите dbt test --select stg_jaffle__orders. Намеренно нарушьте одно из правил в seed-данных и убедитесь, что нужный тест ловит.
Итоги
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 за тестами