Data Engineering и ETL Тестирование и CI/CD дата-пайплайнов: среды, unit-тесты SQL, data diff и релиз моделей
0%

Тестирование и CI/CD дата-пайплайнов: среды, unit-тесты SQL, data diff и релиз моделей

Тестирование и CI/CD дата-пайплайнов: среды, unit-тесты SQL, data diff и релиз моделей

В обычном сервисе цена ошибки ограничена: выкатили баг, увидели рост 5xx, откатили за три минуты, пользователи потеряли пару запросов. В дата-платформе так не работает.

Ошибка в SQL-модели проявляется не сразу, а на следующем расчёте — через час или через сутки. К моменту обнаружения по ней уже посчитаны двенадцать витрин, обучена модель, отправлены отчёты инвесторам и приняты решения. Откат кода не возвращает данные: строки уже перезаписаны, а «вчерашняя выручка» уже жила три дня в неверном значении. И самое неприятное — тест на проде здесь не бесплатный: один неудачный прогон полного пересчёта стоит реальных денег за сканирование.

Отсюда практический вывод, который отличает зрелую команду данных: инвестиции идут не в «побольше тестов», а в сокращение расстояния между ошибкой и её обнаружением. Эта статья — про то, как поймать ошибку до того, как её увидят данные в проде.

Важно сразу разграничить с главой о качестве данных: там речь о проверках данных в проде (свежесть, объём, аномалии, WAP). Здесь — о проверках кода до релиза. Это две разные сети с разной ячейкой, и подменять одну другой не получается.


1. Две оси, четыре квадранта

Всё, что можно проверить в платформе данных, раскладывается по двум осям: что проверяем (код или данные) и когда (до релиза или в проде).

До релиза (CI) В проде (runtime)
Код компиляция SQL, линтер, unit-тесты трансформаций, тесты DAG’ов ошибки исполнения, таймауты, падения задач
Данные прогон на подвыборке, data diff с продом, схемные контракты тесты свежести/уникальности, аномалии, WAP, сверка

Ошибка большинства команд — жить только в правом нижнем квадранте: «у нас есть dbt-тесты в проде, значит, качество под контролем». Правый нижний ловит дефект после того, как он материализовался, и стоит пересчёта. Левый верхний ловит его за 90 секунд в пул-реквесте и стоит ноль.

Правило распределения усилий то же, что и в обычной разработке: чем дешевле проверка, тем больше её должно быть. Разница лишь в том, что «дорого» в данных измеряется не только минутами CI, но и счётом за сканирование.


2. Что вообще тестировать в SQL-модели

Типичный ответ «проверим, что строк больше нуля» бесполезен. Полезные тесты рождаются из вопроса: какие допущения делает эта модель и что случится, если они нарушатся?

Список допущений, которые ломаются чаще всего:

  • Уникальность ключа. Модель предполагает одну строку на заказ; источник прислал две версии. Результат — удвоенная выручка после JOIN.
  • Обработка NULL. amount > 100 отбрасывает NULL, а NOT (amount <= 100) — нет. В агрегатах SUM игнорирует NULL, COUNT(*) — нет. Половина расхождений в отчётах живёт здесь.
  • Границы интервалов. BETWEEN включает обе границы; полуинтервал [начало, конец) — нет. Заказ в 00:00:00 попадёт в два дня сразу или ни в один.
  • Часовые пояса. Данные в UTC, бизнес-день в Europe/Moscow, отчёт «за 1 июля» отличается на три часа данных. Переход на летнее время в источниках с локальным временем даёт дни на 23 и 25 часов.
  • Поздние строки. Факт приехал через двое суток; инкрементальная модель, фильтрующая по event_date = current_date - 1, не увидит его никогда.
  • Границы SCD. Джойн факта с версионным измерением по valid_from <= t < valid_to — и вопрос, что происходит ровно на границе, и что с фактами до появления первой версии.
  • Деньги. Дробные типы, округление, валюты, знак у возвратов.

