Learning Platform
Глоссарий Troubleshooting
Урок 09.04 · 24 мин
Средний
Unit testsOverridesCITDDenv_varMacros

overrides и CI: вары, env_vars, макросы, TDD workflow

В прошлых уроках мы покрыли given (мокаем upstream данные) и expect (ожидаемый output). Но в реальном dbt-проекте моделями управляют не только данные. На SQL влияют:

  • Vars{{ var('start_date') }} в WHERE
  • Env vars{{ env_var('DBT_ENVIRONMENT') }} в условной логике
  • Macros — кастомные {{ format_currency(amount) }}
  • Текущая датаcurrent_date в bench-логике (зависит от часов сервера)

Если эти значения не контролировать в unit test — тест становится non-deterministic. Сегодня прошёл, завтра упал, потому что текущая дата другая или env_var изменился. Решение — секция overrides в unit_test, которая позволяет мокать всё это.

Airflow: CI интеграция DAG-тестов — аналогичный подход

Этот урок — про overrides + про интеграцию unit tests в CI (как запускать без warehouse) + TDD workflow на dbt (как писать тест перед SQL).


Секция overrides: что можно подменить

unit_tests:
  - name: my_test
    model: my_model
    given: [...]
    overrides:
      vars:
        start_date: '2026-01-01'
        end_date: '2026-01-31'
      env_vars:
        DBT_ENVIRONMENT: 'test'
        DBT_TARGET_SCHEMA: 'unit_test_schema'
      macros:
        format_currency: |
          {%- macro format_currency(amount) -%}
            'mocked_currency'
          {%- endmacro -%}
    expect: [...]

Три вида overrides:

ВидЧто мокаемЗачем
vars{{ var('xxx') }} в SQLГарантировать deterministic input для условной логики
env_vars{{ env_var('XXX') }}Тестировать разные environments (dev / prod)
macrosЛюбой {{ my_macro(...) }}Изолировать тест от внутренней реализации макроса

Также есть overrides.current_date для подмены текущей даты — но это deprecated в пользу более явных вариантов (макрос или var).


Override vars

Модель использует var('start_date') для фильтрации:

SELECT * FROM {{ ref('stg_orders') }}
WHERE order_date >= '{{ var("start_date") }}'
  AND order_date <  '{{ var("end_date") }}'

В dbt_project.yml:

vars:
  start_date: '2025-01-01'
  end_date: '2026-01-01'

В production эти vars меняются через CLI: dbt run --vars '{start_date: 2026-01-01}'. Но в unit test мы не запускаем CLI с vars — нужен explicit override:

unit_tests:
  - name: order_filter_by_date_range
    model: filtered_orders
    given:
      - input: ref('stg_orders')
        rows:
          - {order_id: 1, order_date: '2025-12-31'}  # before range
          - {order_id: 2, order_date: '2026-01-15'}  # in range
          - {order_id: 3, order_date: '2026-02-01'}  # after range
    overrides:
      vars:
        start_date: '2026-01-01'
        end_date: '2026-02-01'
    expect:
      rows:
        - {order_id: 2, order_date: '2026-01-15'}

Без overrides.vars тест бы использовал defaults из dbt_project.yml (2025-01-01 / 2026-01-01), и поведение зависело бы от глобального config — non-deterministic. С override — фиксированно.


Override env_vars

Модели часто условно зависят от env_var (production vs dev):

SELECT *
FROM {{ ref('stg_orders') }}
{%- if env_var('DBT_TARGET', 'dev') == 'prod' %}
  WHERE order_total > 0
{%- endif %}

В тесте мы можем хотеть проверить оба environment:

- name: order_filter_in_prod_excludes_zero
  model: filtered_orders
  given:
    - input: ref('stg_orders')
      rows:
        - {order_id: 1, order_total: 100}
        - {order_id: 2, order_total: 0}
  overrides:
    env_vars:
      DBT_TARGET: 'prod'
  expect:
    rows:
      - {order_id: 1, order_total: 100}  # zero excluded в prod

- name: order_filter_in_dev_keeps_zero
  model: filtered_orders
  given:
    - input: ref('stg_orders')
      rows:
        - {order_id: 1, order_total: 100}
        - {order_id: 2, order_total: 0}
  overrides:
    env_vars:
      DBT_TARGET: 'dev'
  expect:
    rows:
      - {order_id: 1, order_total: 100}
      - {order_id: 2, order_total: 0}  # zero kept в dev

Два теста, один SQL, два разных сценария — мощный паттерн для проверки условной логики.


Override macros

Сложный сценарий: у тебя есть кастомный макрос {{ format_phone_number(phone) }}, который делает E.164 нормализацию. В одной модели:

