Learning Platform
Глоссарий Troubleshooting
Урок 10.03 · 22 мин
Начальный
dbttestsunit testsgiven-when-then

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}

Что здесь происходит:

  1. model — имя модели, которую проверяем (без ref()).
  2. given — список входов с фиктивными данными. Каждый элемент: input: ref('upstream_model') и rows: [...].
  3. 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.

TIP

Если хочется явно проверить, что колонка отсутствует, или что её значение 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, где:

  1. На входе — 3 customer’а с разной историей заказов.
  2. В expect — расчётный LTV для каждого.

Запустите. Затем намеренно сломайте SQL (например, поменяйте SUM на AVG) и убедитесь, что тест поймал с понятным diff.

Проверка знанийKnowledge check
Чем отличается unit test от singular data test? Приведи пример сценария, для которого нужен именно unit test.
ОтветAnswer
Singular data test: SQL-запрос против РЕАЛЬНЫХ данных в warehouse. Проверяет инвариант на тех данных, что есть сейчас. Если данные кривые — упадёт. Если SQL модели кривой, но данные случайно прошли проверку — singular пройдёт. Unit test: SQL-логика модели проверяется на MOCKED входе. Подменяешь ref() и source() фиктивными строками, сравниваешь output модели с ожидаемым. Реальные данные warehouse не нужны. Пример, где нужен unit test: модель marts__revenue_by_country с фильтром 'where status != returned'. Junior-разработчик случайно меняет на 'where status = completed'. Семантически идентично — но если завтра добавится новый status (например, 'in_review'), он ломает unit test, а data test 'revenue не меньше 0' не замечает. Unit test ловит регрессии бизнес-логики до того, как они дотянутся до production-данных.
Проверка знанийKnowledge check
В unit test нужно протестировать модель, которая использует var('start_date'). Без override теста значение var берётся из dbt_project.yml. Как сделать тест детерминированным?
ОтветAnswer
Использовать секцию overrides в unit_tests YAML: unit_tests: - name: test_filter_by_start_date model: my_model overrides: vars: start_date: '2026-01-01' given: ... expect: ... Это переопределит var ТОЛЬКО для этого теста, не трогая dbt_project.yml. Аналогично работают overrides.env_vars (для env_var()) и overrides.macros (для подмены macros — частый паттерн для incremental: 'is_incremental: false'). Без override unit test будет использовать значение из dbt_project.yml — это значит, тест зависит от глобальной конфигурации, что нарушает идемпотентность и делает CI хрупким. Override — best practice для любого var/env_var/macro, который влияет на логику модели.

Итоги

  • 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.

Unit tests: структура YAML, given / expect / format — углублённый разбор

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 7. В чём принципиальная разница между unit test и data test в dbt?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4