Каждый пункт — кандидат в unit-тест с двумя-тремя строками фикстуры. Именно поэтому unit-тесты трансформаций эффективнее, чем кажется: они проверяют не «SQL работает», а «SQL делает то, о чём мы договорились в спорных местах».


3. Unit-тесты трансформаций: фикстуры вместо продовых данных

Ключевая мысль: тест на реальных данных — не unit-тест. Реальные данные меняются, поэтому такой тест то падает, то нет; в них почти никогда нет нужного граничного случая; и они не отвечают на вопрос «что должно получиться», потому что эталон приходится считать тем же самым SQL.

Unit-тест трансформации выглядит так: фиксированный вход → ожидаемый выход. В dbt (начиная с версии 1.8) это встроенный механизм:

# models/marts/fct_orders.yml
unit_tests:
  - name: возврат_уменьшает_выручку_и_не_ломает_дневную_границу
    model: fct_orders
    description: >
      Проверяем три допущения сразу: возврат учитывается со знаком минус,
      бизнес-день считается в московском времени, NULL в сумме не обнуляет заказ.      
    given:
      - input: ref('stg_orders')
        rows:
          # заказ ровно на границе суток по UTC: 30 июня 21:30 UTC = 1 июля 00:30 MSK
          - {order_id: 1, customer_id: 10, amount: 1000, kind: 'sale',
             created_at: '2026-06-30 21:30:00'}
          # возврат по тому же заказу на следующий день
          - {order_id: 2, customer_id: 10, amount: 300, kind: 'refund',
             created_at: '2026-07-01 10:00:00'}
          # сумма не заполнена: заказ должен остаться в счётчике, но не в деньгах
          - {order_id: 3, customer_id: 11, amount: null, kind: 'sale',
             created_at: '2026-07-01 12:00:00'}
    expect:
      rows:
        - {business_date: '2026-07-01', orders_cnt: 3, revenue: 700}

Стоимость такого теста — секунды: dbt подставляет фикстуры вместо ref() и выполняет модель на трёх строках. Стоимость эквивалентной проверки «глазами на проде» — пересчёт витрины и вечер аналитика.

Для трансформаций на Python логика та же, только инструменты обычные:

# tests/test_sessionize.py — тест на склейку событий в сессии.
# Проверяем ровно то, о чём договорились: разрыв 30 минут начинает новую сессию,
# граница включительна, события одного пользователя не смешиваются с чужими.
import polars as pl
from pipelines.sessionize import sessionize

def test_разрыв_ровно_30_минут_начинает_новую_сессию():
    events = pl.DataFrame({
        "user_id": ["u1", "u1", "u1", "u2"],
        "ts": ["2026-07-01T10:00:00", "2026-07-01T10:29:59",
               "2026-07-01T10:59:59", "2026-07-01T10:10:00"],
    }).with_columns(pl.col("ts").str.to_datetime())

    result = sessionize(events, gap_minutes=30).sort(["user_id", "ts"])

    # u1: первые два события в одной сессии (разрыв 29:59),
    # третье — в новой (разрыв ровно 30:00, граница НЕ включается)
    assert result["session_id"].to_list()[:3] == [1, 1, 2]
    # события другого пользователя нумеруются независимо
    assert result.filter(pl.col("user_id") == "u2")["session_id"].to_list() == [1]

Что тестировать не надо: тривиальные переименования колонок, SELECT * из источника, и «что хранилище умеет складывать числа». Тест, который не может упасть от осмысленной ошибки, — это стоимость поддержки без выгоды. Общая философия — в треке про тестирование: «Модульное тестирование».


4. Тесты пайплайна, а не только трансформаций

Кроме SQL в репозитории живёт код оркестрации, и он ломается по-своему: опечатка в имени задачи, цикл в графе, забытый retries, зависимость от задачи из другого DAG’а, которая переименована. Базовый тест целостности графа разобран в главе про оркестрацию — его достаточно поставить в CI один раз, и он окупается на первой же опечатке.