SELECT
    customer_id,
    {{ format_phone_number('phone') }} AS phone_normalized
FROM {{ ref('stg_customers') }}

Если ты тестируешь модель и тестируешь macro отдельно, то для unit test модели сам macro может быть мокан:

- name: customer_with_mocked_phone_normalization
  model: customer_with_phone
  given:
    - input: ref('stg_customers')
      rows:
        - {customer_id: 1, phone: '+1-555-123-4567'}
  overrides:
    macros:
      format_phone_number: |
        {%- macro format_phone_number(column) -%}
          UPPER({{ column }})
        {%- endmacro -%}
  expect:
    rows:
      - {customer_id: 1, phone_normalized: '+1-555-123-4567'.upper()}

(Это академический пример — обычно макросы тестируются отдельно от моделей и здесь мы изолируемся.)

Реальная польза override macros — когда макрос делает что-то сложно-моделируемое в unit test: вызывает dbt_utils.generate_surrogate_key, делает run_query, обращается к metadata. Мокаешь — изолируешь.

NOTE

Часто override macros не нужен. Тестировать модель как-она-есть, со всеми вызываемыми макросами — это интеграционный мини-тест между моделью и её macros. Если кому-то ломает — лучше так. Override используется только когда macro делает что-то external (run_query, env_var, file IO).


Unit tests в CI: запуск без warehouse

Главное преимущество unit tests — они не требуют warehouse. dbt компилирует SQL с моками и выполняет на любом адаптере, который понимает базовый SQL.

В CI типичная схема:

# .github/workflows/dbt-ci.yml
name: dbt CI
on: [pull_request]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - run: pip install dbt-core==1.10.21 dbt-duckdb==1.10.1
      - run: dbt deps
      - run: dbt parse  # проверяем YAML / refs
      - run: dbt test --select test_type:unit
        env:
          DBT_PROFILES_DIR: ./profiles

Что тут:

  1. Python + dbt-duckdb (ephemeral, файловый).
  2. dbt deps — packages из packages.yml.
  3. dbt parse — статическая проверка моделей и unit tests (YAML / refs).
  4. dbt test --select test_type:unit — запускает только unit tests, без dbt run.

Тайминг: parse + unit tests на проекте 100 моделей и 50 unit tests — 30-60 секунд. Это первый gate в CI: если unit tests падают, не идём дальше (data tests на CI warehouse — это уже минуты).

Developer
CI: Unit
CI: Data (Slim)
Main branch
Production
1. PR opened2. unit green -> trigger data job3. data green -> merge allowed4. scheduled deploy

DuckDB как ephemeral CI warehouse

В CI часто используется ephemeral DuckDB для unit tests — никакого state, файл создаётся на старте job и удаляется в конце.

profiles/profiles.yml:

my_project:
  target: ci
  outputs:
    ci:
      type: duckdb
      path: ':memory:'  # in-memory, не на диск
      threads: 4

:memory: означает DuckDB существует только в RAM процесса. Для unit tests этого достаточно: dbt компилирует SQL с моками, выполняет, проверяет результат, процесс завершается.

Плюсы:

  • Fastest setup — нет I/O.
  • No cleanup — процесс умер, DuckDB исчез.
  • No state leakage between CI runs.

Минусы:

  • Нельзя сохранить результат для debug — нужен path: './ci.duckdb' если хочется артефакт.

TDD workflow: red-green-refactor на dbt

Test-driven development — стиль разработки, где тест пишется перед кодом. Для dbt-моделей это особенно полезно, потому что unit test — это спецификация поведения.

Цикл:

TDD цикл для dbt модели
1. RedПишем unit test с given (mock data) и expect (ожидаемый output) ДО написания SQL. dbt test упадёт, потому что модель ещё не существует или возвращает не то. Это OK - это red фаза.
2. GreenПишем минимальный SQL, чтобы unit test прошёл. Не оптимизируем, не красим, просто чтобы expect совпал с actual. Это minimum viable transformation.
3. RefactorРефакторинг SQL для качества: понятные CTE, удалить dead code, проследить style guide. Unit test остаётся зелёным - значит логика сохранена.

Шаг 1 — Red

# models/marts/_unit_tests.yml
unit_tests:
  - name: customer_lifetime_value_basic
    model: customer_lifetime_value
    given:
      - input: ref('stg_customers')
        rows:
          - {customer_id: 1, first_name: 'Alice'}
      - input: ref('stg_orders')
        rows:
          - {order_id: 1, customer_id: 1, order_total: 50.00}
          - {order_id: 2, customer_id: 1, order_total: 75.00}
    expect:
      rows:
        - {customer_id: 1, first_name: 'Alice', lifetime_value: 125.00, total_orders: 2}

