Все материалы
Продуктовая аналитикапрактикумсредний

Как тестировать аналитические расчёты на Python и pandas

Практический гайд по тестам для аналитика: проверить метрики на маленьком датасете, поймать регрессию и защитить расчёт от тихих изменений.

КПКейсПрактика9 августа 2026 г.18 мин

Тесты нужны не только для backend-кода. Аналитический расчёт тоже может сломаться после переименования колонки, изменения фильтра или нового правила дедупликации. Самая опасная ошибка — не падение программы, а правдоподобная цифра, которая стала другой.

Коротко

Тест аналитического кода проверяет не стиль Python, а смысл результата: правильный знаменатель, уникальность пользователя, границы периода и ожидаемое поведение на маленьком наборе данных. Начать можно с нескольких DataFrame и обычных assert, а затем вынести проверки в pytest.

  • Пиши маленький тестовый датасет, где ответ можно посчитать вручную.
  • Проверяй не только тип и форму, но и конкретные значения метрики.
  • Отдельно тестируй крайние случаи: пустой период, дубликаты, пропуски и нулевой знаменатель.
  • Тесты должны быть независимыми от реального CSV и текущей даты.

Сначала вынеси расчёт в функцию

Код в ячейке сложно проверить, если он одновременно читает файл, фильтрует даты и рисует график. Вынеси бизнес-правило в функцию с DataFrame на входе. Тогда тест сможет передать маленький набор строк и сравнить результат с ожидаемым.

pythonРасчёт DAU по core action
import pandas as pd

def calculate_dau(events: pd.DataFrame) -> pd.DataFrame:
    active = events.loc[events['event_name'].eq('core_action')].copy()
    active['event_date'] = pd.to_datetime(active['occurred_at']).dt.date
    return (
        active.groupby('event_date', as_index=False)
        .agg(dau=('user_id', 'nunique'))
        .sort_values('event_date')
    )

Контрольный датасет должен быть маленьким и хитрым

Четыре строки, где каждый пользователь совершает действие один раз, проверяют только счастливый путь. Добавь повторное событие одного пользователя, событие другого типа, следующий день и пользователя с пропуском. Такой набор лучше ловит ошибку count вместо nunique и неправильный фильтр.

pythonПример ожидаемого результата
events = pd.DataFrame({
    'user_id': [1, 1, 2, 3, 3],
    'occurred_at': [
        '2026-08-01 10:00', '2026-08-01 10:05',
        '2026-08-01 11:00', '2026-08-01 12:00',
        '2026-08-02 09:00',
    ],
    'event_name': ['core_action', 'core_action', 'login', 'core_action', 'core_action'],
})

actual = calculate_dau(events)
expected = pd.DataFrame({
    'event_date': [pd.to_datetime('2026-08-01').date(), pd.to_datetime('2026-08-02').date()],
    'dau': [2, 1],
})
pd.testing.assert_frame_equal(actual.reset_index(drop=True), expected)

Что проверять кроме одного числа

Для метрики важны структура и инварианты. DAU не может быть больше числа уникальных пользователей в исходном срезе. Выручка после фильтра paid не должна стать отрицательной. Доля групп должна складываться в 100% с оговоркой на округление. Такие проверки переживают изменение конкретных значений.

Инварианты для аналитического расчёта
ОбъектПроверка
DAUне больше числа уникальных пользователей в периоде
Retentionот 0 до 1 и не выше размера стартовой когорты
Конверсияот 0 до 1, знаменатель не равен нулю
Выручканеотрицательна после явной обработки возвратов
Сегментыключи не пропали после merge, unknown виден отдельно

pytest делает тесты частью запуска

Когда функций становится больше, тесты удобно хранить отдельно и запускать одной командой. pytest покажет, какой сценарий сломался. Для аналитика особенно полезны читаемые имена: по названию должно быть ясно, какую бизнес-ошибку защищает тест.

pythonТест с понятным сценарием
def test_dau_counts_unique_users_per_day():
    events = make_events_for_two_days()

    result = calculate_dau(events)

    assert result.loc[result['event_date'].eq(date(2026, 8, 1)), 'dau'].item() == 2
    assert result['dau'].sum() == 3
Тест — не доказательство правильной метрики

Он защищает зафиксированное правило. Если само определение DAU изменилось, сначала обнови решение и документацию, а потом тест. Не меняй expected только ради зелёного CI.

Три уровня тестов для аналитического кода

У аналитического расчёта есть как минимум три уровня проверки. На первом уровне тестируется маленькая функция: например, фильтр core action или расчёт D7. Такой тест быстрый и точно показывает, какое правило изменилось. На втором уровне проверяется связка функций на подготовленном наборе данных: загрузка, нормализация дат, дедупликация и агрегация. На третьем уровне сверяется итоговый отчёт с контрольным числом или независимым источником. Не стоит пытаться одним тестом доказать всё сразу: при падении будет непонятно, ошибка в схеме, в фильтре или в формуле.