Стоит добавить к нему три вещи:

  • Проверку конвенций. Все задачи имеют владельца и алерт-канал; у DAG’ов Tier-1 задан SLA; у инкрементальных моделей объявлен уникальный ключ. Это линтер, а не тест, но ловит он больше.
  • Тесты на внешние вызовы с записанными ответами. Коннекторы из предыдущей главы тестируются на сохранённых ответах API: реальный вызов в CI — это флаки, лимиты и утечка ключей.
  • Интеграционный прогон на подвыборке. Один DAG, маленький набор данных, реальные операторы, но контейнерные Postgres/MinIO вместо облака. Разбор подхода — в «Интеграционном тестировании».

5. Среды: где выполняется PR-версия модели

Главная особенность CI в данных: вычисление невозможно изолировать от хранилища. Модель нельзя собрать «в памяти» — её надо где-то материализовать. Отсюда три уровня сред.

Среда Что это физически Данные Кто платит за прогон
Личная среда разработчика схема dev_alice в том же хранилище клон или подвыборка прода разработчик, много мелких прогонов
CI-среда пул-реквеста эфемерная схема ci_pr_482, удаляется после мержа подвыборка или клон, только изменённые модели CI, десятки прогонов в день
Прод analytics всё расписание

Три приёма делают такие среды дешёвыми:

Zero-copy clone. Snowflake и Databricks умеют клонировать схему без копирования файлов: создаётся новый набор метаданных, ссылающийся на те же данные, а расходятся они только по мере записи. Полноценная копия витрин на 40 ТБ создаётся за секунды и стоит копейки, пока в ней ничего не пишут.

-- Snowflake: личная среда разработчика поверх прод-данных за секунды.
CREATE SCHEMA IF NOT EXISTS dev_alice CLONE analytics;

-- Databricks / Delta: неглубокий клон таблицы (копируются только метаданные).
CREATE TABLE dev_alice.fct_orders SHALLOW CLONE analytics.fct_orders;

Подвыборка с сохранением ссылочной целостности. Если клона нет (BigQuery, ClickHouse, Postgres), берут срез: N дней фактов и только связанные измерения. Случайные 1 % строк — типичная ошибка: join’ы перестают находить пары, и тесты проходят на пустых результатах. Правильный срез строится по ключу: сначала выбираются заказы за 3 дня, затем клиенты и товары именно этих заказов.

Синтетика вместо персональных данных. Копия прода в dev-схеме — самый частый канал утечки персональных данных, о чём подробно в «Приватности и соответствии требованиям». Практика: клонируются витрины без PII-колонок, а поля вроде почты и телефона заменяются детерминированным псевдонимом — детерминированным, чтобы join’ы продолжали работать. Общая теория изолированных сред — в «Средах».


6. Сборка только изменённого: почему CI не пересчитывает всё

Наивный CI собирает весь проект: полторы тысячи моделей, сорок минут и заметный счёт за каждый пул-реквест, где поправили комментарий. Решение — дифференциальная сборка: пересобрать только изменённые модели и то, что от них зависит.

Механика простая. Прод-сборка сохраняет артефакт-манифест (граф моделей и хэши их кода). CI сравнивает свой манифест с прод-манифестом и получает список изменённых узлов. Дальше берётся изменённое плюс потомки — то, на что изменение может повлиять.

# Забираем манифест последней успешной прод-сборки
aws s3 cp s3://dbt-artifacts/prod/manifest.json ./prod-state/manifest.json

# Собираем только изменённые модели и всё, что ниже по графу.
# --defer: недостающие ЗАВИСИМОСТИ берём из прода, а не пересобираем.
dbt build \
  --select 'state:modified+' \
  --state ./prod-state \
  --defer \
  --target ci \
  --vars '{"days_of_data": 3}'    # в CI работаем на коротком окне

--defer — ключевая часть: если ваша модель читает stg_orders, который вы не меняли, CI не будет пересобирать его в PR-схеме, а подставит прод-таблицу. Без этого дифференциальная сборка бессмысленна: любая модель тянет за собой половину графа вверх.

Экономический эффект измеряется прямо: типичный переход с полной сборки на дифференциальную сокращает время PR-проверки с 30–50 минут до 3–6 и снижает счёт за CI на порядок. Побочный эффект важнее денег: проверка, которая идёт четыре минуты, действительно запускается на каждом изменении, а сорокаминутная — «потом, перед релизом».


