> Как организовать юнит-тесты в Python? (Python)

Уровень: senior · Роль: backend · Язык: Python · Категория: Технические вопросы

Компании: praktika.ai, Исходный Код

Стек: Python

> Пример ответа

Короткий ответ

Организация юнит-тестов в Python строится вокруг трёх уровней: структура файлов (папка tests/ рядом с пакетом), инструменты (pytest, unittest, mock) и соглашения о нейминге (test_*.py, функции test_*). Для senior-уровня важно не просто писать тесты, а проектировать их: изолировать внешние зависимости через dependency injection и unittest.mock, использовать фикстуры для подготовки окружения, параметризацию для покрытия кейсов. Тесты должны быть быстрыми, детерминированными и независимыми, чтобы их можно было запускать параллельно и в CI.

Подробное объяснение

Юнит-тесты проверяют поведение отдельной единицы кода - функции, класса, метода - в изоляции от внешних систем (БД, сеть, файловая система). В Python стандартный подход - unittest из стандартной библиотеки, но де-факто индустриальный стандарт - pytest с его фикстурами, параметризацией и плагинами.

Ключевые принципы организации:

  • Структура: тесты лежат в отдельной директории tests/, зеркалящей структуру пакета. Например, myapp/services/user_service.py → tests/services/test_user_service.py. Это упрощает навигацию и позволяет запускать точечные тесты.
  • Изоляция: каждый тест создаёт свои данные и не зависит от порядка выполнения. Для этого используются фикстуры с scope="function" (по умолчанию) или scope="module"/"session" для тяжёлых ресурсов.
  • Моки и стабы: внешние вызовы (HTTP, БД, очереди) заменяются на unittest.mock.Mock или pytest-mock. Важно мокать на границе системы - там, где код взаимодействует с внешним миром, а не внутри бизнес-логики.
  • Параметризация: @pytest.mark.parametrize позволяет прогнать один тест с разными входными данными, включая граничные случаи и ошибки.
  • Покрытие: измеряется через pytest-cov, но цель не 100%, а покрытие критичных веток и обработки ошибок.

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

На практике

На практике организация выглядит так:

  1. Выбор фреймворка: pytest - основной, unittest - если проект legacy или нужен встроенный runner.
  2. Конфигурация: файл pytest.ini или pyproject.toml с настройками (testpaths, addopts, markers).
  3. Фикстуры: выносятся в conftest.py на уровне директории, чтобы переиспользовать между модулями. Тяжёлые ресурсы (БД, Redis) - в session-scoped фикстуры с teardown.
  4. Маркеры: @pytest.mark.slow, @pytest.mark.integration для разделения быстрых unit-тестов и медленных интеграционных. В CI запускаются только unit-тесты на каждый коммит, интеграционные - на merge request.
  5. Запуск: pytest tests/ -x -q для быстрой проверки, pytest --cov=myapp --cov-report=term-missing для отчёта о покрытии.

Для изоляции от внешних сервисов часто используется паттерн "порт-адаптер": бизнес-логика зависит от абстракций (интерфейсов), а в тестах подставляются фейковые реализации. Это лучше, чем мокать конкретные библиотеки, потому что тесты не ломаются при смене HTTP-клиента или драйвера БД.

Пример кода

PYTHON
# myapp/services/user_service.py
from dataclasses import dataclass
from typing import Protocol
class UserRepository(Protocol):
def get_by_id(self, user_id: int) -> dict | None: ...
@dataclass
class UserService:
repo: UserRepository
def get_username(self, user_id: int) -> str:
user = self.repo.get_by_id(user_id)
if user is None:
raise ValueError(f"User {user_id} not found")
return user["name"]
# tests/conftest.py
import pytest
from myapp.services.user_service import UserService
class FakeRepo:
def __init__(self, data: dict[int, dict]):
self._data = data
def get_by_id(self, user_id: int) -> dict | None:
return self._data.get(user_id)
@pytest.fixture
def service():
repo = FakeRepo({1: {"name": "Alice"}})
return UserService(repo)
# tests/services/test_user_service.py
import pytest
def test_get_username_existing_user(service):
assert service.get_username(1) == "Alice"
def test_get_username_missing_user(service):
with pytest.raises(ValueError, match="not found"):
service.get_username(42)
@pytest.mark.parametrize("user_id,expected", [(1, "Alice"), (2, "Bob")])
def test_get_username_parametrized(user_id, expected):
repo = FakeRepo({1: {"name": "Alice"}, 2: {"name": "Bob"}})
svc = UserService(repo)
assert svc.get_username(user_id) == expected

Как отвечать на собеседовании

Начни с краткого определения юнит-теста и его цели - проверка изолированного поведения. Затем переходи к структуре: tests/ рядом с пакетом, нейминг test_*. Упомяни pytest как основной инструмент, но покажи, что знаешь unittest и его ограничения (больше boilerplate, нет фикстур из коробки). Подчеркни важность изоляции через dependency injection и моки - это ключевой момент для senior. Расскажи про фикстуры и их scope, параметризацию, маркеры для разделения тестов. Если спросят про покрытие - скажи, что гнаться за 100% не нужно, важнее покрыть критичные ветки и обработку ошибок. Заверши примером из практики: как ты организовывал тесты в реальном проекте, какие проблемы решал (например, параллельный запуск, тесты, которые падали из-за порядка выполнения).

Что проверяет интервьюер

Интервьюер оценивает:

  • Понимание разницы между unit, integration и e2e тестами - и умение объяснить, почему unit-тесты должны быть быстрыми и изолированными.
  • Умение проектировать тестируемый код: dependency injection, порты-адаптеры, отсутствие скрытых глобальных состояний.
  • Практическое знание инструментов: pytest фикстуры, mock, parametrize, conftest.py.
  • Навыки отладки тестов: как найти flaky тест, как изолировать проблему, как использовать --pdb или -x.
  • Понимание CI-процессов: какие тесты запускать на каждый коммит, какие - на merge, как ускорить прогон (параллелизм через pytest-xdist).

Для senior-позиции важно показать, что тесты - это не обязанность, а инструмент проектирования: они влияют на архитектуру кода, заставляют делать код слабо связанным и легко заменяемым.

Типичные ошибки

  • Тесты зависят от порядка выполнения - например, используют общие данные без очистки. Решение: фикстуры с teardown, изоляция данных на каждый тест.
  • Мокают слишком много - мокают внутренние вызовы вместо внешних границ, из-за чего тесты перестают проверять реальное поведение и ломаются при рефакторинге.
  • Тестируют реализацию, а не поведение - проверяют, что метод вызван с определёнными аргументами, вместо проверки результата. Это делает тесты хрупкими.
  • Игнорируют обработку ошибок - тестируют только happy path, а исключения и граничные случаи остаются непокрытыми.
  • Тяжёлые тесты - поднимают БД или делают реальные HTTP-запросы в unit-тестах. Это делает прогон медленным и недетерминированным.
  • Дублирование логики в тестах - например, переписывают алгоритм внутри теста, чтобы "проверить" его. Тест должен проверять спецификацию, а не повторять реализацию.
  • Отсутствие структуры - все тесты в одном файле, нет разделения на модули, нет conftest.py для общих фикстур. Это усложняет поддержку и навигацию.

> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?

Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью