Learning Platform
Урок 10.01 · 22 мин
Начальный
pytestunit testsasserttest discoveryconftest
CI vs CD: что увидит junior DE в первую неделю Data quality: где валидировать данные в pipeline

Зачем DE вообще писать тесты

В предыдущих модулях мы написали кучу кода: парсеры CSV, клиенты к API, ETL-функции, обёртки над psycopg. Всё это работает «на моём ноутбуке», потому что мы кормили его данными, которые сами и придумали. Но завтра приходит реальный CSV с лишней запятой в третьем поле, послезавтра — API меняет формат даты, а через неделю кто-то меняет имя колонки в БД, и наш красивый pipeline молча начинает писать в target пустые строки.

Регрессии
— главная боль любого live-проекта. Кто-то делает невинный коммит, всё компилируется и линтится, но в продакшене таблица начинает заполняться NULL-ами. Без тестов вы узнаёте об этом через два дня от аналитика, который не понимает, почему его дашборд показывает 0 выручки.

Автотесты
решают три задачи одновременно:

  1. Ловят регрессии. Поменял функцию — pytest за 2 секунды говорит, какие из 200 проверок упали.
  2. Документируют поведение. Чтобы понять, что делает parse_record, не надо читать сам код — читай test_parse_record. Там в каждом тесте указаны вход и ожидаемый выход.
  3. Дают уверенность для рефакторинга. Хочешь переписать ETL-функцию — пиши тесты на старую реализацию, переписывай, прогоняй тесты. Если зелёные — поведение не сломано.

В этом модуле мы берём

pytest
, версия 8.x — индустриальный стандарт. Альтернативы (unittest из stdlib, nose2) либо устарели, либо мертвы.

Виды тестов: unit, integration, e2e

Перед тем, как писать код, договоримся о словаре. Когда вам на собеседовании скажут «напишите unit-тест на функцию» — речь не про размер, а про границу проверки.

Unit test
проверяет одну функцию в изоляции. Никакой БД, никакой сети, никаких файлов (или только tmp-файлы). Если функция вызывает requests.get — вы её мокаете. Цель: проверить логику функции, а не «работают ли вместе все компоненты».

Integration test
проверяет несколько модулей вместе. Например, функция load_to_db + реальная in-memory SQLite — мы хотим убедиться, что SQL-запрос синтаксически валиден, что транзакция коммитится, что колонки маппятся правильно.

End-to-end (e2e) test
прогоняет весь pipeline. Скачали JSON с (mock) API, прогнали через transform, записали в (test) БД, прочитали обратно, сравнили с эталоном. В DE это обычно один-два теста «happy path» на весь pipeline — медленные, но критичные.

Пропорция в реальном проекте: много unit, средне integration, мало e2e. Это знаменитая «

пирамида тестов
».

Пирамида тестов

Чем выше — тем шире охват, но дороже запуск. В реальном проекте 80% времени тратится на unit-тесты.

e2e5%Полный pipeline. Медленные, хрупкие. 1-3 теста.
что покрытоAPI → transform → DB
integration15-20%Несколько модулей вместе. Реальная SQLite/локальный mock-server.
что покрытоETL + БД, client + responses
unit75-80%Одна функция. Никакого I/O, никакой сети. Запускаются за миллисекунды.
что покрытоparse_*, transform_*, validate_*

Для junior DE 99% работы — unit-тесты. К ним и пойдём.

Установка pytest

pytest нужен только для разработки — в продакшен он не идёт. Поэтому ставим в

dev-группу
:

uv add --group dev pytest

Эта команда обновит pyproject.toml:

[dependency-groups]
dev = [
    "pytest>=8.3",
]

И обновит uv.lock. В образ для продакшена (см. модуль 10 — Capstone) эта группа не попадёт, экономим место и attack surface.

Проверим:

uv run pytest --version

Должно вывести pytest 8.x.x.

Структура: где живут тесты

Стандартное соглашение для Python-проекта:

my-etl/
├── src/
│   └── my_etl/
│       ├── __init__.py
│       ├── parser.py
│       └── loader.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py
│   ├── test_parser.py
│   └── test_loader.py
└── pyproject.toml

Два правила, по которым pytest находит тесты:

  1. Файлы: всё, что подходит под маску test_*.py или *_test.py. По умолчанию первая. Файлы вне этой маски игнорируются — туда можно класть утилиты, helpers, фикстурные данные.
  2. Функции и классы: внутри файла берутся функции test_* и классы Test* с методами test_*. Всё остальное — вспомогательный код.

Это называется

test discovery
.

Как pytest находит тесты

Discovery идёт от корня проекта по дереву файлов. Имена решают всё.

стартpwd / rootdirКорень определяется по conftest.py / pyproject.toml
обходвсё дерево
фильтр файловtest_*.pyМаска по умолчанию
parseимпорт модуля
внутри файлаtest_* / Test*Функции и классы по имени
запусккаждый test_ как отдельный тест

Создадим первый тест. Допустим, в src/my_etl/parser.py живёт функция:

# src/my_etl/parser.py
def parse_csv_record(line: str, *, sep: str = ",") -> list[str]:
    """Разбивает CSV-строку на поля по разделителю.

    Простая версия (без поддержки экранирования) — для иллюстрации.
    """
    return [field.strip() for field in line.split(sep)]

И tests/test_parser.py:

from my_etl.parser import parse_csv_record


def test_simple_record() -> None:
    assert parse_csv_record("a,b,c") == ["a", "b", "c"]


def test_whitespace_stripped() -> None:
    assert parse_csv_record("a , b , c") == ["a", "b", "c"]


def test_custom_separator() -> None:
    assert parse_csv_record("a;b;c", sep=";") == ["a", "b", "c"]


def test_empty_string() -> None:
    assert parse_csv_record("") == [""]


def test_single_field() -> None:
    assert parse_csv_record("hello") == ["hello"]

Запуск:

uv run pytest

Вывод:

============================= test session starts ==============================
platform darwin -- Python 3.13.1, pytest-8.3.4
rootdir: /path/to/my-etl
collected 5 items

tests/test_parser.py .....                                                [100%]

============================= 5 passed in 0.02s ===============================

Пять точек — пять зелёных тестов. Готово.

Магия assert: diff на failure

В классическом unittest (stdlib) пишут так:

self.assertEqual(actual, expected)
self.assertIn(item, container)
self.assertRaises(ValueError, fn, arg)

В pytest — просто assert:

assert actual == expected
assert item in container

Технически это тот же Python — но pytest перехватывает assert и при провале показывает разворот: что было слева, что справа, и какая ровно разница.

def test_dict_equality() -> None:
    actual = {"name": "Anna", "age": 30, "city": "Moscow"}
    expected = {"name": "Anna", "age": 31, "city": "Moscow"}
    assert actual == expected

Запуск:

FAILED tests/test_parser.py::test_dict_equality

    def test_dict_equality() -> None:
        actual = {"name": "Anna", "age": 30, "city": "Moscow"}
        expected = {"name": "Anna", "age": 31, "city": "Moscow"}
>       assert actual == expected
E       AssertionError: assert {'age': 30, 'city': 'Moscow', 'name': 'Anna'} == {'age': 31, 'city': 'Moscow', 'name': 'Anna'}
E
E         Common items:
E         {'city': 'Moscow', 'name': 'Anna'}
E         Differing items:
E         {'age': 30} != {'age': 31}

pytest сравнил два словаря и точно показал, какое поле различается. На больших структурах (списки записей из CSV, dict-результаты API) это спасает часы разбирательств.

NOTE

Если хочется добавить кастомное сообщение — assert actual == expected, "loaded count mismatch". pytest всё равно покажет diff + ваше сообщение сверху.

Проверка исключений: pytest.raises

Часто надо проверить, что функция бросает ожидаемое исключение. Например, наш парсер не должен падать на пустых строках, но обязан бросать ValueError на None:

def parse_csv_record(line: str, *, sep: str = ",") -> list[str]:
    if line is None:
        raise ValueError("line must be str, not None")
    return [field.strip() for field in line.split(sep)]

Тест:

import pytest

from my_etl.parser import parse_csv_record


def test_none_raises() -> None:
    with pytest.raises(ValueError, match="must be str"):
        parse_csv_record(None)

pytest.raises(...) — это context manager. Если внутри with бросилось указанное исключение — тест зелёный; если что-то другое — тест красный; если не бросилось ничего — тоже красный.

Аргумент match= — regex по тексту исключения. Можно опустить, но обычно полезно: вы хотите проверить, что бросилось то самое ValueError, а не какое-то совсем чужое.

Поймать исключение и достать атрибуты — через excinfo:

def test_none_raises_detailed() -> None:
    with pytest.raises(ValueError) as excinfo:
        parse_csv_record(None)
    assert "must be str" in str(excinfo.value)

Запуск тестов: ключевые опции

В обычной работе вы будете комбинировать четыре-пять флагов. Запомните их.

# Всё, что найдёт
uv run pytest

# Один конкретный файл
uv run pytest tests/test_parser.py

# Один тест по имени (id функции)
uv run pytest tests/test_parser.py::test_empty_string

# Все тесты, в имени которых есть подстрока
uv run pytest -k "csv"

# Verbose — каждый тест отдельной строкой с именем
uv run pytest -v

# Без перехвата stdout — увидите print() из ваших функций
uv run pytest -s

# Короткий traceback (не вся цепочка вызовов)
uv run pytest --tb=short

# Остановиться на первой ошибке
uv run pytest -x

# Перезапустить только то, что упало в прошлый раз
uv run pytest --lf

Самая популярная комбинация при разработке: uv run pytest -x --tb=short -q. Стало красным — pytest остановился, показал короткий traceback, не загромождая вывод. После починки — uv run pytest --lf -x повторяет упавшие, и идёте дальше.

conftest.py: общие настройки