7. Data diff: главный инструмент, которого обычно нет

Тесты отвечают на вопрос «модель корректна?». Они не отвечают на вопрос, который на самом деле волнует ревьюера: что именно изменится в данных, если этот PR влить?

Data diff сравнивает результат PR-версии модели с текущей прод-версией. Уровней три, и полезны все:

  1. Схема. Добавленные, удалённые, переименованные колонки, смена типов. Удаление колонки — ломающее изменение для BI, даже если все тесты зелёные.
  2. Агрегаты. Число строк, сумма ключевых мер, число уникальных ключей, доля NULL по колонкам. Быстро и почти бесплатно.
  3. Построчно по первичному ключу. Сколько строк появилось, исчезло, изменилось; по каким колонкам; примеры расхождений. Дороже, но именно здесь видно «поменялось 0,4 % строк, все — заказы с возвратами».
-- Сравнение PR-версии с прод-версией по первичному ключу.
-- Идея та же, что в dbt-audit-helper compare_relations, но развёрнуто.
WITH prod AS (SELECT * FROM analytics.fct_orders  WHERE business_date >= CURRENT_DATE - 7),
     pr   AS (SELECT * FROM ci_pr_482.fct_orders  WHERE business_date >= CURRENT_DATE - 7),
joined AS (
    SELECT COALESCE(p.order_id, c.order_id) AS order_id,
           p.order_id IS NULL               AS only_in_pr,
           c.order_id IS NULL               AS only_in_prod,
           p.revenue                        AS pr_revenue,
           c.revenue                        AS prod_revenue
    FROM pr AS p
    FULL OUTER JOIN prod AS c ON p.order_id = c.order_id
)
SELECT
    COUNT(*)                                                        AS rows_total,
    COUNT_IF(only_in_pr)                                            AS added_rows,
    COUNT_IF(only_in_prod)                                          AS removed_rows,
    COUNT_IF(NOT only_in_pr AND NOT only_in_prod
             AND pr_revenue IS DISTINCT FROM prod_revenue)          AS changed_revenue,
    ROUND(100.0 * COUNT_IF(pr_revenue IS DISTINCT FROM prod_revenue)
          / NULLIF(COUNT(*), 0), 3)                                 AS changed_pct,
    SUM(pr_revenue) - SUM(prod_revenue)                             AS revenue_delta
FROM joined;

IS DISTINCT FROM вместо <> здесь принципиально: сравнение с NULL через <> даёт NULL, и изменения «было NULL — стало 0» просто не попадут в отчёт.

Как читать результат — короткая таблица решений:

Что показал diff Вопрос ревьюера Что должно быть в PR
0 строк изменилось рефакторинг без эффекта? «это чистый рефакторинг» — и это ценно
Изменились все строки точно ли так задумано? объяснение и, скорее всего, backfill
Изменилась 0,1 % строк что общего у этих строк? пример строк и причина
Строки исчезли фильтр стал строже намеренно? обоснование или исправление
Появилась колонка потребители знают? обновлённый контракт и документация
Изменился тип колонки ломает ли BI? план миграции потребителей

Именно этот отчёт превращает ревью SQL-модели из «вроде читается» в осмысленный разговор. Инструменты готовые есть: dbt-audit-helper, data-diff от Datafold, встроенные планы sqlmesh plan, который дополнительно классифицирует изменение как ломающее или нет и предлагает пересчитать только затронутые интервалы.


8. Релиз модели: как выкатывать изменение витрины

Код влит — дальше начинается то, чего нет в обычном CD: изменение кода почти всегда требует изменения данных. Три сценария по возрастанию риска.

Аддитивное изменение (добавили колонку, добавили модель). Безопасно: старые потребители не замечают. Правило — новые колонки добавляются в конец и допускают NULL для истории, пока не выполнен backfill.