Запуск:

dbt test --select unit_test:customer_lifetime_value_basic

-> Падает. Модель не существует. Это red.

Шаг 2 — Green

-- models/marts/customer_lifetime_value.sql
SELECT
    c.customer_id,
    c.first_name,
    SUM(o.order_total) AS lifetime_value,
    COUNT(o.order_id) AS total_orders
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

Запуск:

dbt test --select unit_test:customer_lifetime_value_basic

-> Прошёл. Green.

Шаг 3 — Refactor

Допустим, hard-coded LEFT JOIN хотим заменить на CTE с windowing, или добавить ORDER BY, или вынести агрегаты в CTE. Любая правка — unit test должен остаться зелёным.

WITH order_summary AS (
    SELECT
        customer_id,
        SUM(order_total) AS lifetime_value,
        COUNT(*) AS total_orders
    FROM {{ ref('stg_orders') }}
    GROUP BY customer_id
)
SELECT
    c.customer_id,
    c.first_name,
    COALESCE(o.lifetime_value, 0) AS lifetime_value,
    COALESCE(o.total_orders, 0) AS total_orders
FROM {{ ref('stg_customers') }} c
LEFT JOIN order_summary o ON o.customer_id = c.customer_id

Wait — этот рефакторинг изменил поведение! Раньше для клиента без заказов lifetime_value = NULL. Теперь COALESCE(..., 0) -> lifetime_value = 0. Unit test (с одним customer и двумя orders) всё ещё проходит, но если бы был тест «customer without orders» — он бы упал.

Урок: unit tests ловят регрессии в покрытых сценариях. Если сценарий не покрыт — рефактор может сломать поведение незаметно. Хороший набор — все основные сценарии (happy + edge), не только один.

Добавим второй тест и повторим:

  - name: customer_lifetime_value_no_orders
    model: customer_lifetime_value
    given:
      - input: ref('stg_customers')
        rows:
          - {customer_id: 2, first_name: 'Bob'}
      - input: ref('stg_orders')
        rows: []
    expect:
      rows:
        - {customer_id: 2, first_name: 'Bob', lifetime_value: 0, total_orders: 0}

Тест проходит -> рефакторинг корректный. Хорошие unit tests = спецификация поведения.


Антипаттерны и ловушки

  1. Тест без явных типов. {order_total: 0.1} парсится как float, аггрегация может выдать 0.30000000000000004. Используй '0.10' (string-decimal) или csv с DECIMAL.

  2. Тест с current_date() без override. Если модель ссылается на CURRENT_DATE, тест становится non-deterministic. Решение: использовать var с default = current_date и override этого var в test.

    WHERE order_date не меньше '`{{ var("today", run_started_at.date()) }}`'::date
    overrides:
      vars:
        today: '2026-05-19'
  3. Тест без data tests на той же модели. Unit test покрывает логику, но не качество данных. На критической модели нужны и unit tests, и data tests (unique, not_null, …).

  4. Слишком много fixtures в одном тесте. Если в given 50 строк — это не unit test, это integration test. Разбей на несколько мелких unit tests или вынеси в csv file.

  5. Имена тестов без описательности. test_1, test_2, customer_lifetime_value_test — плохие. customer_lifetime_value_two_orders_active_user — хорошее. CI вывод сразу понятен.

  6. Mock vars в unit test, но не override в dbt_project.yml defaults. Если CI запускает dbt test --vars '{...}', и var в overrides — поведение может различаться. Чисто полагаться на override (или явно задавать default в dbt_project.yml).

  7. Не запускать unit tests в pre-commit. Команды, у которых unit tests запускаются только в CI, обнаруживают регрессии после push. Локальный pre-commit dbt test --select test_type:unit --select state:modified+ — мгновенный feedback.


Попробуй сам

В своём dbt-проекте:

  1. Выбери модель с CASE + JOIN + var. Это твоя цель.
  2. Напиши 3 unit tests: happy path, один edge case (NULL/empty/boundary), один сценарий с overrides vars.
  3. Прогон: dbt test --select unit_test:my_model. Должны быть зелёные.
  4. Удали один важный CASE или COALESCE в SQL. Прогон — должен упасть.
  5. Восстанови. Прогон — зелёный.
  6. Внеси изменение (рефактор: переместить логику в CTE). Unit tests должны остаться зелёными.