Файл conftest.py — это «магический» файл, который pytest подхватывает автоматически без импорта. В нём живёт всё, что должно быть общим для тестов соседней папки и подпапок:

  • Фикстуры (про них — в уроке 02).
  • Хуки (например, pytest_collection_modifyitems — модифицировать список тестов).
  • Конфигурация маркеров (pytest.mark.slow, etc.).

Минимальный conftest.py для типичного DE-проекта:

# tests/conftest.py
import os
import sys
from pathlib import Path


# Если используется layout src/, добавим src/ в sys.path для импортов.
# При нормальной установке проекта через `uv pip install -e .` это не нужно,
# но в маленьких прототипах помогает.
SRC = Path(__file__).resolve().parent.parent / "src"
if SRC.is_dir() and str(SRC) not in sys.path:
    sys.path.insert(0, str(SRC))


# Заглушка для переменных окружения, которые читает приложение при импорте.
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
os.environ.setdefault("API_TOKEN", "test-token")

В этом же файле потом пропишутся фикстуры — общие тестовые данные, моки, временные БД.

pyproject.toml: конфигурация pytest

Тонкие настройки pytest — не в отдельном pytest.ini, а в pyproject.toml под [tool.pytest.ini_options]:

[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
    "-ra",                # показывать summary для failed/skipped
    "--strict-markers",   # ругаться на опечатки в @pytest.mark.foo
    "--tb=short",
]
markers = [
    "slow: helper marker for slow integration tests",
    "db: tests that require a real DB connection",
]

testpaths = ["tests"] — pytest не пойдёт обходить весь проект, только эту папку. На больших репах экономит секунды на discovery.

DE-кейс: тест парсера записи

Возьмём чуть более жизненную функцию. Пусть parser.py парсит запись CSV из аналитической выгрузки:

# src/my_etl/parser.py
from dataclasses import dataclass


@dataclass(frozen=True)
class Order:
    order_id: int
    user_id: int
    amount: float
    currency: str


def parse_order(line: str) -> Order:
    """Парсит строку вида '1001,42,199.99,USD'.

    Бросает ValueError, если поле невалидно.
    """
    parts = [p.strip() for p in line.split(",")]
    if len(parts) != 4:
        raise ValueError(f"expected 4 fields, got {len(parts)}: {line!r}")
    order_id_s, user_id_s, amount_s, currency = parts
    try:
        return Order(
            order_id=int(order_id_s),
            user_id=int(user_id_s),
            amount=float(amount_s),
            currency=currency.upper(),
        )
    except ValueError as exc:
        raise ValueError(f"bad record {line!r}: {exc}") from exc

Тесты:

# tests/test_parser.py
import pytest

from my_etl.parser import Order, parse_order


class TestParseOrderHappy:
    def test_basic(self) -> None:
        order = parse_order("1001,42,199.99,USD")
        assert order == Order(1001, 42, 199.99, "USD")

    def test_lowercase_currency_upcased(self) -> None:
        assert parse_order("1,2,3.0,usd").currency == "USD"

    def test_whitespace_ok(self) -> None:
        assert parse_order(" 1 , 2 , 3.0 , USD ") == Order(1, 2, 3.0, "USD")


class TestParseOrderErrors:
    def test_too_few_fields(self) -> None:
        with pytest.raises(ValueError, match="expected 4 fields"):
            parse_order("1,2,3")

    def test_too_many_fields(self) -> None:
        with pytest.raises(ValueError, match="expected 4 fields"):
            parse_order("1,2,3,USD,extra")

    def test_non_int_id(self) -> None:
        with pytest.raises(ValueError, match="bad record"):
            parse_order("abc,2,3.0,USD")

    def test_non_float_amount(self) -> None:
        with pytest.raises(ValueError, match="bad record"):
            parse_order("1,2,not-a-number,USD")

Заметьте: тесты сгруппированы в классы по «теме». pytest найдёт их через Test* и test_* без явной регистрации. Группировка нужна нам, людям — чтобы быстро понять структуру.

Это и есть рабочий стиль unit-тестирования DE-кода: одна функция — десяток тестов, покрывающих happy path и все edge cases (пустые, мусорные, граничные). Если функция честно написана с проверками — все тесты зелёные за миллисекунды.

Что мы получили

  • pytest ищет файлы test_*.py, в них — функции test_* и классы Test*. Никакой явной регистрации.
  • assert actual == expected — pytest сам показывает diff, никаких assertEqual.
  • pytest.raises(ExcClass, match="...") — проверяем исключения.
  • Ключевые флаги: -v, -s, --tb=short, -x, --lf, -k expr.
  • conftest.py — общие настройки и фикстуры для соседних тестов. Подхватывается автоматически.
  • pyproject.toml под [tool.pytest.ini_options] — единая конфигурация без отдельных файлов.
  • Стиль: каждая функция покрыта блоком тестов, happy path и errors разделены.

В следующем уроке — фикстуры (как переиспользовать setup между тестами) и parametrize (один тест с десятком наборов входных данных).

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

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

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

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