Модуль 9: Типизация, тестирование и качество
В этом модуле мы систематизируем инженерные практики: статическая типизация, исключения и контракты, логирование, создание CLI, тестирование и мокирование. Также рассмотрим ключевые модули стандартной библиотеки, которые встречаются в большинстве проектов.
9.1 Типизация и mypy
Аннотации типов улучшают читаемость и надёжность кода, позволяют инструментам (mypy, pyright) проверять ошибки на этапе разработки.
from typing import Optional, Union, Iterable, Sequence, Mapping, Callable, TypeVar, Generic, Literal
T = TypeVar("T")
# Базовые аннотации
age: int = 42
name: str = "Alice"
ratio: float = 0.75
# Optional/Union
user_id: Optional[int] = None
value: Union[int, float] = 3.14
# Коллекции
nums: list[int] = [1, 2, 3]
users: dict[str, int] = {"alice": 1}
# Параметры функций и возвращаемые типы
def add(a: int, b: int) -> int:
return a + b
# Callable
def apply(f: Callable[[int], int], x: int) -> int:
return f(x)
# Обобщения
class Box(Generic[T]):
def __init__(self, value: T) -> None:
self.value = value
def get(self) -> T:
return self.value
# Literal полезен для ограниченных наборов значений
Mode = Literal["r", "w", "a"]
def open_mode(mode: Mode) -> None:
...
# Протоколы и структурная типизация
from typing import Protocol
class SupportsClose(Protocol):
def close(self) -> None: ...
class Resource:
def close(self) -> None:
print("closed")
# TypedDict для словарей с фиксированной схемой
from typing import TypedDict
class User(TypedDict):
id: int
name: str
is_active: bool
u: User = {"id": 1, "name": "Alice", "is_active": True}
Настройка mypy
Проверка типов выполняется вне рантайма. Добавьте конфиг и запустите проверку.
# mypy.ini
[mypy]
python_version = 3.11
warn_unused_ignores = True
warn_return_any = True
warn_redundant_casts = True
no_implicit_optional = True
strict = True
[mypy-tests.*]
ignore_errors = True
# Пример запуска
mypy src/
Типизация не влияет на выполнение кода, но помогает IDE, CI и код-ревью. В сочетании с dataclasses
/pydantic
повышает надёжность.
9.2 Исключения и контракты
Исключения — стандартный механизм сигнализации об ошибках. Контракты формализуют предпосылки и постусловия функций.
class DomainError(Exception):
pass
class ValidationError(DomainError):
pass
def divide(a: float, b: float) -> float:
# Предусловие (contract): b != 0
assert b != 0, "b must be non-zero"
return a / b
try:
result = divide(10, 0)
except AssertionError as e:
# Контракт нарушен
print(e)
# Контекстные менеджеры для гарантированного освобождения ресурсов
from contextlib import contextmanager
@contextmanager
def open_safely(path: str):
f = open(path, "w", encoding="utf-8")
try:
yield f
finally:
f.close()
with open_safely("file.txt") as f:
f.write("hello")
Лучшие практики
- Поднимайте специфичные исключения (не глотайте их без логирования).
- Используйте контракты через
assert
для инвариантов и предусловий; в проде — валидацию входных данных. - Для сложной валидации используйте
pydantic
илиattrs
. - Оберните границы I/O в адаптеры и пробрасывайте доменные ошибки.
9.3 Логирование
logging
— стандартный фреймворк логирования. Настройте формат, уровни и хендлеры.
import logging
from pathlib import Path
LOG_DIR = Path("logs")
LOG_DIR.mkdir(exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
handlers=[
logging.StreamHandler(),
logging.FileHandler(LOG_DIR / "app.log", encoding="utf-8")
]
)
logger = logging.getLogger("app")
logger.info("started")
logger.warning("something odd")
# dictConfig для продвинутой конфигурации
import logging.config
LOGGING = {
"version": 1,
"handlers": {
"console": {"class": "logging.StreamHandler", "level": "DEBUG"},
},
"loggers": {
"app": {"handlers": ["console"], "level": "INFO"}
}
}
logging.config.dictConfig(LOGGING)
log = logging.getLogger("app")
log.info("configured via dictConfig")
Для структурированных логов рассмотрите structlog
или logging.LoggerAdapter
с добавлением контекста.
9.4 CLI и argparse
Создавайте удобные консольные интерфейсы с argparse
. Для сложных CLI обратите внимание на click
и typer
.
# cli.py
import argparse
from pathlib import Path
parser = argparse.ArgumentParser(prog="photo-tool", description="Resize photos")
parser.add_argument("input", type=Path, help="Папка с изображениями")
parser.add_argument("--width", type=int, default=800)
parser.add_argument("--height", type=int, default=600)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
print(args.input, args.width, args.height, args.dry_run)
# Подкоманды
sub = argparse.ArgumentParser(prog="tool")
cmd = sub.add_subparsers(dest="command", required=True)
build = cmd.add_parser("build")
build.add_argument("--prod", action="store_true")
serve = cmd.add_parser("serve")
serve.add_argument("--port", type=int, default=8000)
9.5 Тестирование pytest
pytest
— де-факто стандарт в Python. Чистый синтаксис, мощные фикстуры и плагин-экосистема.
# test_math.py
import pytest
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
])
def test_add(a, b, expected):
assert a + b == expected
# Фикстуры
@pytest.fixture
def tmp_user(tmp_path):
p = tmp_path / "user.json"
p.write_text("{}", encoding="utf-8")
return p
# pytest.ini
[pytest]
addopts = -q -ra
filterwarnings = ignore::DeprecationWarning
python_files = tests.py test_*.py *_tests.py
markers =
slow: медленные тесты
integration: интеграционные
Покрывайте тестами бизнес-правила и границы систем. В CI включайте отчёт о покрытии (coverage.py
).
9.6 Мокирование
Изолируйте тестируемый код от внешних зависимостей: сети, БД, времени.
# unittest.mock
from unittest.mock import patch, MagicMock
import requests
@patch("requests.get")
def test_fetch(mock_get: MagicMock):
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {"ok": True}
resp = requests.get("https://api.example.com")
assert resp.json()["ok"] is True
# monkeypatch (pytest)
def test_env(monkeypatch):
monkeypatch.setenv("APP_ENV", "test")
import os
assert os.getenv("APP_ENV") == "test"
Для времени используйте freezegun
, для HTTP — responses
или pytest-httpserver
.
9.7 Широко используемая стандартная библиотека
itertools
from itertools import islice, chain, combinations, groupby
nums = range(100)
first_ten = list(islice(nums, 10))
pairs = list(combinations([1,2,3], 2)) # [(1,2), (1,3), (2,3)]
# Группировка
animals = ["ant", "bear", "cat", "cow"]
key = lambda s: s[0]
for k, g in groupby(sorted(animals, key=key), key=key):
print(k, list(g))
functools
from functools import lru_cache, partial, singledispatch
@lru_cache(maxsize=1024)
def fib(n: int) -> int:
return n if n < 2 else fib(n-1) + fib(n-2)
mul_by_10 = partial(lambda x, y: x * y, 10)
@singledispatch
def dump(obj):
return str(obj)
@dump.register
def _(obj: list):
return f"list({len(obj)})"
pathlib
from pathlib import Path
p = Path("data") / "file.txt"
if not p.exists():
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("hello", encoding="utf-8")
print(p.read_text(encoding="utf-8"))
datetime и zoneinfo
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
now_utc = datetime.now(timezone.utc)
now_msk = now_utc.astimezone(ZoneInfo("Europe/Moscow"))
print(now_msk.isoformat())