Fixtures: dict / csv inline / csv file / sql и edge cases
В прошлом уроке мы прошли структуру unit_tests YAML. Этот урок — глубже в fixtures: как выбрать формат, как организовать переиспользуемые fixtures, и какие edge cases надо специально проверять (NULL, дат, длинных строк, leap years, юникод, граничных чисел).
Хороший набор unit-тестов — это не «happy path» + 1-2 edge cases. Это систематический discovery corner-кейсов из data lake. Каждый раз, когда production-инцидент происходит, добавляйте unit test на этот корнер-кейс. Через 6 месяцев — у вас защита от регрессий.
SQL: семантика NULL — почему edge cases с NULL непредсказуемыВыбор формата: дерево решений
| Кейс | Лучший формат |
|---|---|
| 1-3 строки, понятный набор полей | dict (inline) |
| 5-20 строк, фокус на корнер-кейсах | csv (inline) |
| 20+ строк, переиспользуется в нескольких тестах | csv (file) |
| 100+ строк, сгенерированные данные (нагрузочный тест) | sql |
| Сложные типы (массивы, JSON, struct) | sql (с явным CAST) |
| Очень специфический edge case (1 строка с NULL во всех полях) | dict |
Формат 1: dict (inline)
Стандартный, читаемый для маленьких fixtures.
given:
- 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'}
Плюсы:
- Самый компактный.
- Прост для review в PR — видны все значения сразу.
- Хорошо подсвечивается syntax highlighter.
Минусы:
- При 5+ колонках строки становятся длинными.
- При 10+ строках сложно сравнивать значения вертикально (нет выравнивания).
- Все строки в
{...}, много знаков пунктуации.
Convention: один dict-row на одной YAML-строке через flow-mapping {key: val, key: val}. Можно и через block-mapping (один key per line), но получается длинно.
Формат 2: csv inline
Лучший для табличных fixtures с 5-20 строками.
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
4,200,,2026-01-25
5,300,0.00,
Плюсы:
- Колонки выровнены — глаз цепляет аномалии.
- Можно открыть в Excel/Numbers и редактировать как таблицу.
- NULL = пустая ячейка между запятыми — компактно.
Минусы:
- Кастинг типов автоматический. Например,
2026-01-01без квот рассматривается как дата, и dbt пытается parse’ить. Если хочешь string — ставь в quotes. - Truthy/falsy кастится:
true/false/1/0для boolean. Если хочешь именно string'1'— ставь в quotes. - Пустые значения по умолчанию NULL. Если хочешь именно пустую строку —
"".
В CSV нет способа отличить NULL от empty string. Пустая ячейка интерпретируется как NULL для большинства типов, но ,, для VARCHAR может стать пустой строкой в DuckDB. Если различие критично — используй dict или sql формат.
Формат 3: csv file
Для переиспользуемых fixtures.
Сам YAML:
given:
- input: ref('stg_orders')
format: csv
fixture: orders_baseline
Файл tests/fixtures/orders_baseline.csv:
order_id,customer_id,order_total,order_date,status
1,100,50.00,2026-01-01,completed
2,100,75.00,2026-01-15,completed
3,200,100.00,2026-01-20,completed
4,200,0.00,2026-01-25,refunded
5,300,200.00,2026-02-01,pending
Конфигурация в dbt_project.yml:
# default
fixture-paths: ["tests/fixtures"]
Когда использовать:
- Одна и та же fixture в 2+ unit tests (DRY-принцип).
- Fixture большая (50+ строк), захламляет YAML.
- Команда хочет ходить редактировать в табличном редакторе.
Плюсы:
- Один источник истины — обновил CSV, все тесты подхватили.
- Лёгкая интеграция с tools (Excel, csvkit, pandas).
- YAML остаётся коротким.
Минусы:
- Лишний файл — больше навигации в репо.
- При diff-review надо открывать оба файла (YAML + CSV).
- При rename модели — нужно обновлять references вручную.
Convention: если fixture используется в одном тесте — оставь inline csv. Только когда нужен reuse — выноси в файл.
Формат 4: sql
Раritет, но мощный для генерируемых данных.
given:
- input: ref('stg_orders')
format: sql
rows: |
SELECT
i AS order_id,
((i - 1) % 100) + 1 AS customer_id,
(i * 1.5)::numeric(10, 2) AS order_total,
DATE '2026-01-01' + (i * INTERVAL '1 day') AS order_date,
CASE WHEN i % 10 = 0 THEN 'refunded' ELSE 'completed' END AS status
FROM generate_series(1, 1000) AS s(i)
Это создаст 1000 строк с детерминированным шаблоном. Полезно для:
- Performance тесты: проверить, что аггрегация на 1000 строк выдаёт ожидаемые числа (тест на distributive arithmetic).
- Property-based testing: убедиться, что для всех customer_id (от 1 до 100) число заказов одинаково.
- Сложные типы: arrays, JSON, struct — где dict/csv бессильны.
given:
- input: ref('raw_events')
format: sql
rows: |
SELECT
1 AS event_id,
'click' AS event_type,
STRUCT_PACK(url := 'https://x.com', referrer := NULL) AS metadata,
['mobile', 'ios'] AS tags
Минусы:
- SQL зависит от warehouse (DuckDB vs Snowflake vs BigQuery — синтаксис разный).
- Менее читаемо, чем dict / csv.
- Нет статической проверки — ошибка в SQL найдётся только в runtime.
Edge cases, которые должны быть покрыты
Это не исчерпывающий список — это категории edge cases, которые повторяются в реальных проектах. На критических моделях покрывай каждую.
NULL в обязательных колонках
- name: customer_summary_handles_null_email
model: customer_summary
given:
- input: ref('stg_customers')
rows:
- {customer_id: 1, first_name: 'Alice', email: null, total_orders: 5}
expect:
rows:
- {customer_id: 1, first_name: 'ALICE', email: '[email protected]', status: 'active'}
Проверяешь, что COALESCE / IFNULL / ISNULL работают. Если кто-то уберёт COALESCE — тест упадёт.
NULL во всех опциональных колонках
- name: customer_summary_all_optional_null
model: customer_summary
given:
- input: ref('stg_customers')
rows:
- {customer_id: 2, first_name: null, email: null, last_login: null, total_orders: 0}
expect:
rows:
- {customer_id: 2, first_name: null, email: '[email protected]', last_login: null, status: 'inactive'}
Это «всё-NULL» сценарий — катит ли модель такой row или падает с runtime error.
Граничные даты: leap year, year-end, очень старые/будущие
- name: order_processing_handles_leap_year
model: order_aggregations
given:
- input: ref('stg_orders')
rows:
- {order_id: 1, order_date: '2024-02-29', order_total: 100.00} # leap day
- {order_id: 2, order_date: '2026-12-31', order_total: 200.00} # year-end
- {order_id: 3, order_date: '1900-01-01', order_total: 50.00} # very old
- {order_id: 4, order_date: '9999-12-31', order_total: 1.00} # max date
Это покрывает: datetime arithmetic, truncation to month/year, date_part edge cases.
Длинные строки и Unicode
- name: name_normalization_unicode
model: customer_summary
given:
- input: ref('stg_customers')
rows:
- {customer_id: 1, first_name: 'Алиса Иванова-Петрова', email: '[email protected]'}
- {customer_id: 2, first_name: 'José María González O''Brien', email: '[email protected]'}
- {customer_id: 3, first_name: '李華', email: '[email protected]'}
- {customer_id: 4, first_name: 'A' * 256, email: '[email protected]'} # NB: dict не поддержит — используй csv
expect:
rows:
- {customer_id: 1, first_name: 'АЛИСА ИВАНОВА-ПЕТРОВА', ...}
- {customer_id: 2, first_name: 'JOSÉ MARÍA GONZÁLEZ O''BRIEN', ...}
- {customer_id: 3, first_name: '李華', ...} # верхний регистр для китайских — без изменений
Проверяешь: UPPER / LOWER на не-ASCII, escape апострофов, truncation длинных строк (если есть VARCHAR(50) constraint).
Граничные числа
- name: revenue_handles_extreme_amounts
model: customer_revenue
given:
- input: ref('stg_orders')
rows:
- {order_id: 1, customer_id: 100, order_total: 0.00} # zero
- {order_id: 2, customer_id: 100, order_total: 0.01} # 1 cent
- {order_id: 3, customer_id: 100, order_total: -50.00} # refund (negative)
- {order_id: 4, customer_id: 100, order_total: 999999.99} # large
- {order_id: 5, customer_id: 100, order_total: 1e-9} # very small, may underflow
expect:
rows:
- {customer_id: 100, lifetime_value: 949950.00, total_orders: 5}
Что проверяем: SUM не теряет точность при больших числах, не падает на отрицательных, не округляет на маленьких.
Пустой dataset
- name: customer_summary_empty_source
model: customer_summary
given:
- input: ref('stg_customers')
rows: []
expect:
rows: []
Проверяешь, что модель не падает при пустом upstream. Полезно когда есть downstream агрегаты — SUM(0) -> 0 или SUM(0) -> NULL?
Дубли по PK
- name: customer_summary_duplicate_pk
model: customer_summary
given:
- input: ref('stg_customers')
rows:
- {customer_id: 1, first_name: 'Alice'}
- {customer_id: 1, first_name: 'Alice (duplicate)'}
expect:
rows:
- {customer_id: 1, first_name: 'ALICE'} # либо первая, либо ошибка — зависит от логики
Если модель не дедуплицирует — упадёт data test unique на customer_id. Если модель должна дедуплицировать (через ROW_NUMBER() OVER (PARTITION BY customer_id)) — unit test проверит, что dedup срабатывает.
Типы данных в fixtures: ловушки
DuckDB кастует достаточно умно, но не всегда так, как ожидается.
Decimal vs float
rows:
- {amount: 0.1} # float64, может стать 0.1000000000000000055...
- {amount: '0.1'} # string, кастится в decimal — точно 0.1
Для финансовых данных всегда используй string-обёрнутые decimal, или explicit type через csv с DECIMAL cast.
Boolean
rows:
- {is_active: true} # boolean
- {is_active: True} # YAML: тоже boolean
- {is_active: 'true'} # string!
- {is_active: 1} # integer 1
Опасно для моделей, которые делают WHERE is_active = TRUE — если случайно подал string 'true', тест может пройти на DuckDB (implicit cast), но упасть на Snowflake (strict).
Дата vs DateTime vs TIMESTAMP
rows:
- {order_date: '2026-01-15'} # обычно date
- {order_date: '2026-01-15 10:30:00'} # обычно timestamp
- {order_date: '2026-01-15T10:30:00Z'} # ISO с timezone
dbt не делает type assertion. Тип конвертируется на лету. Если модель ожидает order_date::DATE, а в fixture '2026-01-15 10:30:00' — модель может truncate или error.
NULL для разных типов
rows:
- {amount: null, date: null, name: null}
В YAML null универсален и кастится в NULL для любого типа. Это всегда работает.
Стратегия: «one happy + N edge cases» vs «table-driven»
Два стиля написания unit tests.
One happy + N edge cases
Каждый сценарий — отдельный unit test.
unit_tests:
- name: revenue_happy_path
given: [...]
expect: [...]
- name: revenue_null_email
given: [...]
expect: [...]
- name: revenue_zero_orders
given: [...]
expect: [...]
- name: revenue_leap_year
given: [...]
expect: [...]
Плюсы: чёткие имена тестов, при провале CI сразу видно, какой scenario сломался. Минусы: много YAML, дублирование boilerplate.
Table-driven
Один unit test с большой fixture, покрывающей все сценарии сразу.
unit_tests:
- name: revenue_all_scenarios
given:
- input: ref('stg_customers')
format: csv
rows: |
customer_id,first_name,email,total_orders,scenario
1,Alice,[email protected],5,happy
2,Bob,,0,null_email_zero_orders
3,Charlie,[email protected],-1,negative_orders
4,Dave,[email protected],1000000,large_orders
expect:
format: csv
rows: |
customer_id,first_name,email,status
1,ALICE,[email protected],active
2,BOB,[email protected],inactive
3,CHARLIE,[email protected],inactive
4,DAVE,[email protected],active
Плюсы: меньше YAML, проще обзорно посмотреть на все сценарии. Минусы: при провале CI один большой diff, надо вычитывать, какой row сломан.
Рекомендация: для критических моделей (revenue, churn, attribution) — one-per-scenario. Для простых моделей — table-driven. Решающий фактор — сколько времени дебаггать failure: для критических каждая минута важна, для простых — можно потерпеть.
Попробуй сам
Открой свой dbt-проект. Возьми модель, которая делает агрегацию с CASE / COALESCE / DATE_TRUNC. Напиши 5 fixture-сценариев:
- Happy path — типичные данные.
- NULL в одном поле — что делает COALESCE / IS NULL логика.
- Empty input — модель не падает.
- Boundary — даты на границе месяца/года, числа на границе типа.
- Unicode / длинная строка — не теряем данные, не truncate.
Для каждого пиши: что подаёшь (given), что ожидаешь (expect), что произойдёт если убрать ключевой COALESCE в SQL модели — какой тест должен упасть. Это mental check покрытия.
Ключевые выводы
- dict — для 1-5 строк, компактный inline YAML. Хорош для большинства тестов.
- csv inline — для 5-20 строк, видны столбцы в табличном виде. Лучший выбор для many edge cases в одном тесте.
- csv file — для переиспользуемых fixtures (2+ unit tests). Хранится в
tests/fixtures/. - sql — для генерируемых данных или сложных типов (struct, array, JSON).
- Edge cases для покрытия: NULL в обязательных/опциональных полях, boundary dates (leap year, year-end), Unicode/long strings, extreme numbers (zero, negative, very large), empty dataset, дубли PK.
- Типы YAML примитивные — decimal лучше задавать как string-в-quotes. Boolean / date — без quotes (но осторожно с implicit casting).
- Style: one-per-scenario для критических моделей (легче дебагать failure), table-driven для простых (меньше YAML).
- Каждый production-инцидент -> новый unit test. Через 6 месяцев — регрессия защищена.