Зачем DE вообще писать тесты
В предыдущих модулях мы написали кучу кода: парсеры CSV, клиенты к API, ETL-функции, обёртки над psycopg. Всё это работает «на моём ноутбуке», потому что мы кормили его данными, которые сами и придумали. Но завтра приходит реальный CSV с лишней запятой в третьем поле, послезавтра — API меняет формат даты, а через неделю кто-то меняет имя колонки в БД, и наш красивый pipeline молча начинает писать в target пустые строки.
NULL-ами. Без тестов вы узнаёте об этом через два дня от аналитика, который не понимает, почему его дашборд показывает 0 выручки.
- Ловят регрессии. Поменял функцию —
pytestза 2 секунды говорит, какие из 200 проверок упали. - Документируют поведение. Чтобы понять, что делает
parse_record, не надо читать сам код — читайtest_parse_record. Там в каждом тесте указаны вход и ожидаемый выход. - Дают уверенность для рефакторинга. Хочешь переписать ETL-функцию — пиши тесты на старую реализацию, переписывай, прогоняй тесты. Если зелёные — поведение не сломано.
В этом модуле мы берём
unittest из stdlib, nose2) либо устарели, либо мертвы.
Виды тестов: unit, integration, e2e
Перед тем, как писать код, договоримся о словаре. Когда вам на собеседовании скажут «напишите unit-тест на функцию» — речь не про размер, а про границу проверки.
requests.get — вы её мокаете. Цель: проверить логику функции, а не «работают ли вместе все компоненты».
load_to_db + реальная in-memory SQLite — мы хотим убедиться, что SQL-запрос синтаксически валиден, что транзакция коммитится, что колонки маппятся правильно.
Пропорция в реальном проекте: много unit, средне integration, мало e2e. Это знаменитая «
Чем выше — тем шире охват, но дороже запуск. В реальном проекте 80% времени тратится на unit-тесты.
Для junior DE 99% работы — unit-тесты. К ним и пойдём.
Установка pytest
pytest нужен только для разработки — в продакшен он не идёт. Поэтому ставим в
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 находит тесты:
- Файлы: всё, что подходит под маску
test_*.pyили*_test.py. По умолчанию первая. Файлы вне этой маски игнорируются — туда можно класть утилиты, helpers, фикстурные данные. - Функции и классы: внутри файла берутся функции
test_*и классыTest*с методамиtest_*. Всё остальное — вспомогательный код.
Это называется
Discovery идёт от корня проекта по дереву файлов. Имена решают всё.
Создадим первый тест. Допустим, в 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) это спасает часы разбирательств.
Если хочется добавить кастомное сообщение — 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 (один тест с десятком наборов входных данных).