Изменение логики без изменения схемы (поправили определение метрики). Схема та же, числа другие. Это самое опасное: BI-дашборды продолжат работать и покажут другие цифры без предупреждения. Обязательны: backfill затронутого периода одним прогоном (см. оркестрацию), запись в журнал изменений метрики и уведомление потребителей. Смена определения без объявления — главный источник фразы «этим числам нельзя верить».

Ломающее изменение схемы (удалили или переименовали колонку, изменили зерно). Здесь нужен двухфазный релиз, как в миграциях баз: сначала добавить новое, потом перевести потребителей, потом удалить старое.

Механика переключения — представление как указатель: потребители читают analytics.fct_orders, которое является вью над физической таблицей fct_orders_v1 или fct_orders_v2. Релиз = пересоздание вью; откат = пересоздание обратно, мгновенно и без пересчёта.

-- Публичное имя не меняется никогда; меняется только то, куда оно указывает.
CREATE OR REPLACE VIEW analytics.fct_orders AS SELECT * FROM analytics_int.fct_orders_v2;
-- Откат: та же команда с v1. Секунда, а не пересчёт витрины.

Отдельно про удаление: deprecation — процесс, а не решение. Колонка помечается в каталоге, потребители находятся по линиджу, назначается дата, за неделю до неё колонка заполняется NULL в тестовой среде, чтобы «сломалось предсказуемо». Общие практики выката и отката — в «CD и стратегиях релиза», а стратегия ветвления — в «Стратегиях ветвления».


9. Тестирование потоковых джоб

Стриминг из четвёртой главы добавляет измерение, которого нет в батче: время. Дефекты живут не в логике, а в порядке и задержках, поэтому тест обязан управлять временем явно.

  • Детерминированный вход. Набор событий с заданными временными метками, включая заведомо поздние и заведомо не по порядку. Ручной контроль водяного знака: в тестовом харнессе Flink его двигают явно, проверяя, что окно закрылось именно тогда, когда ожидалось.
  • Тест на позднее событие. Пришло после закрытия окна — попало в боковой выход, а не потерялось молча. Это один из самых частых продовых дефектов и один из самых простых тестов.
  • Тест на восстановление. Джоба останавливается, состояние восстанавливается из чекпоинта, результат совпадает с непрерывным прогоном. Ловит некорректную работу с состоянием.
  • Тест на эволюцию состояния. Новая версия джобы читает savepoint старой. Если сериализация состояния изменилась несовместимо, вы узнаете об этом в CI, а не при выкате в 3 часа ночи.
  • Реплей как приёмочный тест. Прогон исторического отрезка топика через новую версию и сравнение с эталоном — тот же data diff, только для потока.

Инфраструктурно это MiniCluster во Flink, встроенные тестовые харнессы Kafka Streams или Testcontainers с реальным брокером. Замена брокера моком экономит минуту прогона и теряет ровно то, ради чего тест писался, — семантику доставки.


10. Стоимость и время CI

CI в данных стоит денег буквально, поэтому его бюджет — инженерная задача, а не бухгалтерская.

Приём Эффект Риск
Дифференциальная сборка (state:modified+) минус порядок по времени и деньгам пропуск эффекта, если зависимость неявная (ref не проставлен)
Ограничение окна данных в CI линейное сокращение сканирования не воспроизводятся дефекты на длинной истории
Deferred-зависимости не пересобираем чужие модели CI зависит от доступности прода
Кэш артефактов и пакетов минус минуты устаревший манифест даёт неверный diff
Nightly полная сборка ловит то, что пропустил PR-контур стоит денег каждую ночь; ставить на выборочные проекты
Ограничение размера warehouse для CI предсказуемый счёт долгие прогоны при больших PR

Практический ориентир: PR-проверка должна укладываться в 10 минут — за этой границей разработчики начинают её обходить, и никакие правила процесса это не лечат. Общий разбор построения CI-конвейеров — в «Основах CI» и «Тестах в CI».


11. Метрики процесса

