Capstone: tests, contracts, dbt-expectations
В этом уроке поднимаем data quality с уровня “tutorial” до уровня “production”. Реализуем enforced contracts на 3 ключевых marts, добавляем 5+ продвинутых тестов через dbt-expectations, custom generic test для бизнес-правила, и настраиваем store_failures для дебага.
К концу урока проект имеет:
- 3 marts с enforced contracts (mart_revenue_daily, mart_users_360, mart_orders)
- 1 mart с versions (mart_revenue_daily v1 + v2)
- 10+ generic tests + 5+ dbt-expectations tests
- 1 custom generic test для revenue consistency
- store_failures на 3+ критичных тестах
Часть 1: Model contracts
Зачем contracts
Model contract — это формальное заявление: “эта модель имеет такие колонки, таких типов, с такими constraints”. Контракт enforced — dbt валидирует совпадение во время build. Если query вернул другую структуру (другая колонка, другой тип) — build падает.
Это критично для public marts, которые потребляются BI и ML-командами. Без контракта изменение dbt-модели может silent’но сломать downstream dashboard.
mart_revenue_daily: enforced contract
# models/marts/core/_models.yml
models:
- name: mart_revenue_daily
description: "Daily revenue aggregated by country. Updated daily, retains 3 years history."
config:
contract:
enforced: true
columns:
- name: order_date
description: "Date of orders (UTC)"
data_type: DATE
constraints:
- type: not_null
tests:
- not_null
- name: shipping_address_country
description: "ISO 3166-1 alpha-2 country code"
data_type: VARCHAR
constraints:
- type: not_null
tests:
- not_null
- accepted_values:
values: ['US', 'GB', 'DE', 'FR', 'JP', 'CA', 'AU', 'BR', 'IN', 'NL']
- name: order_count
description: "Number of orders for this day/country combo"
data_type: BIGINT
constraints:
- type: not_null
- name: unique_customers
description: "Distinct user_id count"
data_type: BIGINT
constraints:
- type: not_null
- name: total_revenue_usd
description: "Sum of order_total_usd, USD with 2 decimal precision"
data_type: DECIMAL(18, 2)
constraints:
- type: not_null
- name: avg_order_value_usd
description: "Average order value (total_revenue / order_count)"
data_type: DECIMAL(18, 2)
Что enforced
При dbt run --select mart_revenue_daily dbt сначала валидирует, что:
- Колонки совпадают по именам и порядку с YAML.
- Типы совпадают (DECIMAL(18, 2) vs DECIMAL(10, 2) — fail).
- not_null constraints — если есть
constraints: [type: not_null]на колонке, dbt валидирует, что SELECT не возвращает NULL.
Если несовпадение — dbt run упадёт с понятной ошибкой типа “expected DECIMAL(18, 2), got DECIMAL(18, 4) on column total_revenue_usd”.
DuckDB constraints — caveats. dbt-duckdb 1.10 поддерживает enforced contract по типам и not_null. Но: (1) Compound constraints (primary_key, foreign_key, check) — metadata-only, не enforced на data. (2) DuckDB-специфичные типы (HUGEINT, UUID) — не все validated. В production на Snowflake / BigQuery contract validation глубже.
mart_users_360: контракт + check constraint
- name: mart_users_360
description: "Customer 360 view: lifetime stats, segments, CLV"
config:
contract:
enforced: true
columns:
- name: user_id
data_type: BIGINT
constraints:
- type: not_null
- type: primary_key # metadata-only на DuckDB
tests:
- unique
- name: user_email
data_type: VARCHAR
constraints:
- type: not_null
tests:
- dbt_utils.expression_is_true:
expression: "regexp_matches(user_email, '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$')"
- name: lifetime_value_usd
description: "Sum of all order_total for this user, excluding cancelled/fraudulent"
data_type: DECIMAL(18, 2)
constraints:
- type: not_null
- type: check
expression: "lifetime_value_usd >= 0"
- name: total_orders
data_type: BIGINT
constraints:
- type: not_null
- type: check
expression: "total_orders >= 0"
- name: customer_segment
data_type: VARCHAR
constraints:
- type: not_null
tests:
- accepted_values:
values: ['new', 'returning', 'vip', 'churned']
- name: first_order_date
data_type: DATE
- name: last_order_date
data_type: DATE
check constraints — на DuckDB metadata-only, но в YAML их указываем — это документация и подготовка к миграции на Snowflake / BigQuery (где они enforced).
mart_orders: contract + версионирование
- name: mart_orders
description: "Fact table: enriched orders with user and product info"
latest_version: 1
config:
contract:
enforced: true
versions:
- v: 1
columns:
- name: order_id
data_type: VARCHAR
constraints:
- type: not_null
- name: user_id
data_type: BIGINT
constraints:
- type: not_null
- name: user_email
data_type: VARCHAR
- name: first_order_date
data_type: DATE
- name: order_status
data_type: VARCHAR
constraints:
- type: not_null
- name: order_total_usd
data_type: DECIMAL(18, 2)
constraints:
- type: not_null
- name: order_items_count
data_type: INTEGER
- name: shipping_address_country
data_type: VARCHAR
- name: order_created_at
data_type: TIMESTAMP
- name: order_updated_at
data_type: TIMESTAMP
- name: dbt_loaded_at
data_type: TIMESTAMP
latest_version: 1 — это объявление, что текущая default-версия — v1. Когда добавим v2 — мы изменим latest_version: 2, а v1 пометим deprecation_date.
Часть 2: Version-эволюция mart_revenue_daily
Сценарий: добавление новой колонки
Допустим, прошёл квартал. Marketing-команда просит добавить repeat_order_count (заказы от повторных покупателей) в mart_revenue_daily. Это breaking change — структура контракта меняется.
Production-подход — создать v2 без ломки v1.
Реализация v2
Создаём mart_revenue_daily_v2.sql:
-- models/marts/core/mart_revenue_daily_v2.sql
{{
config(
materialized='incremental',
incremental_strategy='delete+insert',
unique_key=['order_date', 'shipping_address_country'],
on_schema_change='append_new_columns'
)
}}
WITH base AS (
SELECT * FROM {{ ref('mart_orders') }}
{% if is_incremental() %}
WHERE order_created_at::DATE >= (CURRENT_DATE - INTERVAL '7 days')
{% endif %}
),
with_repeat_flag AS (
SELECT
*,
CASE
WHEN order_created_at::DATE > first_order_date::DATE
THEN 1 ELSE 0
END AS is_repeat_order
FROM base
)
SELECT
order_created_at::DATE AS order_date,
shipping_address_country,
COUNT(DISTINCT order_id) AS order_count,
COUNT(DISTINCT user_id) AS unique_customers,
SUM(is_repeat_order) AS repeat_order_count, -- new in v2
SUM(order_total_usd) AS total_revenue_usd,
AVG(order_total_usd) AS avg_order_value_usd
FROM with_repeat_flag
WHERE order_status NOT IN ('cancelled', 'fraudulent')
GROUP BY 1, 2
YAML с versions
- name: mart_revenue_daily
description: "..."
latest_version: 2
config:
contract:
enforced: true
versions:
- v: 1
deprecation_date: '2026-09-01'
- v: 2
columns:
# v1 columns (общие)
- name: order_date
data_type: DATE
constraints: [{type: not_null}]
- name: shipping_address_country
data_type: VARCHAR
constraints: [{type: not_null}]
- name: order_count
data_type: BIGINT
constraints: [{type: not_null}]
- name: unique_customers
data_type: BIGINT
constraints: [{type: not_null}]
- name: total_revenue_usd
data_type: DECIMAL(18, 2)
constraints: [{type: not_null}]
- name: avg_order_value_usd
data_type: DECIMAL(18, 2)
# v2-only column
- name: repeat_order_count
data_type: BIGINT
constraints: [{type: not_null}]
config:
v: [2] # доступно только в v2
Downstream consumption
Consumer проекты теперь могут писать:
{{ ref('mart_revenue_daily') }}— резолвится в latest_version (v2){{ ref('mart_revenue_daily', v=1) }}— явно v1{{ ref('mart_revenue_daily', v=2) }}— явно v2
После deprecation_date: '2026-09-01':
- v1 ещё работает (если physically существует)
- dbt выдаёт warning при ref(’…’, v=1)
- После удаления v1 ref валидится в latest
Это API-versioning для данных. Marketing-команда может перейти на v2 в свободном темпе, не ломая существующих consumers.
Часть 3: dbt-expectations tests
Установка
# packages.yml
packages:
- package: dbt-labs/dbt_utils
version: 1.3.0
- package: calogica/dbt_expectations
version: 0.10.4
dbt deps
Применение в проекте
# models/marts/core/_models.yml — добавляем тесты к mart_revenue_daily
- name: mart_revenue_daily
# ... contract ...
tests:
- dbt_expectations.expect_table_row_count_to_be_between:
min_value: 30
max_value: 365 * 30 # max 30 стран × 365 дней
- dbt_expectations.expect_table_aggregation_to_equal_other_table:
expression: "SUM(total_revenue_usd)"
compare_model: ref('mart_orders')
compare_expression: "SUM(order_total_usd) FILTER (WHERE order_status NOT IN ('cancelled', 'fraudulent'))"
tolerance: 0.01
columns:
- name: total_revenue_usd
# ... existing ...
tests:
- dbt_expectations.expect_column_values_to_be_between:
min_value: 0
max_value: 10000000 # sanity check: no day > $10M
- dbt_expectations.expect_column_value_lengths_to_be_between:
min_value: 0
max_value: 20
- name: order_count
tests:
- dbt_expectations.expect_column_values_to_be_between:
min_value: 0
max_value: 100000
- name: unique_customers
tests:
- dbt_expectations.expect_column_pair_values_A_to_be_smaller_than_B:
column_A: unique_customers
column_B: order_count
or_equal: true # customers <= orders (один может купить несколько)
Разбор тестов
expect_table_row_count_to_be_between — таблица должна иметь от 30 до 365×30 строк. Защищает от случайной пустой таблицы (например, при бажном WHERE).
expect_table_aggregation_to_equal_other_table — sanity check: SUM(revenue) в mart_revenue_daily должен равняться SUM(revenue) в mart_orders (с тем же фильтром). Если расхождение больше 0.01 — что-то сломано в агрегации.
expect_column_values_to_be_between — value range check. Защищает от outliers (например, баг записал $10B в одну строку).
expect_column_pair_values_A_to_be_smaller_than_B — relationship между колонками. Customers ≤ orders — это бизнес-инвариант.
Эти тесты — defensive layer. Стандартные not_null/unique ловят grobye errors. dbt-expectations ловят subtler problems: выбросы, нарушенные инварианты, расхождения между моделями.
Часть 4: Custom generic test
Когда стандартных тестов не хватает, пишем custom generic test.
Use case: revenue consistency check
Бизнес-правило: mart_revenue_daily.total_revenue_usd за день должен равняться сумме mart_orders.order_total_usd за тот же день (с фильтром на success). Стандартный тест не покрывает — нужен custom.
-- tests/generic/test_revenue_consistency.sql
{% test revenue_consistency(model, revenue_column, source_model, source_amount_column, source_date_column, success_statuses=['paid', 'shipped', 'delivered']) %}
WITH agg_target AS (
SELECT
order_date,
SUM({{ revenue_column }}) AS target_total
FROM {{ model }}
GROUP BY 1
),
agg_source AS (
SELECT
{{ source_date_column }}::DATE AS order_date,
SUM({{ source_amount_column }}) AS source_total
FROM {{ source_model }}
WHERE order_status IN (
{% for status in success_statuses %}
'{{ status }}'{% if not loop.last %},{% endif %}
{% endfor %}
)
GROUP BY 1
),
joined AS (
SELECT
t.order_date,
t.target_total,
s.source_total,
ABS(t.target_total - s.source_total) AS diff
FROM agg_target t
LEFT JOIN agg_source s USING (order_date)
)
SELECT *
FROM joined
WHERE diff > 0.01 -- толерантность 1 цент
{% endtest %}
Применение
- name: mart_revenue_daily
tests:
- revenue_consistency:
revenue_column: total_revenue_usd
source_model: ref('mart_orders')
source_amount_column: order_total_usd
source_date_column: order_created_at
Тест запустится dbt test --select mart_revenue_daily, и упадёт, если найдёт хотя бы 1 день с расхождением > 1 cent.
Custom generic tests — это extension dbt для domain-specific invariants. Каждая команда обычно имеет 5-15 таких тестов на критичные бизнес-правила.
Часть 5: store_failures для дебага
Зачем
Когда тест падает, dbt по default’у показывает только COUNT failures. Чтобы дебажить — нужно знать, какие именно записи failed.
store_failures: true сохраняет failing rows в отдельную таблицу для дебага.
Конфигурация global
# dbt_project.yml
tests:
+store_failures: true
+store_failures_as: table # или 'view'
+schema: dbt_test_failures # отдельная схема для failures
Конфигурация per-test
- name: mart_revenue_daily
tests:
- dbt_expectations.expect_table_aggregation_to_equal_other_table:
# ...
config:
store_failures: true
severity: error
- revenue_consistency:
# ...
config:
store_failures: true
store_failures_as: table # перезаписывать на каждый run
Использование
После test fail:
# Dbt сохранил failures
dbt test --select mart_revenue_daily
# Проверяем в warehouse
SELECT * FROM dbt_test_failures.expect_table_aggregation_to_equal_other_table_mart_revenue_daily;
Видим конкретные записи / дни с расхождением. Дебагим.
store_failures cleanup. Failure tables создаются и не удаляются автоматически. Через год у вас в schema 200+ failure-таблиц. Решения: (1) Отдельная схема dbt_test_failures (легко чистить целиком). (2) dbt_project.yml: on-run-end: "{{ dbt_audit.cleanup_test_audit() }}" — кастомный macro для cleanup’а. (3) Использовать store_failures_as: view — view пересоздаётся каждый раз, не накапливается.
Часть 6: severity и тесты как контракты
- name: mart_orders
columns:
- name: order_id
tests:
- not_null:
severity: error # default, но явно для документации
- unique:
severity: error
- name: order_total_usd
tests:
- dbt_expectations.expect_column_values_to_be_between:
min_value: 0
max_value: 100000
severity: error # outliers — критично
- dbt_expectations.expect_column_quantile_values_to_be_between:
quantile: 0.99
min_value: 50
max_value: 5000
severity: warn # 99% percentile — soft check, warn а не error
Принцип: error для критичных constraints, warn для quality checks. Error падает CI, warn — лог + Explorer warning. Срез: ~30% error, ~70% warn для большинства проектов.
Проверка после реализации
dbt deps
dbt build --full-refresh # все contracts валидируются при run
dbt test # все тесты, включая dbt-expectations и custom
Ожидаемый результат:
- 3 marts с contracts — validation pass
- mart_revenue_daily v1 и v2 — оба работают, ref’ятся независимо
- ~30+ tests pass (если данные согласованы)
- 1-2 warning’а допустимы (это “soft checks”)
Что middle-инженер должен унести
- Знать, когда использовать contracts: критично для public marts, потребляемых BI/ML.
- Понимать caveats DuckDB-constraints (compound не enforced).
- Уметь делать version evolution: v1 -> v2 без ломки consumers.
- Применять dbt-expectations для quality checks выше стандартных not_null/unique.
- Писать custom generic tests для domain-specific invariants.
- Использовать store_failures для дебага failing tests.
- Распределять severity: error для критичных, warn для quality.