Для pandas полезно разделить тесты по смыслу. Unit-тест отвечает на вопрос «правильно ли работает функция на известном DataFrame». Contract-тест проверяет, что источник всё ещё содержит нужные колонки и допустимые типы. Regression-тест хранит ожидаемое значение для стабильного исторического периода. Это разные виды уверенности. Если добавить только сравнение итоговой суммы, незаметная ошибка в одном сегменте может компенсироваться другой и пройти проверку.

Фикстура должна быть небольшой, но не игрушечной. В ней оставь повторное событие, пропуск, неизвестную категорию и случай на границе периода. Название теста формулируй как бизнес-утверждение: test_dau_counts_unique_users_per_day полезнее, чем test_report. Через месяц такое имя напомнит, почему expected равен именно двум.

  • Unit-тест: одна функция и несколько строк, ответ считается вручную.
  • Contract-тест: источник соответствует ожидаемой схеме.
  • Regression-тест: стабильный срез не изменился без объяснения.
  • Smoke-тест: полный pipeline запускается на маленьком файле от начала до конца.

Крайние случаи, которые меняют смысл метрики

Пустой DataFrame — не экзотика. Он появляется, когда за выбранный день не было событий или фильтр получил новый код действия. Функция должна либо вернуть пустой результат с правильными колонками, либо явно сообщить, что расчёт невозможен. Возвращать случайный ноль опасно: ноль означает измеренное отсутствие активности, а пустой результат может означать отсутствие данных.

Проверь границы дат отдельно. Событие в 2026-08-01 23:59:59 должно попасть в день 1 августа, а событие ровно в 2026-08-02 00:00:00 — уже нет. Если расчёт использует UTC, это зафиксируй в тесте. Иначе переход на локальный часовой пояс изменит дневные значения, хотя исходные события останутся теми же.

Для денежных полей добавь отрицательные значения, возвраты и десятичные хвосты. Для идентификаторов — пропуск и строковый вариант числа. Для merge — пользователя без строки в справочнике и справочник с повторным ключом. Тест должен не только показать ошибку, но и закрепить решение: отбрасываем строку, отправляем в quarantine, относим к unknown или останавливаем pipeline.

Минимальный набор сценариев для метрики
СценарийЧто может сломатьсяКакое правило зафиксировать
пустой деньфункция падает при groupbyпустой результат с сохранённой схемой
повторное событиеDAU считается через countодин пользователь считается один раз в день
UTC и локальная датасобытие попадает в соседний деньчасовой пояс задан до агрегации
дубликат ключа в справочникеmerge размножает строкиvalidate останавливает расчёт
нулевой знаменательполучается inf или NaNвернуть NA и показать отсутствие базы

Как разбирать падение теста, а не чинить симптом

Красный тест — это начало диагностики. Сначала сравни входной DataFrame с фикстурой: не изменился ли тип даты, порядок строк или набор колонок. Затем сравни промежуточные таблицы. Для расчёта DAU полезно отдельно вывести число строк после фильтра core action, число уникальных пользователей и диапазон дат. Если итог отличается, промежуточные значения обычно сразу показывают, на каком шаге возникло расхождение.

Не делай sort_values и reset_index внутри expected только для того, чтобы скрыть разницу. Сначала реши, должен ли порядок быть частью контракта. Для набора по дням порядок обычно важен и его стоит проверять. Для словаря сегментов порядок строк может быть несущественным, тогда сравнивай после явной сортировки по ключу. Такой выбор делает тест честным.

Если изменилось бизнес-правило, обнови три вещи одновременно: описание метрики, реализацию и тест. В pull request или журнале запуска оставь короткую причину изменения. Иначе через месяц будет невозможно отличить намеренную смену определения от случайной регрессии.

Зелёный тест не заменяет ревью определения

Тест проверяет то, что в него записали. Перед обновлением expected спроси: поменялась логика продукта, источник данных или только код? Если ответ неясен, сначала сравни результат с независимым расчётом и владельцем метрики.

Практика: чеклист перед публикацией отчёта

Перед публикацией ежедневного отчёта прогони небольшой набор проверок. Убедись, что период не пустой случайно, количество строк после фильтров находится в ожидаемом диапазоне, даты не ушли в будущее, а знаменатель не равен нулю. Для сегментов сравни сумму групп с общей базой и отдельно выведи unknown. Это не математическое доказательство качества, но хороший сигнал о смене схемы или сломанном трекинге.

Затем запусти regression-тест на последнем полном периоде и на одном историческом периоде. Последний период нужен, чтобы поймать недавнее изменение источника. Исторический защищает от ситуации, когда новый код случайно подогнали под свежие данные. Для больших файлов не обязательно гонять полный pipeline в каждом коммите: быстрый smoke запускается всегда, полный regression — по расписанию или перед публикацией.

Результат тестов стоит показывать рядом с данными: сколько строк проверено, сколько отправлено в quarantine, какая доля unknown и когда был последний успешный запуск. Так качество становится частью отчёта, а не личной памятью аналитика.

  • Проверить схему и типы до расчёта.
  • Сохранить входной период и версию набора данных.
  • Сравнить ключевые суммы с независимым источником.
  • Показать quarantine и unknown, а не спрятать их фильтром.
  • Только после этого публиковать график и вывод для команды.
Продолжить чтение
Вся библиотека