Unit tests: проверяем логику модели, а не данные
До сих пор все тесты, которые мы видели, проверяли данные: правда ли, что в колонке нет NULL? Правда ли, что foreign keys целые? Правда ли, что суммы сходятся? Такие тесты называются data tests — они работают с реальными данными в warehouse.
Но у dbt-разработчика есть ещё одна боль: сама SQL-логика модели может быть кривой. Например, при вычислении revenue вы случайно применили LEFT JOIN вместо INNER, и в результате получаете лишние строки с NULL. Data test «revenue >= 0» это не поймает — формально нули корректны. Чтобы такие баги ловить заранее, нужен другой тип тестов — unit tests, появившиеся в dbt 1.8 (GA).
В чём идея unit test
Unit test берёт модель, подменяет её входы на фиктивные (mocked) данные и сравнивает фактический вывод с ожидаемым. Это работает в компайл-тайме без реальных данных warehouse — точно так же, как unit-тесты в обычном бэкенде.
Хороший проект использует оба типа: data tests гарантируют целостность входных данных, unit tests — что модель правильно их обрабатывает.
Анатомия unit test
Unit tests описываются в YAML — обычно прямо рядом с моделью, в файле _models.yml или отдельном _unit_tests.yml. Структура:
unit_tests:
- name: test_calculate_revenue_excludes_returns
model: marts__revenue_daily
given:
- input: ref('stg_jaffle__orders')
rows:
- {order_id: 1, order_date: '2026-01-01', total_amount: 100.00, status: 'completed'}
- {order_id: 2, order_date: '2026-01-01', total_amount: 50.00, status: 'returned'}
- {order_id: 3, order_date: '2026-01-02', total_amount: 75.00, status: 'completed'}
expect:
rows:
- {order_date: '2026-01-01', revenue: 100.00}
- {order_date: '2026-01-02', revenue: 75.00}
Что здесь происходит:
model— имя модели, которую проверяем (безref()).given— список входов с фиктивными данными. Каждый элемент:input: ref('upstream_model')иrows: [...].expect— ожидаемый результат модели на этом входе.
dbt берёт SQL модели marts__revenue_daily, подставляет вместо {{ ref('stg_jaffle__orders') }} временную таблицу с этими тремя строками, запускает модель, сравнивает результат с expect.rows. Если совпадает — PASS. Не совпадает — FAIL с подробным diff.
Запуск
$ dbt test --select test_type:unit
13:42:15 Running with dbt=1.10.2
13:42:16 1 of 1 START unit_test stg_jaffle__orders::test_calculate_revenue_excludes_returns
13:42:18 1 of 1 PASS test_calculate_revenue_excludes_returns ............... [PASS in 1.92s]
Или конкретный тест:
$ dbt test --select test_calculate_revenue_excludes_returns
В дашборде dbt test unit tests маркируются как unit_test (data tests — просто test).
Fixture formats: dict, csv, sql
Описать входы можно тремя способами. Выбор зависит от количества и типа данных.
1. Inline dict (как выше)
Удобно для 2-10 строк, читается прямо в YAML.
given:
- input: ref('stg_jaffle__orders')
rows:
- {order_id: 1, total_amount: 100.00}
- {order_id: 2, total_amount: 50.00}
2. CSV fixture
Для 10+ строк или когда тестовые данные нужно поддерживать аналитику в табличном виде. Кладёте CSV в tests/fixtures/:
# tests/fixtures/orders_basic.csv
order_id,order_date,total_amount,status
1,2026-01-01,100.00,completed
2,2026-01-01,50.00,returned
3,2026-01-02,75.00,completed
И ссылаетесь:
given:
- input: ref('stg_jaffle__orders')
format: csv
fixture: orders_basic
fixture: orders_basic — это имя файла без расширения. По дефолту dbt ищет в tests/fixtures/. Каталог настраивается через fixture-paths в dbt_project.yml.
3. Inline SQL
Когда данные сложно сформировать дикт-ом — например, нужны вычисляемые поля или CASE:
given:
- input: ref('stg_jaffle__orders')
format: sql
rows: |
SELECT 1 AS order_id, '2026-01-01'::date AS order_date, 100.00 AS total_amount, 'completed' AS status
UNION ALL
SELECT 2, '2026-01-01'::date, 50.00, 'returned'
UNION ALL
SELECT 3, '2026-01-02'::date, 75.00, 'completed'
Используется реже — для редких сценариев. На junior уровне сосредоточимся на dict (для маленьких сетов) и csv (для всего остального).
Подменяем не только ref(), но и source()
Если модель читает из {{ source('jaffle', 'raw_orders') }}, в given можно подменить и source:
given:
- input: source('jaffle', 'raw_orders')
rows:
- {id: 1, ts: '2026-01-01', amt: 100}
Это критично: вы изолируете модель полностью, не завися ни от каких реальных таблиц.
Overrides: подменяем var/env_var/macros
Иногда модель использует {{ var('start_date') }} или {{ env_var('TENANT_ID') }}. Чтобы unit test был детерминирован, эти значения тоже надо зафиксировать. Делается через секцию overrides:
unit_tests:
- name: test_filter_by_start_date
model: stg_jaffle__orders
overrides:
vars:
start_date: '2026-01-01'
env_vars:
TENANT_ID: 'test_tenant'
macros:
is_incremental: false
given:
- input: source('jaffle', 'raw_orders')
rows:
- {id: 1, order_date: '2025-12-31', total: 50}
- {id: 2, order_date: '2026-01-01', total: 100}
- {id: 3, order_date: '2026-01-02', total: 75}
expect:
rows:
- {id: 2, order_date: '2026-01-01', total: 100}
- {id: 3, order_date: '2026-01-02', total: 75}
Тест проверяет: при var('start_date') = '2026-01-01' модель должна вернуть только записи с этой даты и позже.
is_incremental: false — частый override для incremental моделей: вы хотите тестировать full-refresh path, а не incremental.
Что в expect: format и rows
expect имеет тот же набор форматов, что и given:
expect:
format: csv
fixture: expected_revenue
или dict-список (как было выше), или SQL.
dbt сравнивает результат модели с expect построчно, по всем колонкам. Если ожидаемый набор не содержит какой-то колонки, dbt игнорирует её в сравнении — это полезно: можно проверять только бизнес-логические поля, не вписывая каждый created_at.
Если хочется явно проверить, что колонка отсутствует, или что её значение NULL — пишите null в YAML: {col: null}.
Когда писать unit test, а когда data test
В хорошем junior-проекте на каждую staging модель — generic data tests на ключевые колонки, на каждую критичную mart-модель (revenue, churn, KPI) — unit test на логику.
Полный пример: unit test для агрегации
Допустим, у нас mart-модель marts__revenue_by_country, которая считает revenue по странам, исключая returned заказы:
-- models/marts/marts__revenue_by_country.sql
{{ config(materialized='table') }}
with orders as (
select * from {{ ref('stg_jaffle__orders') }}
where status != 'returned'
),
customers as (
select * from {{ ref('stg_jaffle__customers') }}
)
select
c.country,
sum(o.total_amount) as total_revenue,
count(distinct o.order_id) as order_count
from orders o
inner join customers c on o.customer_id = c.customer_id
group by c.country
Unit test:
# models/marts/_unit_tests.yml
unit_tests:
- name: test_revenue_by_country_excludes_returns
model: marts__revenue_by_country
given:
- input: ref('stg_jaffle__orders')
rows:
- {order_id: 1, customer_id: 'c1', total_amount: 100.00, status: 'completed'}
- {order_id: 2, customer_id: 'c2', total_amount: 50.00, status: 'returned'}
- {order_id: 3, customer_id: 'c1', total_amount: 75.00, status: 'completed'}
- input: ref('stg_jaffle__customers')
rows:
- {customer_id: 'c1', country: 'US'}
- {customer_id: 'c2', country: 'DE'}
expect:
rows:
- {country: 'US', total_revenue: 175.00, order_count: 2}
Запускаем:
$ dbt test --select test_revenue_by_country_excludes_returns
13:42:18 1 of 1 PASS test_revenue_by_country_excludes_returns ........... [PASS in 0.31s]
Если кто-то поменяет where status != 'returned' на where status = 'completed' — тест ещё проходит (поведение эквивалентное). А если кто-то уберёт фильтр совсем — тест поймает, потому что total_revenue станет 225.00 (включая returned), а ожидаем 175.00.
Ограничения и quirks
- Производительность. На большой модели с 20+ зависимостями unit test нужен mock каждого. YAML раздувается. Стоит писать unit-тесты только для критичных моделей, а не для каждого staging.
- DuckDB поддерживает unit tests полностью. На некоторых легаси-адаптерах могут быть quirks с типами.
- Не для incremental прямо как есть. Для incremental моделей в
overridesстоит указатьis_incremental: false, чтобы тестировать full-refresh path. Тест incremental-логики — отдельный сложный сценарий. - Не заменяет integration test. Unit test говорит «логика правильна на этом mocked-входе». Не говорит «весь пайплайн end-to-end работает».
Попробуй сам
Возьмите модель из своего проекта (или придумайте) с нетривиальной логикой — например, расчёт LTV с window function. Напишите unit test, где:
- На входе — 3 customer’а с разной историей заказов.
- В expect — расчётный LTV для каждого.
Запустите. Затем намеренно сломайте SQL (например, поменяйте SUM на AVG) и убедитесь, что тест поймал с понятным diff.
Итоги
- Unit tests (dbt 1.8+) проверяют логику модели на mocked-входе, не зависят от реальных данных.
- Структура:
given(фиктивные входы),expect(ожидаемый output),overrides(var/env_var/macros). - Fixture formats: inline dict (мало строк), CSV (много), inline SQL (сложные кейсы).
- Запуск:
dbt test --select test_type:unit. - Где применять: критичные mart-модели, нетривиальная логика, защита от регрессий.
- Не заменяет data tests — они работают на разных уровнях и должны жить вместе.
В следующем уроке — тур по самым полезным тестам из dbt_utils: equal_rowcount, expression_is_true, recency, at_least_one.