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. Мокаешь — изолируешь.
Часто 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
Что тут:
- Python + dbt-duckdb (ephemeral, файловый).
dbt deps— packages изpackages.yml.dbt parse— статическая проверка моделей и unit tests (YAML / refs).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 — это уже минуты).
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 — это спецификация поведения.
Цикл:
Шаг 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 = спецификация поведения.
Антипаттерны и ловушки
-
Тест без явных типов.
{order_total: 0.1}парсится как float, аггрегация может выдать0.30000000000000004. Используй'0.10'(string-decimal) или csv с DECIMAL. -
Тест с
current_date()без override. Если модель ссылается наCURRENT_DATE, тест становится non-deterministic. Решение: использовать var сdefault = current_dateи override этого var в test.WHERE order_date не меньше '`{{ var("today", run_started_at.date()) }}`'::dateoverrides: vars: today: '2026-05-19' -
Тест без data tests на той же модели. Unit test покрывает логику, но не качество данных. На критической модели нужны и unit tests, и data tests (
unique,not_null, …). -
Слишком много fixtures в одном тесте. Если в
given50 строк — это не unit test, это integration test. Разбей на несколько мелких unit tests или вынеси в csv file. -
Имена тестов без описательности.
test_1,test_2,customer_lifetime_value_test— плохие.customer_lifetime_value_two_orders_active_user— хорошее. CI вывод сразу понятен. -
Mock vars в unit test, но не override в
dbt_project.ymldefaults. Если CI запускаетdbt test --vars '{...}', и var вoverrides— поведение может различаться. Чисто полагаться на override (или явно задавать default вdbt_project.yml). -
Не запускать unit tests в pre-commit. Команды, у которых unit tests запускаются только в CI, обнаруживают регрессии после push. Локальный pre-commit
dbt test --select test_type:unit --select state:modified+— мгновенный feedback.
Попробуй сам
В своём dbt-проекте:
- Выбери модель с CASE + JOIN + var. Это твоя цель.
- Напиши 3 unit tests: happy path, один edge case (NULL/empty/boundary), один сценарий с overrides vars.
- Прогон:
dbt test --select unit_test:my_model. Должны быть зелёные. - Удали один важный CASE или COALESCE в SQL. Прогон — должен упасть.
- Восстанови. Прогон — зелёный.
- Внеси изменение (рефактор: переместить логику в CTE). Unit tests должны остаться зелёными.
Это TDD-цикл в одном упражнении. После 3-5 таких циклов это становится привычкой.
Ключевые выводы
- overrides секция в unit_test позволяет мокать
vars,env_vars,macros. Это делает тесты deterministic — не зависят от global config, environment, или внутренней реализации macros. - vars override нужен когда модель использует
var(...)для фильтрации или контроля логики. - env_vars override нужен для тестирования conditional-логики (prod vs dev).
- macros override — редкая ситуация: только когда macro делает что-то external (run_query, env_var, IO).
- Unit tests в CI запускаются без warehouse — ephemeral DuckDB или
:memory:. Тайминг: 30-60 секунд на проект 100 моделей. - CI pipeline: unit-job -> data-job -> merge. Unit-job — первый gate, экономит время на data layer при поломке логики.
- TDD workflow: пиши unit test первый (red) -> минимальный SQL чтобы пройти (green) -> рефактор с zelеным тестом. Хороший набор тестов = спецификация поведения.
- Антипаттерны: тесты без типов (float точность),
current_date()без override (non-deterministic), отсутствие data tests рядом (unit ≠ data), большие fixtures (это integration), нечитаемые имена тестов.