Зачем мокать вообще
Допустим, у вас есть функция, которая забирает данные из GitHub API и пишет в Postgres:
def sync_repo_stars(owner: str, name: str, db: Connection) -> int:
response = httpx.get(f"https://api.github.com/repos/{owner}/{name}")
response.raise_for_status()
data = response.json()
db.execute(
"INSERT INTO repos (name, stars) VALUES (?, ?)",
(data["full_name"], data["stargazers_count"]),
)
return data["stargazers_count"]
Хочется написать тест. Как?
Вариант 1 — звонить в настоящий GitHub. Тест становится медленным (200 мс на запрос), флэкающим (упал интернет — упал тест), зависит от внешнего сервиса (GitHub поменял схему — все тесты красные), требует токен (его секретно нужно прокидывать в CI). Плохая идея.
Вариант 2 — поднять локальный mock-сервер. Сложно настроить, ещё одна точка отказа.
Вариант 3 — подменить httpx.get на функцию-заглушку, которая вместо реального запроса вернёт заранее подготовленный ответ. Это и есть
Цели мокирования:
- Скорость — тест выполняется за миллисекунды, а не за секунды.
- Детерминизм — один и тот же тест всегда даёт один и тот же результат. Никаких «у меня иногда падает».
- Изоляция — упал интернет, упала БД, GitHub за пейволом — тесты идут.
- Контроль — мы можем легко проверить, как функция реагирует на 404, на 500, на таймаут.
Цена — тест проверяет только нашу логику, а не «оно правда работает с настоящим GitHub». Поэтому моки — для unit-тестов; для проверки «работает целиком» нужны integration/e2e тесты (см. урок 1).
Уровни мокирования в pytest
В pytest есть три инструмента мокирования, и они дополняют друг друга. По возрастанию мощности:
monkeypatch— встроенный, простой, для подмены переменных окружения, атрибутов модулей, функций. Лучший выбор для 80% случаев.unittest.mock— stdlib, мощный:Mock,MagicMock,patch. Подменяет вообще что угодно, в том числе атрибуты внутри сторонних объектов.- Спец-инструменты для HTTP —
httpx.MockTransport,respx. Перехватывают сетевой слой, не зная про конкретную функцию.
Разберём по очереди.
monkeypatch: точечная подмена
monkeypatch — это built-in fixture (из предыдущего урока). Главная фишка: всё, что вы поменяете, откатится автоматически после теста.
Подмена переменных окружения
Самый частый кейс — наша функция читает токен из os.environ:
import os
import httpx
def github_client() -> httpx.Client:
token = os.environ["GITHUB_TOKEN"]
return httpx.Client(
base_url="https://api.github.com",
headers={"Authorization": f"Bearer {token}"},
)
Тест:
def test_github_client_reads_env(monkeypatch) -> None:
monkeypatch.setenv("GITHUB_TOKEN", "ghp_test_123")
client = github_client()
assert client.headers["Authorization"] == "Bearer ghp_test_123"
def test_github_client_missing_env(monkeypatch) -> None:
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
with pytest.raises(KeyError):
github_client()
Главное — после теста GITHUB_TOKEN восстановится к тому, что было до. Если у вас локально стояла настоящая переменная — она вернётся.
Подмена атрибута модуля
# my_etl/clock.py
from datetime import datetime, timezone
def now() -> datetime:
return datetime.now(timezone.utc)
from datetime import datetime, timezone
from my_etl import clock
def test_now_can_be_frozen(monkeypatch) -> None:
frozen = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)
monkeypatch.setattr(clock, "now", lambda: frozen)
assert clock.now() == frozen
monkeypatch.setattr(clock, "now", lambda: frozen) — на время теста clock.now указывает на нашу лямбду. После — оригинал на месте.
Тонкий момент: monkeypatch.setattr должен патчить имя там, где оно используется, а не там, где определено. Если внутри функции do_etl написано from my_etl import clock и clock.now() — патчим clock.now. Если from my_etl.clock import now и now() — патчим my_etl.do_etl.now (имя там, куда импортировали). Это типичная ловушка новичков.
Подмена sys.argv
Полезно для CLI-тестов:
def test_cli_parses_args(monkeypatch) -> None:
monkeypatch.setattr("sys.argv", ["my-etl", "--mode", "dry-run"])
args = parse_cli()
assert args.mode == "dry-run"
unittest.mock: Mock и patch
Когда monkeypatch тесноват — берём unittest.mock из stdlib. Он более выразительный, но и побольше «магии».
Mock как объект-заглушка
Mock — это объект, у которого автоматически создаются любые атрибуты и любые методы:
from unittest.mock import Mock
m = Mock()
m.foo # Mock-атрибут
m.bar.baz() # Mock-метод, возвращает другой Mock
m.return_value = 42 # m() теперь возвращает 42
m() # 42
Это удобно, когда нужно подсунуть «что-то с такими-то методами», не имитируя весь интерфейс реального объекта.
MagicMock
MagicMock — то же самое, что Mock, но дополнительно поддерживает dunder-методы (__len__, __iter__, __enter__, etc.). Если ваш код использует with x: или for item in x — нужен MagicMock:
from unittest.mock import MagicMock
db = MagicMock()
db.cursor.return_value.fetchall.return_value = [(1, "Anna"), (2, "Bob")]
# симулируем: with db.cursor() as cur: cur.fetchall()
with db.cursor() as cur:
rows = cur.fetchall()
assert rows == [(1, "Anna"), (2, "Bob")]
patch как контекст-менеджер и декоратор
patch — самый частый способ. Подменяет имя на время блока кода:
from unittest.mock import patch
# как context manager
def test_with_patch() -> None:
with patch("my_etl.api.httpx.get") as mock_get:
mock_get.return_value.json.return_value = {"id": 42}
mock_get.return_value.status_code = 200
result = fetch_user(42)
assert result == {"id": 42}
mock_get.assert_called_once()
# как decorator
@patch("my_etl.api.httpx.get")
def test_with_decorator(mock_get) -> None:
mock_get.return_value.json.return_value = {"id": 42}
...
Главное правило (то же, что у monkeypatch.setattr): патчите имя там, где оно используется. Если my_etl/api.py делает import httpx и зовёт httpx.get(...) — патчим "my_etl.api.httpx.get", не "httpx.get".
side_effect: исключения и последовательности
return_value — это «всегда возвращай вот это». side_effect — мощнее:
Бросать исключение:
mock_get.side_effect = httpx.TimeoutException("timeout!")
# при первом же вызове mock_get(...) бросится TimeoutException
Возвращать разные значения на каждый вызов:
mock_get.side_effect = [
Response(json_data={"page": 1}),
Response(json_data={"page": 2}),
Response(json_data={"page": 3}),
]
# три первых вызова отдадут эти три, четвёртый — StopIteration
Запускать функцию вместо мока:
def fake_get(url, **kwargs):
if "users" in url:
return Response(json_data={"users": [...]})
return Response(status_code=404)
mock_get.side_effect = fake_get
Это бесценно для теста retry-логики: первый вызов — таймаут, второй — 500, третий — 200. Проверяем, что функция переживает оба фейла и в итоге возвращает корректный результат.
httpx.MockTransport: правильный путь для HTTP
С httpx есть встроенный способ мокать HTTP-запросы, не дёргая внутренности через patch. Это
httpx.MockTransportimport httpx
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/repos/python/cpython":
return httpx.Response(
200,
json={"full_name": "python/cpython", "stargazers_count": 60000},
)
return httpx.Response(404, json={"message": "Not Found"})
def test_sync_repo_stars() -> None:
transport = httpx.MockTransport(handler)
with httpx.Client(transport=transport, base_url="https://api.github.com") as client:
response = client.get("/repos/python/cpython")
assert response.json()["stargazers_count"] == 60000
Что произошло: вместо реального сетевого вызова httpx взял наш handler, передал ему request, получил response. С точки зрения кода клиента — он сделал HTTP-запрос; с точки зрения теста — никакой сети не было.
Этот подход рекомендован httpx в их документации и применяется в уроке про requests/httpx. Он чище, чем patch("...httpx.get") — потому что:
- Работает на уровне транспорта, не лезет во внутренности библиотеки.
- Видит всё, что код делает с клиентом, включая правильные headers и URL.
- Не сломается при обновлении httpx (mock через patch — может сломаться).
Интеграция с fixture
В DE-проекте обычно делают фикстуру, которая отдаёт httpx.Client с MockTransport:
# tests/conftest.py
import httpx
import pytest
def _github_handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if path == "/repos/python/cpython":
return httpx.Response(
200,
json={"full_name": "python/cpython", "stargazers_count": 60000},
)
if path == "/repos/missing/repo":
return httpx.Response(404, json={"message": "Not Found"})
if path == "/repos/server/error":
return httpx.Response(500, json={"message": "boom"})
return httpx.Response(404, json={"message": f"unhandled: {path}"})
@pytest.fixture
def github_client() -> httpx.Client:
transport = httpx.MockTransport(_github_handler)
return httpx.Client(transport=transport, base_url="https://api.github.com")
# tests/test_sync.py
def test_existing_repo(github_client) -> None:
response = github_client.get("/repos/python/cpython")
assert response.status_code == 200
assert response.json()["stargazers_count"] == 60000
def test_missing_repo(github_client) -> None:
response = github_client.get("/repos/missing/repo")
assert response.status_code == 404
def test_handles_5xx(github_client) -> None:
response = github_client.get("/repos/server/error")
assert response.status_code == 500
Альтернатива — пакет
uv add --group dev respx) — более декларативный синтаксис, регистрация ответов через builder-API. Для большинства DE-задач MockTransport достаточно; respx стоит подключать, если у вас десятки эндпоинтов и хочется компактнее.
Мокирование БД: in-memory SQLite
С базой данных самый простой подход — не мокать. Используйте in-memory SQLite (см. предыдущий урок, фикстура db). Это реальная БД, реальные SQL-запросы, реальные ошибки. Скорость — миллисекунды на тест.
@pytest.fixture
def db() -> Iterator[sqlite3.Connection]:
conn = sqlite3.connect(":memory:")
conn.executescript("""
CREATE TABLE repos (
name TEXT PRIMARY KEY,
stars INTEGER NOT NULL
);
""")
yield conn
conn.close()
def test_inserts_repo(db) -> None:
db.execute("INSERT INTO repos (name, stars) VALUES (?, ?)", ("py/cpy", 60000))
assert db.execute("SELECT stars FROM repos").fetchone()[0] == 60000
Когда SQLite не годится (специфичные для PostgreSQL JSONB, оконные функции, расширения) — берите testcontainers:
# tests/conftest.py — фикстура с реальным временным PostgreSQL
import pytest
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="session")
def postgres():
with PostgresContainer("postgres:17-alpine") as pg:
yield pg.get_connection_url()
scope="session" — поднимаем контейнер один раз за весь прогон, иначе медленно.
А если хочу замокать psycopg-курсор
Бывает оправдано: например, проверяете обработку DB-ошибок, а не сам SQL. Тогда MagicMock:
from unittest.mock import MagicMock
import psycopg
def test_retries_on_serialization_failure() -> None:
cursor = MagicMock()
# первый вызов — ошибка сериализации, второй — успех
cursor.execute.side_effect = [
psycopg.errors.SerializationFailure("conflict"),
None,
]
cursor.fetchone.return_value = (42,)
conn = MagicMock()
conn.cursor.return_value.__enter__.return_value = cursor
result = insert_with_retry(conn, "value")
assert result == 42
assert cursor.execute.call_count == 2
Anti-pattern: мокать всё подряд
Главный грех начинающего — замокать вообще всё, что код делает. Получается тест, который проверяет, что mock возвращает то, что ему сказали возвращать. Никакого реального покрытия:
# плохой тест
def test_sync_repo() -> None:
with patch("my_etl.sync.parse_response") as mock_parse:
mock_parse.return_value = {"stars": 1}
with patch("my_etl.sync.insert_repo") as mock_insert:
with patch("my_etl.sync.fetch_repo") as mock_fetch:
mock_fetch.return_value = {"raw": "data"}
result = sync_repo("py/cpy")
assert result == 1 # это просто прокинули mock_parse через функцию
Этот тест ничего не проверил: вся логика замокана. Если функция реально не работает — тест зелёный.
Правило: мокайте только внешний мир — сеть, БД, файлы вне tmp_path, текущее время. Собственный код проекта не мокайте. Если функция А вызывает функцию Б из этого же модуля, и Б сложная — выделите Б в отдельный класс/модуль и протестируйте её отдельным тестом.
Эвристика: если вы патчите больше двух вещей в одном тесте — скорее всего, тестируете слишком много за раз. Разбейте на два теста или вытащите часть логики в отдельную функцию, которую можно тестировать чисто.
DE-кейс: тест ETL-функции
Соберём всё вместе. Функция тянет данные с GitHub API и сохраняет в БД:
# src/my_etl/sync.py
import httpx
import sqlite3
from datetime import datetime, timezone
def sync_repo(client: httpx.Client, db: sqlite3.Connection, full_name: str) -> int:
"""Загружает stars-count для репозитория и пишет в БД.
Возвращает количество звёзд. Бросает HTTPStatusError при 4xx/5xx.
"""
response = client.get(f"/repos/{full_name}")
response.raise_for_status()
data = response.json()
db.execute(
"INSERT OR REPLACE INTO repos (name, stars, synced_at) VALUES (?, ?, ?)",
(
data["full_name"],
data["stargazers_count"],
datetime.now(timezone.utc).isoformat(),
),
)
db.commit()
return data["stargazers_count"]
Тесты:
# tests/test_sync.py
import httpx
import pytest
import sqlite3
from my_etl.sync import sync_repo
def _handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if path == "/repos/python/cpython":
return httpx.Response(200, json={
"full_name": "python/cpython",
"stargazers_count": 60123,
})
if path == "/repos/missing/repo":
return httpx.Response(404, json={"message": "Not Found"})
return httpx.Response(500, json={"message": "boom"})
@pytest.fixture
def client() -> httpx.Client:
return httpx.Client(
transport=httpx.MockTransport(_handler),
base_url="https://api.github.com",
)
@pytest.fixture
def db():
conn = sqlite3.connect(":memory:")
conn.execute("""
CREATE TABLE repos (
name TEXT PRIMARY KEY,
stars INTEGER NOT NULL,
synced_at TEXT NOT NULL
)
""")
yield conn
conn.close()
class TestSyncRepo:
def test_happy_path(self, client, db) -> None:
stars = sync_repo(client, db, "python/cpython")
assert stars == 60123
row = db.execute("SELECT name, stars FROM repos").fetchone()
assert row == ("python/cpython", 60123)
def test_not_found_raises(self, client, db) -> None:
with pytest.raises(httpx.HTTPStatusError):
sync_repo(client, db, "missing/repo")
# БД не должна содержать запись
assert db.execute("SELECT COUNT(*) FROM repos").fetchone()[0] == 0
def test_server_error_raises(self, client, db) -> None:
with pytest.raises(httpx.HTTPStatusError):
sync_repo(client, db, "server/error")
Что в итоге:
- HTTP мокается через
MockTransport(никакой сети). - БД — реальная in-memory SQLite (видим, что INSERT работает, что транзакция коммитится).
- Время мокать не пришлось, потому что мы не проверяем его конкретное значение. Если бы проверяли —
monkeypatch.setattrили фикстураfreezegun. - 3 теста, каждый — один сценарий: happy / 404 / 500.
Это и есть рабочий стиль unit-тестирования ETL. Быстрые, детерминированные, проверяющие логику, а не библиотеку httpx.
Что мы получили
- Мокаем внешний мир: сеть, файлы (вне tmp), время. Собственный код не мокаем.
monkeypatch— лёгкий, идеален для env vars и атрибутов модулей. Auto-cleanup.unittest.mock(Mock,MagicMock,patch,side_effect) — мощнее, но требует осторожности с именованием.- Для HTTP —
httpx.MockTransport: чище и стабильнее, чемpatch. - БД лучше не мокать: in-memory SQLite или testcontainers. Если мокаете курсор — только для тестов специфичных DB-ошибок.
- Anti-pattern: мокать всё подряд. Тогда тест ничего не проверяет.
В следующем уроке — как прогонять эти тесты автоматически на каждый коммит через GitHub Actions, и как измерять coverage.