Это TDD-цикл в одном упражнении. После 3-5 таких циклов это становится привычкой.


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

  1. overrides секция в unit_test позволяет мокать vars, env_vars, macros. Это делает тесты deterministic — не зависят от global config, environment, или внутренней реализации macros.
  2. vars override нужен когда модель использует var(...) для фильтрации или контроля логики.
  3. env_vars override нужен для тестирования conditional-логики (prod vs dev).
  4. macros override — редкая ситуация: только когда macro делает что-то external (run_query, env_var, IO).
  5. Unit tests в CI запускаются без warehouse — ephemeral DuckDB или :memory:. Тайминг: 30-60 секунд на проект 100 моделей.
  6. CI pipeline: unit-job -> data-job -> merge. Unit-job — первый gate, экономит время на data layer при поломке логики.
  7. TDD workflow: пиши unit test первый (red) -> минимальный SQL чтобы пройти (green) -> рефактор с zelеным тестом. Хороший набор тестов = спецификация поведения.
  8. Антипаттерны: тесты без типов (float точность), current_date() без override (non-deterministic), отсутствие data tests рядом (unit ≠ data), большие fixtures (это integration), нечитаемые имена тестов.
Проверка знанийKnowledge check
Модель `weekly_revenue` использует `WHERE order_date >= CURRENT_DATE - INTERVAL '7 day'`. Unit test проходит локально, но в CI падает с разницей в expect.row_count. Что не так?
ОтветAnswer
Это classic non-determinism. `CURRENT_DATE` зависит от системного времени, а в CI каждый запуск — другой день. Сегодня unit test с `order_date: '2026-05-13'` попадает в окно, через 8 дней — за окном.\n\nКорректное решение:\n\n1. В SQL модели — использовать **var с default** вместо hard-coded `CURRENT_DATE`:\n\n```sql\n WHERE order_date не меньше '{{ var("reference_date", run_started_at.date()) }}'::date - INTERVAL '7 day'\n```\n\n2. В unit test — **override этого var**:\n\n```yaml\n overrides:\n vars:\n reference_date: '2026-05-15'\n given:\n - input: ref('stg_orders')\n rows:\n - {order_id: 1, order_date: '2026-05-13'} # within 7d of 2026-05-15\n - {order_id: 2, order_date: '2026-05-01'} # outside\n expect:\n rows:\n - {order_id: 1, order_date: '2026-05-13'}\n```\n\nТеперь тест deterministic — независимо от дня запуска, var фиксирован. Дополнительно в production runs можно `dbt run --vars '{reference_date: 2026-05-19}'` если нужен другой день.\n\nЭто общий принцип: **в SQL не должно быть прямой ссылки на CURRENT_DATE, NOW(), random()**, всё мокируемое выносится в var.
Проверка знанийKnowledge check
Команда хочет добавить unit tests в CI, но в jobs нет подключения к prod Snowflake. Как настроить unit-tests job в GitHub Actions, чтобы запускалось без warehouse?
ОтветAnswer
Использовать **DuckDB ephemeral** профиль в CI. Unit tests dbt — это compile-time проверка, не нужно реальное подключение к warehouse:\n\n```yaml\n# .github/workflows/dbt-ci.yml\nname: dbt CI\non: [pull_request]\n\njobs:\n unit-tests:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-python@v5\n with: { python-version: '3.11' }\n - run: pip install dbt-core==1.10.21 dbt-duckdb==1.10.1\n - run: dbt deps\n - run: dbt parse\n - run: dbt test --select test_type:unit\n env:\n DBT_PROFILES_DIR: ./profiles\n```\n\nWith profile в `./profiles/profiles.yml`:\n\n```yaml\nmy_project:\n target: ci\n outputs:\n ci:\n type: duckdb\n path: ':memory:'\n threads: 4\n```\n\nКлюч — `path: ':memory:'`. DuckDB существует только в RAM CI-runner'a. Никаких сетевых подключений, секретов, prod Snowflake. \n\nЧто работает:\n- dbt parse (статическая проверка)\n- unit tests (моки через given/expect)\n- Базовый `dbt run --target ci` (на чистой DuckDB)\n\nЧто **не** работает:\n- ref() на seeds, которые не загружены\n- source() на реальные tables (надо мокать или использовать seeds в CI seed)\n- Snowflake-specific SQL (warehouse syntax differences)\n\nДля unit tests это idealно — изоляция логики от инфры. data tests (которые нужно `unique`/`not_null` на реальной таблице) уже отдельный job, на CI warehouse.

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

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. Модель использует `WHERE order_date >= CURRENT_DATE - INTERVAL '7 day'`. Unit test проходит локально, но через неделю в CI падает: actual.row_count ≠ expect.row_count. В чём проблема?

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

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

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

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