Зачем unit tests: transformation logic vs data quality
В dbt I мы научились писать data tests — unique, not_null, relationships, accepted_values, кастомные generic / singular tests. Эти тесты выполняют SQL-запрос против таблицы в warehouse и проверяют, что результат пустой. Они отлично ловят проблемы с данными: дубли по PK, NULL там где не должно быть, orphan FK, неожиданные значения.
Но есть целый класс багов, которые data tests не ловят. Это баги в логике трансформации: неправильный JOIN, перепутанные операнды в CASE, неверная формула revenue. Эти баги проявляются по-разному в зависимости от того, какие данные сейчас в source. На сегодняшних данных модель выдаёт «нормальный» результат и тесты зелёные. На завтрашних — корнер кейс, и всё ломается в продакшене.
Unit tests (dbt 1.8+) — это второй слой защиты: проверяем логику на известных входных данных с известным ожидаемым выходом, без обращения к реальному warehouse. Это классический unit-test паттерн из software engineering, теперь доступный для dbt-моделей.
Этот урок — про концепт: чем unit-тесты отличаются от data-тестов, какие баги ловят, почему нужны оба слоя. Следующие три урока — про конкретный синтаксис, fixtures и интеграцию в CI.
Два слоя тестирования
dbt-i: введение в unit testsПростой пример. Модель customer_lifetime_value:
{{ config(materialized='table') }}
SELECT
c.customer_id,
c.first_name,
SUM(o.order_total) AS lifetime_value,
COUNT(o.order_id) AS total_orders
FROM {{ ref('dim_customers') }} c
LEFT JOIN {{ ref('fct_orders') }} o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.first_name
Data tests на эту модель:
- name: customer_lifetime_value
columns:
- name: customer_id
data_tests:
- unique
- not_null
- name: lifetime_value
data_tests:
- dbt_utils.expression_is_true:
expression: ">= 0"
Эти тесты сработают на актуальных данных. Они не проверяют, что SUM(o.order_total) считает именно то, что нужно. Если кто-то по ошибке заменит SUM на AVG, или JOIN на INNER (потеряются клиенты без заказов), все data tests останутся зелёными. Дубли — нет, NULL — нет, отрицательных — нет. Просто неправильные значения. Это и есть бага в логике, для которой существуют unit tests.
Конкретный пример бага, которого data tests не видят
Возьмём mart-модель fct_customer_orders:
SELECT
o.order_id,
o.customer_id,
o.order_date,
o.order_total,
o.order_total - COALESCE(d.discount_amount, 0) AS net_revenue,
CASE
WHEN o.order_total >= 100 THEN 'premium'
WHEN o.order_total >= 50 THEN 'standard'
ELSE 'basic'
END AS order_tier
FROM {{ ref('stg_orders') }} o
LEFT JOIN {{ ref('stg_discounts') }} d ON d.order_id = o.order_id
Логика выглядит корректно. Но представь, что в текущих данных нет ни одного заказа с дисконтом (stg_discounts пустой). На текущих данных:
net_revenue = o.order_total - 0 = o.order_totalвсегда, потому чтоd.discount_amountвсегда NULL.- Все ожидаемые тесты пройдут:
net_revenue >= 0, нет NULL в обязательных колонках, customer_id unique по PK.
А теперь маркетинг включил кампанию с купонами. В stg_discounts появляются строки. Их discount_amount оказывается в копейках (5000 = 50 рублей), а не в основной валюте, как order_total (50 = 50 рублей). На производстве net_revenue уходит в большие отрицательные значения. Tests падают, дашборд показывает -$4900 средний чек. Это потеря: на починку и коммуникацию уйдут часы, в blameless postmortem напишут «недостаток покрытия логики дискаунта тестами».
Этот баг можно было поймать unit-тестом до коммита:
unit_tests:
- name: discount_amount_substracts_correctly
model: fct_customer_orders
given:
- input: ref('stg_orders')
rows:
- {order_id: 1, customer_id: 100, order_date: '2026-01-01', order_total: 100.00}
- input: ref('stg_discounts')
rows:
- {order_id: 1, discount_amount: 10.00}
expect:
rows:
- {order_id: 1, net_revenue: 90.00, order_tier: 'standard'}
Если кто-то в будущем перепутает копейки и рубли — fixture с known input даст known output, и тест упадёт. Сейчас, до того, как баг доберётся до прода.
Чем unit tests НЕ являются
Главная путаница приходит из software engineering — там unit test == small isolated piece. В dbt уровень изоляции другой.
То есть в dbt:
- Unit test НЕ тестирует одну строчку SQL. Тестирует модель целиком с её JOIN, агрегатами, оконными функциями.
- Unit test НЕ запускает upstream. Если ты пишешь unit test на
mart_customer_orders, fixtures дляstg_ordersиstg_discountsты задаёшь руками — реальный SQLstg_ordersне выполняется. - Unit test НЕ требует warehouse с данными. Можно прогнать на чистой DuckDB без сидов и моделей. dbt компилирует временные CTE с твоими fixture-строками и подставляет их вместо
ref(...).
То есть «unit» здесь — это «one model», не «one expression». Это компромисс между удобством и изоляцией: ты тестируешь логику одной модели на конкретном input -> output.
Когда писать unit test, когда data test
Не каждая модель нуждается в unit-тестах. Старая поговорка: если код тривиальный — тест дороже самого кода. Используй чек-лист.
| Признак модели | Unit test? |
|---|---|
Простой SELECT * FROM source или одиночный WHERE | Нет |
| Несколько JOIN-ов с edge cases (NULL keys, дубли) | Да |
| CASE с >2 ветками или nested CASE | Да |
| Финансовая формула (revenue, discount, fees) | Да (обязательно) |
| Бизнес-логика, которую обсуждали >5 минут | Да |
| Window function с partitioning / ordering | Да |
Аггрегации с filters (COUNT(DISTINCT CASE ...)) | Да |
| Recursive CTE или сложная иерархия | Да |
| Просто переименование колонок | Нет |
Эвристика: «если ты тратишь >30 минут размышляя над логикой — она достойна теста».
Data tests, в отличие от unit tests, дёшевы и покрытие должно быть высоким. Если у модели есть PK — unique + not_null обязательны. Если FK — relationships. Если categorical column — accepted_values. Это базовая гигиена, не argued.
Стоимость unit tests
Unit tests не бесплатны:
- Время на написание fixture. На каждый сценарий ты пишешь 3-15 строк YAML с моками. Чем больше upstream — тем больше fixture-строк. Для модели с 5 ref’ами unit-тест на каждый сценарий — 50+ строк YAML.
- Поддержка. Когда меняется schema upstream, fixtures нужно обновить. Если у
stg_ordersпоявилась новая колонкаis_returned— все unit tests, которые мокаютstg_orders, нужно дополнить (или оставить NULL и принять). - Coverage gap risk. Можно написать unit-тест, который тестирует “happy path” и пропустить корнер-кейс. Покрытие == дисциплина + code review.
Стоимость окупается на критических моделях и активной разработке. Для рассчётов revenue, finance, churn, attribution — обязательно. Для базовых staging-моделей — нет.
Архитектура: где unit tests запускаются
Ключевая идея — unit tests первыми. Они быстрые (секунды), не требуют warehouse, ловят дешёвые баги. Data tests — медленные (минуты на большую таблицу), требуют warehouse, ловят дорогие баги. CI запускает unit -> data, и если unit упал — экономишь время на data step.
DuckDB и unit tests
Хорошая новость: unit tests полностью работают на DuckDB. dbt-duckdb 1.10+ поддерживает unit_tests resource как нативную фичу dbt-core. В курсовых лабах мы будем гонять unit tests локально на DuckDB.
Технически dbt при unit-тестировании:
- Парсит YAML с
unit_tests. - Для каждого
given.inputсоздаёт CTE из rows. - Компилирует SQL модели, заменяя
{{ ref(...) }}на этот CTE. - Выполняет SQL и сравнивает результат с
expect.rows(через UNION ALL trick). - Если разница — выдаёт diff между actual и expected.
В Snowflake / BigQuery / Postgres работает аналогично. Только в DuckDB всё это локально и моментально.
Попробуй сам (mental exercise)
Ты пишешь модель customer_churn_flag:
SELECT
customer_id,
last_order_date,
CASE
WHEN last_order_date < CURRENT_DATE - INTERVAL '90 day' THEN TRUE
WHEN last_order_date IS NULL THEN TRUE
ELSE FALSE
END AS is_churned
FROM {{ ref('customer_orders_summary') }}
Какие unit-test сценарии нужны? Подумай и сравни:
- Happy path active: клиент с
last_order_date = current_date - 30 day->is_churned = FALSE. - Happy path churned:
last_order_date = current_date - 100 day->is_churned = TRUE. - Edge: exactly 90 days:
last_order_date = current_date - 90 day-> надо проверить — модель использует<, значит на ровно 90 -> FALSE. Это правильно? Возможно бизнес имел в виду<=. Тест поднимет вопрос. - Edge: NULL last_order_date: ->
is_churned = TRUE. Это специальный сценарий, который CASE покрывает явно. - Edge: future date (баг в source данных):
last_order_date = current_date + 1 day->is_churned = FALSE. Возможно нужен third case “is_data_invalid”.
Каждый сценарий — отдельный unit_test с явным given.rows и expect.rows. Это спецификация поведения, написанная как тест.
Идея «спецификация как тест» — это и есть TDD on dbt. Об этом — в уроке 04.
Ключевые выводы
- Data tests проверяют качество данных в warehouse (
unique,not_null,accepted_values). Они нужны для всех моделей с PK / FK / categorical columns. - Unit tests проверяют логику трансформации на mock-данных. Они нужны для моделей со сложной бизнес-логикой (JOIN, CASE, агрегаты, оконные функции, финансы).
- Unit tests изолированы от upstream — fixture-строки задаются вручную, ref/source мокаются. Тест запускается без warehouse и за секунды.
- Стоимость unit tests — время на fixture + поддержка при изменении schema. Окупается на критических моделях.
- В CI пайплайне unit tests запускаются первыми, перед data tests. Они дешевле и быстрее, режут feedback loop.
- На DuckDB unit tests работают полностью — dbt-duckdb 1.10+ поддерживает их nativel.
- Эвристика: «если над логикой думал >30 минут — пиши unit test». Тривиальные select-from — не нуждаются.