Learning Platform
Глоссарий Troubleshooting
Урок 09.01 · 22 мин
Средний
Unit testsData testsTesting strategyTDDTransformation logic

Зачем unit tests: transformation logic vs data quality

В dbt I мы научились писать data testsunique, 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
Data tests vs 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 уровень изоляции другой.

Unit test scope в dbt
unit testОдин YAML файл с fixture. given block - моки upstream моделей (ref/source). expect block - ожидаемые строки. dbt компилирует модель с подменёнными CTE и сравнивает результат.
тестирует одну модельПолная SQL логика одной модели. Не часть SELECT, не одно выражение CASE. Тестируем модель целиком с её JOIN, WHERE, GROUP BY, оконными функциями.
isolated от upstreamUpstream модели мокаются через given. Транзитивные зависимости не запускаются. Это быстро, изолированно, deterministic.

То есть в dbt:

  • Unit test НЕ тестирует одну строчку SQL. Тестирует модель целиком с её JOIN, агрегатами, оконными функциями.
  • Unit test НЕ запускает upstream. Если ты пишешь unit test на mart_customer_orders, fixtures для stg_orders и stg_discounts ты задаёшь руками — реальный SQL stg_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 не бесплатны:

  1. Время на написание fixture. На каждый сценарий ты пишешь 3-15 строк YAML с моками. Чем больше upstream — тем больше fixture-строк. Для модели с 5 ref’ами unit-тест на каждый сценарий — 50+ строк YAML.
  2. Поддержка. Когда меняется schema upstream, fixtures нужно обновить. Если у stg_orders появилась новая колонка is_returned — все unit tests, которые мокают stg_orders, нужно дополнить (или оставить NULL и принять).
  3. Coverage gap risk. Можно написать unit-тест, который тестирует “happy path” и пропустить корнер-кейс. Покрытие == дисциплина + code review.

Стоимость окупается на критических моделях и активной разработке. Для рассчётов revenue, finance, churn, attribution — обязательно. Для базовых staging-моделей — нет.


Архитектура: где unit tests запускаются

Developer
Git / PR
CI: unit tests
Slim CI: data tests
Production
1. git push (модель + fixture)2. dbt test --select test_type:unit3. if unit ok: dbt build modified+4. merge -> scheduled run

Ключевая идея — 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-тестировании:

  1. Парсит YAML с unit_tests.
  2. Для каждого given.input создаёт CTE из rows.
  3. Компилирует SQL модели, заменяя {{ ref(...) }} на этот CTE.
  4. Выполняет SQL и сравнивает результат с expect.rows (через UNION ALL trick).
  5. Если разница — выдаёт 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 сценарии нужны? Подумай и сравни:

  1. Happy path active: клиент с last_order_date = current_date - 30 day -> is_churned = FALSE.
  2. Happy path churned: last_order_date = current_date - 100 day -> is_churned = TRUE.
  3. Edge: exactly 90 days: last_order_date = current_date - 90 day -> надо проверить — модель использует <, значит на ровно 90 -> FALSE. Это правильно? Возможно бизнес имел в виду <=. Тест поднимет вопрос.
  4. Edge: NULL last_order_date: -> is_churned = TRUE. Это специальный сценарий, который CASE покрывает явно.
  5. 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.


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

  1. Data tests проверяют качество данных в warehouse (unique, not_null, accepted_values). Они нужны для всех моделей с PK / FK / categorical columns.
  2. Unit tests проверяют логику трансформации на mock-данных. Они нужны для моделей со сложной бизнес-логикой (JOIN, CASE, агрегаты, оконные функции, финансы).
  3. Unit tests изолированы от upstream — fixture-строки задаются вручную, ref/source мокаются. Тест запускается без warehouse и за секунды.
  4. Стоимость unit tests — время на fixture + поддержка при изменении schema. Окупается на критических моделях.
  5. В CI пайплайне unit tests запускаются первыми, перед data tests. Они дешевле и быстрее, режут feedback loop.
  6. На DuckDB unit tests работают полностью — dbt-duckdb 1.10+ поддерживает их nativel.
  7. Эвристика: «если над логикой думал >30 минут — пиши unit test». Тривиальные select-from — не нуждаются.
Проверка знанийKnowledge check
Команда жалуется: 'у нас data tests зелёные, но дашборд показывает revenue = -$200 средний чек'. Аналитик находит, что bug — в формуле net_revenue. Какой тип теста должны были написать?
ОтветAnswer
Unit test. Data tests (`unique`, `not_null`, `accepted_values`) проверяют **качество данных**, но не **корректность логики**. Если формула `order_total - discount` была сломана из-за разной единицы измерения (рубли vs копейки), data test `net_revenue не меньше 0` ловил это уже постфактум, на проде. \n\nUnit test ловит до коммита: \n\n```yaml\nunit_tests:\n - name: net_revenue_formula\n model: fct_customer_orders\n given:\n - input: ref('stg_orders')\n rows: [{order_id: 1, order_total: 100}]\n - input: ref('stg_discounts')\n rows: [{order_id: 1, discount_amount: 10}]\n expect:\n rows: [{order_id: 1, net_revenue: 90}]\n```\n\nЕсли кто-то перепутает unit (копейки vs рубли) — тест упадёт с понятным diff: expected 90.00, got -4900.00. \n\nВывод: data tests + unit tests — **два слоя**. Data test говорит «данные плохие», unit test говорит «формула плохая». Оба нужны.
Проверка знанийKnowledge check
Senior говорит: 'на простой модели stg_users (SELECT id, email, created_at FROM raw.users WHERE deleted_at IS NULL) unit test не нужен'. Согласны или нет?
ОтветAnswer
Согласны. Эвристика: **unit test пишется на сложную логику, не на тривиальный select-from**. На `stg_users` достаточно data tests:\n\n- `unique` на `id`\n- `not_null` на `id` и `email`\n- Опционально `not_null` на `created_at`\n\nЕсли логика модели сводится к `SELECT col1, col2, col3 FROM source [WHERE simple]`, шанс перепутать что-то близок к нулю. Unit test добавит overhead — 5-10 строк YAML и поддержка — но не поймает реальный bug.\n\nКогда нужен unit test на staging:\n\n- Конверсия типов нетривиальная (например, парсинг JSON, парсинг дат разных форматов)\n- Бизнес-логика заходит в staging (хотя по convention лучше держать в intermediate)\n- Колонки derived через сложный CASE\n\nВ остальных случаях — data tests достаточно. Время senior'а лучше тратить на критические marts: revenue, churn, attribution.

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

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. Team шипит модель `net_revenue = order_total - discount_amount`. На staging тесты `unique`, `not_null`, `net_revenue >= 0` все зелёные. После релиза маркетинг включил купоны и net_revenue ушёл в большие минусы из-за разных единиц (рубли vs копейки). Какой тип тестов поймал бы это до прода?

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

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

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

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