Learning Platform
Глоссарий Troubleshooting
Урок 09.03 · 24 мин
Средний
Unit testsFixturesCSVEdge casesNULL handling

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 непредсказуемы

Выбор формата: дерево решений

Какой формат fixture выбрать
Размер fixtureСколько строк в fixture, сколько колонок, как часто эта fixture переиспользуется. Эвристика - dict для маленького, csv для большого, sql для сгенерированного.
РешениеЕсли 1-5 строк и до 10 колонок - dict. Если 10+ строк или нужно явно видеть колонки в табличном виде - csv. Если генерируем pattern (100+ строк по формуле) - sql.
Форматdict для большинства, csv для много строк, csv file если переиспользуется в 2+ тестах, sql редко.
КейсЛучший формат
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. Если хочешь именно пустую строку — "".
WARNING

В 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-сценариев:

  1. Happy path — типичные данные.
  2. NULL в одном поле — что делает COALESCE / IS NULL логика.
  3. Empty input — модель не падает.
  4. Boundary — даты на границе месяца/года, числа на границе типа.
  5. Unicode / длинная строка — не теряем данные, не truncate.

Для каждого пиши: что подаёшь (given), что ожидаешь (expect), что произойдёт если убрать ключевой COALESCE в SQL модели — какой тест должен упасть. Это mental check покрытия.


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

  1. dict — для 1-5 строк, компактный inline YAML. Хорош для большинства тестов.
  2. csv inline — для 5-20 строк, видны столбцы в табличном виде. Лучший выбор для many edge cases в одном тесте.
  3. csv file — для переиспользуемых fixtures (2+ unit tests). Хранится в tests/fixtures/.
  4. sql — для генерируемых данных или сложных типов (struct, array, JSON).
  5. Edge cases для покрытия: NULL в обязательных/опциональных полях, boundary dates (leap year, year-end), Unicode/long strings, extreme numbers (zero, negative, very large), empty dataset, дубли PK.
  6. Типы YAML примитивные — decimal лучше задавать как string-в-quotes. Boolean / date — без quotes (но осторожно с implicit casting).
  7. Style: one-per-scenario для критических моделей (легче дебагать failure), table-driven для простых (меньше YAML).
  8. Каждый production-инцидент -> новый unit test. Через 6 месяцев — регрессия защищена.
Проверка знанийKnowledge check
Команда обнаружила production баг: customers с пустым email пропадали из mart-таблицы. Корень: WHERE email != '' в модели, но в source данных встречается NULL email (не empty string). Какой unit test должна была написать команда, чтобы поймать этот баг до прода?
ОтветAnswer
Unit test с NULL в email и проверкой, что customer присутствует в expect:\n\n```yaml\n- name: customer_summary_keeps_null_email\n model: customer_summary\n given:\n - input: ref('stg_customers')\n rows:\n - {customer_id: 1, first_name: 'Alice', email: '[email protected]'}\n - {customer_id: 2, first_name: 'Bob', email: null} # NULL email\n - {customer_id: 3, first_name: 'Charlie', email: ''} # empty string\n expect:\n rows:\n - {customer_id: 1, first_name: 'ALICE', email: '[email protected]'}\n - {customer_id: 2, first_name: 'BOB', email: '[email protected]'}\n - {customer_id: 3, first_name: 'CHARLIE', email: '[email protected]'} # bug: исчезнет с WHERE email != ''\n```\n\nКогда модель содержала `WHERE email != ''`, customer 3 пропадал (это видимое поведение), а customer 2 (с NULL email) проходил, **но если кто-то поменяет на `WHERE email != '' OR email IS NULL`** — оба должны пройти. \n\nПравильный исправление в SQL модели — **убрать WHERE** и использовать COALESCE на email column, или `WHERE email IS NOT NULL OR (email != '' OR email IS NULL)`. Unit test ловит регрессию: если кто-то добавит фильтр снова — Bob/Charlie исчезнут из expect.\n\nЛогика выводов: NULL ≠ empty string в SQL. `WHERE email != ''` в большинстве SQL диалектов **исключает NULL** (NULL is not equal to anything, not even to itself in three-valued logic). Unit test с обоими сценариями ловит это.
Проверка знанийKnowledge check
Senior пишет unit test на финансовую модель. Использует dict fixture: `{order_total: 0.1}`. При запуске expect.lifetime_value=0.3 при given.rows = 3x order_total=0.1 — тест падает: actual=0.30000000000000004. Что не так?
ОтветAnswer
Это классический **float arithmetic** баг. \n\nYAML `0.1` парсится как **float64**. DuckDB / Snowflake / BigQuery аггрегируют float как float — и 0.1 + 0.1 + 0.1 в IEEE 754 = 0.30000000000000004, не ровно 0.3 (потому что 0.1 не representable в binary float точно).\n\nКорректное решение — **string-обёрнутый decimal в fixture**:\n\n```yaml\nrows:\n - {order_total: '0.1'}\n - {order_total: '0.1'}\n - {order_total: '0.1'}\n```\n\nКогда строка кастится в DECIMAL/NUMERIC в DuckDB, арифметика exact: 0.1 + 0.1 + 0.1 = 0.3 (без хвоста). Это **правило для финансовых данных**: всегда DECIMAL, никогда float.\n\nДополнительные защиты:\n\n1. В SQL модели использовать `order_total::numeric(10, 2)` — явный каст в decimal.\n2. В YAML колонок `data_type: numeric(10, 2)` — это контракт (см. модуль 10).\n3. В expect использовать те же string-quoted decimals: `{lifetime_value: '0.30'}`.\n\nЭто tradeoff: float быстрый, но не точный. Decimal медленный, но точный. Для денег — всегда decimal.

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

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. Production-баг: customers с NULL email пропадают из mart-таблицы. В модели было `WHERE email != ''`. Какой unit test должна была написать команда?

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

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

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

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