Learning Platform
Глоссарий Troubleshooting
Урок 09.02 · 26 мин
Средний
Unit testsYAMLGiven ExpectMock dataref source mocking

Структура unit_tests YAML: given / expect / format

В прошлом уроке мы разобрали, зачем нужны unit tests и какой класс багов они ловят. Этот урок — про синтаксис: где лежит файл, как описать input, как описать ожидаемый output, как мокать ref() и source().

dbt-i: первое знакомство с unit_tests

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 по этим колонкам, как ожидается.

WARNING

Если забудешь 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 становится нечитаемым. Тогда выбираем другой формат.

Форматы fixtures для unit tests

Пример 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 — integer
  • 50.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-тестом?

  1. Multiple segments: 3 клиента в 3 разных сегментах — проверить, что GROUP BY работает корректно.
  2. Same segment, multiple customers: 2 клиента в одном сегменте — проверить, что SUM аккумулирует.
  3. Empty segment: сегмент с 0 customers (через JOIN с lookup table) — проверить, что zero не теряется.
  4. NULL segment: customer без assigned segment — проверить, что либо его исключаем, либо помещаем в ‘unknown’.
  5. Edge: negative revenue (refunds): customer с returnом -> проверить, что SUM учитывает знак.

Это спецификация поведения, написанная как набор unit tests. Чем больше сценариев — тем меньше шанс продакшен-бага.


Ключевые выводы

  1. Unit tests декларируются в YAML, обычно рядом со схемой модели (_unit_tests.yml). Структура: unit_tests: - name: ..., model: ..., given: [...], expect: {...}.
  2. Каждый ref() / source() в SQL модели обязан быть представлен в given. Пустой массив rows: [] допустим, опустить input — нельзя.
  3. Три формата fixtures: dict (default, лучший для 1-5 строк), csv (для 10+ строк, inline или file), sql (для сгенерированных данных). Один тест может комбинировать.
  4. expect — те же три формата. Сравнение по содержимому строк, не по порядку. Порядок (ORDER BY) через unit test не тестируется.
  5. Имена unit tests — описательные: <model>_<scenario>. При CI провале сразу понятно, что сломалось.
  6. NULL обозначается как null в dict, пустая ячейка в csv, NULL в SQL. Типы YAML примитивные, явный каст в модели обычно нужен.
  7. Запуск: dbt test --select test_type:unit (все), unit_test:<name> (один), unit_test:<model> (все на одну модель).
  8. На DuckDB unit_tests работают полностью, нативная поддержка в dbt-duckdb 1.10+.
Проверка знанийKnowledge check
Junior пишет unit test и забывает добавить `input: ref('stg_discounts')` в given (хотя в модели есть LEFT JOIN на stg_discounts). При запуске `dbt test` выдаёт error. Что произойдёт и как исправить?
ОтветAnswer
dbt выдаст ошибку компиляции: \n\n```\nCompilation Error in test ...\n Unit test mapping is missing the ref('stg_discounts')\n```\n\nКаждый `ref()` и `source()` в SQL модели **обязан** быть представлен в `given`, даже если для конкретного сценария ты не хочешь данных оттуда. \n\nИсправить — добавить пустой input:\n\n```yaml\ngiven:\n - input: ref('stg_orders')\n rows: [...]\n - input: ref('stg_discounts')\n rows: [] # явно пусто — LEFT JOIN вернёт NULL\n```\n\nЭто design choice dbt: явное лучше неявного. Если бы dbt молча использовал реальный stg_discounts из warehouse — unit test перестал бы быть unit (стал бы integration). А если бы dbt сам ставил пустой по умолчанию — было бы легко забыть про важный input.\n\nЛайфхак: при ошибке dbt в сообщении явно указывает, какого input не хватает. Скопировал имя — добавил в given.
Проверка знанийKnowledge check
Senior просит написать unit test на модель `daily_revenue_by_country`, в которой 30 строк (по странам) ожидается на выходе. Какой формат fixture выбрать и почему?
ОтветAnswer
**csv формат** — оптимальный выбор. Inline csv в YAML делает 30 строк читаемыми:\n\n```yaml\nexpect:\n format: csv\n rows: |\n country_code,revenue_usd,order_count\n US,15000.00,150\n GB,7500.00,75\n DE,4200.00,42\n FR,3800.00,38\n ...\n```\n\nАльтернативы:\n\n- **dict** для 30 строк — нечитаемо, много визуального шума с фигурными скобками и кавычками. Каждая правка значения требует find/replace внутри YAML.\n\n- **csv file** — ещё лучше для reuse: `fixture: countries_daily_revenue`, файл в `tests/fixtures/`. Стоит использовать, если эта же fixture нужна в нескольких unit tests.\n\n- **sql формат** не подходит — генерация через generate_series подходит для синтетических ID/dates, не для конкретного содержимого (revenue per country — это не математическая формула).\n\nКлюч: **read-friendly + minimal noise**. CSV столбцы выровнены, легко править в редакторе. Один тест — одна fixture в csv, маленькие тесты — dict.

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

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. Модель содержит `LEFT JOIN {{ ref('stg_orders') }} o LEFT JOIN {{ source('raw', 'customers') }} c`. В unit test в given указан только `ref('stg_orders')`. Что произойдёт при dbt test?

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

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

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

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