Структура unit_tests YAML: given / expect / format
В прошлом уроке мы разобрали, зачем нужны unit tests и какой класс багов они ловят. Этот урок — про синтаксис: где лежит файл, как описать input, как описать ожидаемый output, как мокать ref() и source().
Unit tests декларируются в YAML и живут вместе со схемой моделей. Каждый unit test — это пара given (входные мок-данные) + expect (ожидаемый результат). dbt компилирует модель, подставляя мок-CTE вместо реальных ref(...), выполняет SQL и сравнивает строка-в-строку с expect.
Минимальный пример: один unit test
Допустим, есть простая модель models/marts/customer_summary.sql:
SELECT
customer_id,
UPPER(first_name) AS first_name,
COALESCE(email, '[email protected]') AS email,
CASE
WHEN total_orders > 0 THEN 'active'
ELSE 'inactive'
END AS status
FROM {{ ref('stg_customers') }}
YAML с unit test лежит в models/marts/_unit_tests.yml:
version: 2
unit_tests:
- name: customer_summary_basic_transform
model: customer_summary
given:
- input: ref('stg_customers')
rows:
- {customer_id: 1, first_name: 'alice', email: '[email protected]', total_orders: 5}
- {customer_id: 2, first_name: 'bob', email: null, total_orders: 0}
expect:
rows:
- {customer_id: 1, first_name: 'ALICE', email: '[email protected]', status: 'active'}
- {customer_id: 2, first_name: 'BOB', email: '[email protected]', status: 'inactive'}
Что здесь:
name— уникальное имя теста. Появляется вdbt testвыводе.model— какую модель тестируем. Имя безref(...).given— список input-блоков. Каждый input соответствует одномуref()илиsource()в модели.given[].input— выражениеref('...')илиsource('...', '...')— в кавычках, как в SQL.given[].rows— список словарей. Каждый словарь — одна строка.expect— ожидаемый результат после выполнения SQL модели.expect.rows— список словарей с ожидаемыми значениями колонок.
Запуск:
dbt test --select test_type:unit
Если все expect-строки совпадают с actual — тест зелёный. Если хоть одна не совпадает или количество строк отличается — тест красный, dbt покажет diff.
Связь между given inputs и ref/source в модели
Это часто путает. Каждый ref() или source() в SQL модели должен быть представлен в given, иначе unit test упадёт с ошибкой «no mock provided for ref». Если в модели:
SELECT ...
FROM {{ ref('stg_orders') }} o
LEFT JOIN {{ ref('stg_discounts') }} d ON ...
LEFT JOIN {{ source('raw', 'customers') }} c ON ...
то в given должно быть три блока:
given:
- input: ref('stg_orders')
rows: [...]
- input: ref('stg_discounts')
rows: [...]
- input: source('raw', 'customers')
rows: [...]
Даже если для конкретного теста ты не хочешь подсовывать данные в stg_discounts — нужно явно дать пустой rows: []. Тогда LEFT JOIN вернёт NULL по этим колонкам, как ожидается.
Если забудешь mock хоть для одного ref или source, dbt выдаст ошибку компиляции: Unit test mapping is missing the ref/source 'X'. Если хочешь явно «пусто» — пиши rows: []. Опускать нельзя.
Форматы fixtures: dict, csv, sql
Базовый формат — dict (словарь YAML). Это то, что мы видели выше: rows: - {col1: val1, col2: val2}. Удобно для 2-5 строк и небольшого числа колонок.
Когда строк или колонок много — dict становится нечитаемым. Тогда выбираем другой формат.
Пример CSV (inline):
given:
- input: ref('stg_orders')
format: csv
rows: |
order_id,customer_id,order_total,order_date
1,100,50.00,2026-01-01
2,100,75.00,2026-01-15
3,200,100.00,2026-01-20
Пример CSV file:
given:
- input: ref('stg_orders')
format: csv
fixture: orders_basic
И отдельно файл tests/fixtures/orders_basic.csv:
order_id,customer_id,order_total,order_date
1,100,50.00,2026-01-01
2,100,75.00,2026-01-15
Папка по default — tests/fixtures/, путь настраивается через fixture-paths в dbt_project.yml.
Пример SQL:
given:
- input: ref('stg_orders')
format: sql
rows: |
SELECT
i AS order_id,
(i % 10) + 100 AS customer_id,
i * 10.50 AS order_total,
DATE '2026-01-01' + i * INTERVAL '1 day' AS order_date
FROM generate_series(1, 100) AS s(i)
SQL-формат генерит 100 строк через generate_series. Полезно для performance-related unit tests или property-based тестирования.
Когда что использовать:
- dict для большинства тестов (90% случаев)
- csv inline для 10+ строк (например, тесты на агрегаты с разными бакетами)
- csv file когда хочешь переиспользовать ту же fixture в нескольких тестах
- sql только для generated data в редких случаях
expect: тот же формат, что given
expect принимает те же три формата: dict (default), csv, sql. Чаще всего — dict, потому что ожидаемых строк обычно мало (мы тестируем конкретный сценарий).
expect:
format: csv # явно указываем для парсинга csv
rows: |
customer_id,lifetime_value
100,225.00
200,100.00
Важное правило: dbt сравнивает строки по ключам, не по порядку. Порядок строк в expect и фактический порядок в SELECT не имеют значения, главное — что набор строк совпадает. Это означает: тесты на ORDER BY через unit test писать нельзя (это deterministic-проблема — порядок не проверяется).
Типы данных и nullability
Это место, где новички спотыкаются. В YAML по умолчанию все значения — строки (или числа, если без кавычек). Когда dbt компилирует CTE из dict-rows, он пытается угадать тип. Иногда угадывает неверно.
rows:
- {order_id: 1, order_total: 50.00, order_date: '2026-01-15'}
Здесь:
1— integer50.00— decimal'2026-01-15'— string (потому что в кавычках), и dbt НЕ кастит автоматически к date.
Если модель ожидает order_date как date — нужен явный каст в SQL модели (CAST(order_date AS DATE)) или мы должны указать тип через type mapping:
given:
- input: ref('stg_orders')
rows:
- {order_id: 1, order_date: '2026-01-15'}
# type: не работает на уровне rows
К сожалению, dbt unit_tests не имеют explicit column type annotation. Если тип нужно задать жёстко — используй csv format с явными кастами, или sql format с явным CAST.
NULL обозначается одинаково во всех форматах:
# dict
- {customer_id: 1, email: null}
# csv (двойная запятая = NULL для большинства колонок)
order_id,email,name
1,,Alice
# sql
SELECT 1 AS customer_id, NULL AS email
Несколько unit tests на одну модель
Один YAML — много тестов на разные сценарии:
version: 2
unit_tests:
- name: customer_summary_active_user
model: customer_summary
given:
- input: ref('stg_customers')
rows:
- {customer_id: 1, first_name: 'alice', email: '[email protected]', total_orders: 5}
expect:
rows:
- {customer_id: 1, first_name: 'ALICE', email: '[email protected]', status: 'active'}
- name: customer_summary_inactive_user
model: customer_summary
given:
- input: ref('stg_customers')
rows:
- {customer_id: 2, first_name: 'bob', email: null, total_orders: 0}
expect:
rows:
- {customer_id: 2, first_name: 'BOB', email: '[email protected]', status: 'inactive'}
- name: customer_summary_negative_orders_edge
model: customer_summary
given:
- input: ref('stg_customers')
rows:
- {customer_id: 3, first_name: 'charlie', email: '[email protected]', total_orders: -1}
expect:
rows:
- {customer_id: 3, first_name: 'CHARLIE', email: '[email protected]', status: 'inactive'}
Каждый тест — отдельный scenario. Convention naming: <model>_<scenario> или <model>_<input_state>_<expected_behavior>. Имена должны быть описательными, чтобы при провале CI было понятно, что сломалось.
Запуск и selectors
Запустить все unit tests проекта:
dbt test --select test_type:unit
Запустить unit tests конкретной модели:
dbt test --select unit_test:customer_summary
Запустить один конкретный тест:
dbt test --select unit_test:customer_summary_active_user
В CI ты обычно делаешь --select test_type:unit глобально как первый шаг. На локальной разработке — unit_test:my_model для итеративной работы.
Полный пример: модель с двумя ref и LEFT JOIN
Возьмём более реалистичную модель models/marts/fct_customer_orders.sql:
SELECT
c.customer_id,
c.first_name,
COUNT(o.order_id) AS total_orders,
SUM(o.order_total) AS lifetime_value,
AVG(o.order_total) AS avg_order_value,
MAX(o.order_date) AS last_order_date
FROM {{ ref('stg_customers') }} c
LEFT JOIN {{ ref('stg_orders') }} o
ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.first_name
Unit test на сценарий «клиент с двумя заказами»:
unit_tests:
- name: fct_customer_orders_two_orders
model: fct_customer_orders
given:
- input: ref('stg_customers')
rows:
- {customer_id: 100, first_name: 'Alice'}
- input: ref('stg_orders')
rows:
- {order_id: 1, customer_id: 100, order_total: 50.00, order_date: '2026-01-01'}
- {order_id: 2, customer_id: 100, order_total: 75.00, order_date: '2026-01-15'}
expect:
rows:
- customer_id: 100
first_name: 'Alice'
total_orders: 2
lifetime_value: 125.00
avg_order_value: 62.50
last_order_date: '2026-01-15'
Unit test на edge case «клиент без заказов» (LEFT JOIN -> NULL aggregates):
- name: fct_customer_orders_no_orders
model: fct_customer_orders
given:
- input: ref('stg_customers')
rows:
- {customer_id: 200, first_name: 'Bob'}
- input: ref('stg_orders')
rows: [] # пустой — Bob не имеет заказов
expect:
rows:
- customer_id: 200
first_name: 'Bob'
total_orders: 0
lifetime_value: null
avg_order_value: null
last_order_date: null
Этот второй тест ловит классическую ошибку: если кто-то по ошибке заменит LEFT JOIN на INNER JOIN, Bob исчезнет из результата, и тест упадёт с понятным diff: «expected 1 row, got 0 rows».
Проверь себя
Допустим, модель revenue_by_segment агрегирует по customer_segment. Какие сценарии стоит покрыть unit-тестом?
- Multiple segments: 3 клиента в 3 разных сегментах — проверить, что GROUP BY работает корректно.
- Same segment, multiple customers: 2 клиента в одном сегменте — проверить, что SUM аккумулирует.
- Empty segment: сегмент с 0 customers (через JOIN с lookup table) — проверить, что zero не теряется.
- NULL segment: customer без assigned segment — проверить, что либо его исключаем, либо помещаем в ‘unknown’.
- Edge: negative revenue (refunds): customer с returnом -> проверить, что SUM учитывает знак.
Это спецификация поведения, написанная как набор unit tests. Чем больше сценариев — тем меньше шанс продакшен-бага.
Ключевые выводы
- Unit tests декларируются в YAML, обычно рядом со схемой модели (
_unit_tests.yml). Структура:unit_tests: - name: ..., model: ..., given: [...], expect: {...}. - Каждый
ref()/source()в SQL модели обязан быть представлен вgiven. Пустой массивrows: []допустим, опустить input — нельзя. - Три формата fixtures: dict (default, лучший для 1-5 строк), csv (для 10+ строк, inline или file), sql (для сгенерированных данных). Один тест может комбинировать.
expect— те же три формата. Сравнение по содержимому строк, не по порядку. Порядок (ORDER BY) через unit test не тестируется.- Имена unit tests — описательные:
<model>_<scenario>. При CI провале сразу понятно, что сломалось. - NULL обозначается как
nullв dict, пустая ячейка в csv,NULLв SQL. Типы YAML примитивные, явный каст в модели обычно нужен. - Запуск:
dbt test --select test_type:unit(все),unit_test:<name>(один),unit_test:<model>(все на одну модель). - На DuckDB unit_tests работают полностью, нативная поддержка в dbt-duckdb 1.10+.