Понять, работает ли вся конструкция, можно по четырём числам. Их полезно считать так же аккуратно, как продуктовые метрики — платформа данных вполне способна измерить сама себя.

  • Время от коммита до данных в проде. Если сутки — команда будет копить изменения в один большой релиз, а большой релиз ломается сильнее.
  • Доля инцидентов качества, вызванных релизом. Растёт — значит, PR-контур пропускает; падает при неизменном числе релизов — контур работает.
  • Время до отката. Есть ли вообще механизм отката, или единственный путь — «поправим и пересчитаем за ночь».
  • Покрытие критичных моделей. Не «процент моделей с тестами» (эту метрику легко накрутить not_null на всё подряд), а «доля Tier-1-моделей, у которых есть unit-тест на бизнес-логику».

12. Типичные ошибки

  1. Тесты только в проде. Дефект ловится после материализации, каждый пропуск стоит пересчёта.
  2. Unit-тесты на живых данных. Флаки, отсутствие граничных случаев, эталон считается тем же кодом, что и проверяемое.
  3. Полная сборка в каждом PR. Сорок минут и счёт — проверку начинают обходить.
  4. Нет data diff. Ревью SQL превращается в чтение текста; изменение чисел обнаруживает аналитик.
  5. <> вместо IS DISTINCT FROM в сравнениях. Изменения с участием NULL не видны.
  6. Случайная подвыборка для среды разработки. Join’ы не находят пар, тесты зелёные на пустоте.
  7. Прод-данные с PII в личных схемах. Работает до первого аудита или утечки.
  8. Изменение определения метрики без backfill и объявления. Числа «сами поменялись» — доверие потеряно надолго.
  9. Удаление колонки сразу после того, как «вроде никто не использует». Линидж проверяют после инцидента, а не до.
  10. CI, зависящий от расписания прода. Прогон PR ждёт ночного обновления — обратная связь приходит на следующий день и обесценивается.

13. Как это выглядит в проде

В зрелой команде изменение витрины выглядит так. Разработчик работает в личной схеме-клоне, собирает две модели за 40 секунд. Пул-реквест запускает линтер, компиляцию, unit-тесты на фикстурах, дифференциальную сборку в эфемерной схеме и data diff — суммарно пять минут. В PR автоматически появляется комментарий: изменилось 0,4 % строк, все с непустым refund_id, сумма выручки за 7 дней уменьшилась на 1,2 %. Ревьюер спрашивает, ожидаемо ли это; автор отвечает, что да, и прикладывает план backfill за 90 дней. После мержа модель собирается по расписанию, а WAP-проверки из главы 07 не пускают результат наружу, если объём отклонился от нормы. Эфемерная схема удаляется автоматически.

Если что-то пошло не так, откат — это переключение вью на предыдущую версию таблицы, а не ночной пересчёт.


14. Мини-итог

  • Проверки кода до релиза и проверки данных в проде — разные сети; нужны обе.
  • Полезные тесты растут из явных допущений модели: уникальность ключа, NULL, границы интервалов, часовые пояса, поздние строки, деньги.
  • Unit-тест трансформации — фикстура на вход, ожидаемый результат на выход; реальные данные для этого не годятся.
  • Среда для PR строится клоном или ключевой подвыборкой; персональные данные в неё не попадают.
  • Дифференциальная сборка с deferred-зависимостями превращает CI из получаса в минуты.
  • Data diff отвечает на главный вопрос ревью: что изменится в данных.
  • Релиз ломающего изменения — двухфазный, с публичным именем-указателем и мгновенным откатом.
  • Стриминг тестируется управляемым временем: поздние события, восстановление из чекпоинта, совместимость состояния.
  • PR-проверка дольше десяти минут перестаёт запускаться — это инженерное ограничение, а не пожелание.

Источники

Что дальше

Изменения теперь безопасны, но у платформы есть второе постоянное давление — счёт. Один и тот же результат можно получить за 40 секунд и 2 цента или за 20 минут и 12 долларов, и разница почти никогда не в «мощности кластера»: она в том, сколько байт прочитано, сколько раз пересчитано и что материализовано. Следующая статья — про то, как читать план запроса, выбирать уровень материализации и удерживать стоимость аналитической нагрузки под контролем.

Дальше: Производительность и стоимость аналитической нагрузки.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов