Тестирование #
1. Что такое фикстуры в тестировании и какую задачу они решают при подготовке окружения и данных для тестов? #
Фикстура — это заранее подготовленное, известное состояние среды тестирования (данные, объекты, настройки), которое необходимо для корректного выполнения тестов и гарантирует их повторяемость. Фикстуры позволяют избежать дублирования кода настройки и очистки, делая тесты чище и проще для чтения. Для их создания используется специальный синтаксис в тестовых фреймворках, например, декоратор @pytest.fixture в Python.
Фикстуры (fixtures) в pytest - это функции, которые предоставляют тестовые данные, устанавливают предварительные условия и выполняют очистку после тестов.
Фикстуры - это способ инжекции зависимостей в тесты. Они выполняются до (и после) тестовых функций и предоставляют им необходимые ресурсы.
Создание фикстуры
import pytest
@pytest.fixture
def database_connection():
# Setup - выполняется ДО теста
connection = connect_to_database()
print("Setting up database connection")
yield connection # передача контроля тесту
# Teardown - выполняется ПОСЛЕ теста
connection.close()
print("Closing database connection")
def test_database_operations(database_connection):
result = database_connection.query("SELECT * FROM users")
assert len(result) > 0
Какую задачу они решают #
Фикстуры решают проблему повторяющейся подготовки перед тестами.
Без фикстур:
def test_user_can_login():
user = User.objects.create_user(
username="test",
password="password"
)
client = APIClient()
response = client.post("/login/", {
"username": "test",
"password": "password"
})
assert response.status_code == 200
Если таких тестов 20, то создание пользователя, клиента, токена, начальных данных будет повторяться в каждом тесте.
С фикстурой:
@pytest.fixture
def user():
return User.objects.create_user(
username="test",
password="password"
)
@pytest.fixture
def api_client():
return APIClient()
def test_user_can_login(api_client, user):
response = api_client.post("/login/", {
"username": "test",
"password": "password"
})
assert response.status_code == 200
Тест становится чище: он проверяет саму бизнес-логику, а не смешивает её с подготовкой данных.
Что делает фикстура перед тестом #
Фикстура обычно выполняет три задачи:
1. Подготавливает окружение
2. Передаёт готовый объект в тест
3. После теста очищает ресурсы, если это нужно
Например:
@pytest.fixture
def temp_file():
file = open("test.txt", "w")
file.write("data")
file.close()
yield "test.txt"
os.remove("test.txt")
Здесь:
до yield → подготовка
yield → передача значения в тест
после yield → очистка
pytest позволяет тестовой функции запрашивать фикстуру просто через аргумент функции; pytest сам найдёт и выполнит нужную фикстуру перед запуском теста.
Пример с базой данных #
Допустим, нужно протестировать получение уведомлений пользователя.
Без фикстуры тест может быть перегружен:
def test_get_notifications():
user = User.objects.create(username="alex")
Notification.objects.create(user=user, text="Hello")
response = client.get(f"/users/{user.id}/notifications/")
assert response.status_code == 200
assert len(response.data) == 1
С фикстурами:
@pytest.fixture
def user():
return User.objects.create(username="alex")
@pytest.fixture
def notification(user):
return Notification.objects.create(
user=user,
text="Hello"
)
def test_get_notifications(client, user, notification):
response = client.get(f"/users/{user.id}/notifications/")
assert response.status_code == 200
assert len(response.data) == 1
Здесь фикстуры формируют тестовое состояние:
user → создаёт пользователя
notification → создаёт уведомление для этого пользователя
client → даёт тестовый HTTP-клиент
Фикстуры в Django #
В Django слово fixture часто означает файл с заранее сериализованными данными для базы. Например JSON-файл, который можно загрузить в тестовую БД. В официальной документации Django фикстура описывается как набор файлов с сериализованным содержимым базы данных.
Пример условной Django fixture:
[
{
"model": "accounts.user",
"pk": 1,
"fields": {
"username": "test_user",
"email": "test@example.com"
}
}
]
Такой файл можно загрузить перед тестами, чтобы база уже содержала нужные записи.
Главная польза фикстур #
Фикстуры нужны, чтобы тесты были:
повторяемыми → каждый запуск получает одинаковые данные
чистыми → тест не забит подготовительным кодом
изолированными → один тест меньше влияет на другой
удобными → общая подготовка переиспользуется
надёжными → меньше случайных ошибок из-за разного состояния окружения
Коротко #
Фикстура — это механизм подготовки тестового окружения и данных.
Она отвечает не за саму проверку, а за состояние, в котором эта проверка выполняется.
Фикстура отвечает на вопрос:
"Что нужно подготовить, чтобы тест мог нормально выполниться?"
А сам тест отвечает на вопрос:
"Правильно ли работает проверяемая логика?"
2. Для каких задач применяется unittest.mock? #
Для чего применяется unittest.mock
#
unittest.mock применяется для подмены реальных объектов в тестах: функций, методов, классов, внешних сервисов, файловой системы, API-клиентов, времени, переменных окружения и других зависимостей.
Главная задача — проверить логику своего кода изолированно, не вызывая реальные внешние зависимости. В официальной документации unittest.mock описан как библиотека для создания mock-объектов и временной подмены частей системы во время теста.
Основные задачи unittest.mock
#
1. Подмена внешних зависимостей #
Например, код отправляет запрос во внешний API:
def get_user_name(api_client, user_id):
user = api_client.get_user(user_id)
return user["name"]
В тесте не нужно реально ходить во внешний сервис. Можно подменить api_client:
from unittest.mock import Mock
def test_get_user_name():
api_client = Mock()
api_client.get_user.return_value = {"name": "Alex"}
result = get_user_name(api_client, 1)
assert result == "Alex"
Здесь тест проверяет только функцию get_user_name, а не работу настоящего API.
2. Проверка вызовов #
mock позволяет проверить, был ли метод вызван, сколько раз и с какими аргументами. Это полезно, когда результат функции — не только возвращаемое значение, но и действие.
from unittest.mock import Mock
def send_notification(sender, user_id, text):
sender.send(user_id=user_id, message=text)
def test_send_notification():
sender = Mock()
send_notification(sender, 10, "Hello")
sender.send.assert_called_once_with(
user_id=10,
message="Hello"
)
То есть тест проверяет:
метод send был вызван
вызван ровно один раз
получил правильные аргументы
В документации Python отдельно показано, что mock-объекты умеют записывать вызовы и позволяют делать assertions по тому, как они были использованы.
3. Подмена функций через patch
#
patch() временно заменяет объект на mock только на время теста. После теста оригинальный объект возвращается обратно.
Например, есть функция:
# service.py
import requests
def get_status():
response = requests.get("https://example.com/status")
return response.status_code
Тест:
from unittest.mock import patch
from service import get_status
@patch("service.requests.get")
def test_get_status(mock_get):
mock_get.return_value.status_code = 200
result = get_status()
assert result == 200
mock_get.assert_called_once_with("https://example.com/status")
Здесь реальный HTTP-запрос не выполняется. requests.get временно заменён mock-объектом.
patch() в unittest.mock используется именно для временной подмены объектов в нужном модуле; по умолчанию он создаёт MagicMock.
4. Симуляция разных сценариев #
Mock можно настроить так, чтобы он возвращал разные значения или выбрасывал исключения.
from unittest.mock import Mock
def test_api_error():
api_client = Mock()
api_client.get_user.side_effect = ConnectionError("API unavailable")
try:
api_client.get_user(1)
except ConnectionError as error:
assert str(error) == "API unavailable"
Это нужно, чтобы проверять ошибки:
API недоступен
БД вернула ошибку
файл не найден
Redis не отвечает
токен истёк
платёжный сервис отклонил запрос
Без mock такие ситуации сложно стабильно воспроизводить в тестах.
5. Изоляция unit-тестов #
Unit-тест должен проверять маленький участок логики.
Например:
плохо:
тест сервиса регистрации реально пишет в БД,
отправляет email,
ходит в Redis,
создаёт JWT,
обращается к внешнему API
лучше:
БД можно заменить тестовой БД,
email-сервис — mock-объектом,
Redis — mock-объектом,
внешний API — mock-объектом
Так тест становится:
быстрее
стабильнее
изолированнее
проще для отладки
6. Тестирование кода с побочными эффектами #
Побочные эффекты — это действия вне самой функции:
отправка email
запись файла
HTTP-запрос
запись в лог
публикация сообщения в очередь
обращение к S3/MinIO
обращение к Redis
Например:
def register_user(user_data, email_sender):
user = create_user(user_data)
email_sender.send_welcome_email(user.email)
return user
Тест:
from unittest.mock import Mock
def test_register_user_sends_email():
email_sender = Mock()
user = register_user(
{"email": "test@example.com"},
email_sender
)
email_sender.send_welcome_email.assert_called_once_with(
"test@example.com"
)
Тест не отправляет настоящий email, но проверяет, что код попытался его отправить.
7. Подмена классов #
Иногда нужно подменить целый класс, который создаётся внутри функции.
# service.py
from payments import PaymentClient
def pay(order_id):
client = PaymentClient()
return client.charge(order_id)
Тест:
from unittest.mock import patch
from service import pay
@patch("service.PaymentClient")
def test_pay(mock_payment_client):
instance = mock_payment_client.return_value
instance.charge.return_value = "success"
result = pay(100)
assert result == "success"
instance.charge.assert_called_once_with(100)
Здесь PaymentClient() не создаёт реальный клиент. Вместо него используется mock.
8. Подмена переменных окружения #
Через patch.dict можно временно заменить os.environ.
import os
from unittest.mock import patch
def get_debug_mode():
return os.environ.get("DEBUG") == "1"
def test_get_debug_mode():
with patch.dict(os.environ, {"DEBUG": "1"}):
assert get_debug_mode() is True
После выхода из with окружение возвращается в прежнее состояние.
Коротко #
unittest.mock нужен для того, чтобы:
подменять зависимости
не вызывать реальные внешние сервисы
проверять вызовы методов
задавать фейковые return_value
симулировать ошибки через side_effect
изолировать unit-тесты
делать тесты быстрее и стабильнее
Главная идея:
mock не тестирует настоящую зависимость,
mock помогает проверить, как ваш код с этой зависимостью взаимодействует.
Например:
не проверяем, реально ли email ушёл
проверяем, что наш код вызвал email_sender.send(...) с правильными аргументами
3. Что такое mock-объекты (моки) в тестировании? #
Что такое mock-объекты #
Mock-объект, или мок, — это поддельный объект, который в тесте заменяет реальную зависимость.
Он имитирует поведение настоящего объекта, но при этом не выполняет настоящую работу.
Например, вместо реального:
HTTP-запроса
записи в базу данных
отправки email
обращения к Redis
загрузки файла в S3/MinIO
вызова платёжного сервиса
в тесте используется mock-объект.
Официальный модуль Python unittest.mock предназначен именно для замены частей тестируемой системы mock-объектами и проверки того, как эти mock-объекты были использованы.
Простая идея #
Допустим, есть функция:
def notify_user(email_sender, email):
email_sender.send(email)
В реальном коде email_sender.send() может отправлять настоящее письмо.
В тесте это делать не нужно. Поэтому вместо реального email_sender передают mock:
from unittest.mock import Mock
def test_notify_user():
email_sender = Mock()
notify_user(email_sender, "test@example.com")
email_sender.send.assert_called_once_with("test@example.com")
Здесь тест проверяет не отправку письма, а то, что наш код правильно вызвал зависимость.
Что умеет mock #
Mock-объект обычно нужен для трёх задач.
1. Возвращать заранее заданный результат #
from unittest.mock import Mock
api_client = Mock()
api_client.get_user.return_value = {
"id": 1,
"name": "Alex"
}
result = api_client.get_user(1)
assert result == {
"id": 1,
"name": "Alex"
}
То есть вместо настоящего API мы заранее задаём ответ.
2. Проверять вызовы #
Mock запоминает, какие методы у него вызывали, сколько раз и с какими аргументами. В документации Python это указано как один из типичных сценариев использования mock-объектов: запись вызовов и последующая проверка этих вызовов.
sender = Mock()
sender.send("Hello")
sender.send.assert_called_once_with("Hello")
Так можно проверить:
метод был вызван
метод был вызван один раз
метод получил правильные аргументы
3. Симулировать ошибку #
from unittest.mock import Mock
api_client = Mock()
api_client.get_user.side_effect = ConnectionError("API unavailable")
Теперь при вызове:
api_client.get_user(1)
будет выброшено исключение ConnectionError.
Это полезно, когда нужно проверить обработку ошибок:
сервис недоступен
БД не отвечает
Redis упал
файл не найден
токен невалиден
внешний API вернул ошибку
Чем mock отличается от реального объекта #
Реальный объект выполняет настоящую работу:
EmailSender.send() реально отправляет письмо
S3Client.upload() реально загружает файл
PaymentClient.charge() реально списывает деньги
requests.get() реально делает HTTP-запрос
Mock-объект только имитирует это поведение:
ничего реально не отправляет
ничего реально не загружает
никуда реально не обращается
но позволяет проверить взаимодействие
Пример с внешним API #
Код:
# service.py
import requests
def get_status():
response = requests.get("https://example.com/status")
return response.status_code
Тест:
from unittest.mock import patch
from service import get_status
@patch("service.requests.get")
def test_get_status(mock_get):
mock_get.return_value.status_code = 200
result = get_status()
assert result == 200
mock_get.assert_called_once_with("https://example.com/status")
Здесь requests.get временно заменяется mock-объектом. В Python для такой временной подмены используется patch(), который заменяет объект на время теста и затем возвращает оригинал обратно.
Где mock особенно полезен #
Mock-объекты применяются, когда тестируемый код зависит от чего-то внешнего:
HTTP API
база данных
Redis
RabbitMQ / Kafka
email-сервис
файловая система
S3 / MinIO
текущее время
переменные окружения
сторонние SDK
Например, в backend-проекте mock часто используют для подмены:
email_sender
payment_client
s3_client
redis_client
external_api_client
jwt_service
notification_service
Что именно проверяет тест с mock #
Тест с mock обычно проверяет не реальную зависимость, а поведение вашего кода относительно этой зависимости.
Например:
не проверяем, что письмо реально ушло
проверяем, что код вызвал send_email() с правильным email
не проверяем, что S3 реально сохранил файл
проверяем, что upload_file() был вызван с правильным bucket/key
не проверяем, что платёжный сервис реально списал деньги
проверяем, что charge() вызван с правильной суммой
Главное отличие mock от обычной фикстуры #
Фикстура подготавливает окружение или данные:
создать пользователя
подготовить тестовую БД
создать тестовый файл
выдать авторизованный client
Mock заменяет зависимость:
заменить внешний API
заменить email-сервис
заменить Redis
заменить S3-клиент
То есть:
fixture → готовит состояние
mock → имитирует зависимость
Коротко #
Mock-объект — это управляемая подделка реального объекта в тесте.
Он нужен, чтобы:
изолировать тестируемый код
не обращаться к реальным внешним сервисам
задавать нужные return_value
симулировать ошибки через side_effect
проверять вызовы методов и аргументы
делать unit-тесты быстрыми и стабильными
Главная идея:
mock не проверяет реальный сервис;
mock проверяет, как ваш код с этим сервисом взаимодействует.
4. Как проверить test coverage в Python? | Чем вы измеряли покрытие кода тестами? #
Что такое test coverage #
test coverage — это показатель, какая часть кода была выполнена во время запуска тестов.
Например:
TOTAL coverage: 82%
Это значит, что тесты во время выполнения прошли примерно по 82% измеряемых строк кода.
Важно: высокий coverage не гарантирует, что тесты хорошие. Он показывает только, какие строки были выполнены, а не насколько корректно проверена логика.
Основной инструмент: coverage.py
#
Обычно используют пакет coverage.
Установка:
pip install coverage
Запуск тестов под coverage:
coverage run -m pytest
После этого посмотреть отчёт:
coverage report -m
Ключ -m показывает строки, которые не были покрыты тестами.
Пример вывода:
Name Stmts Miss Cover Missing
--------------------------------------------------
app/services.py 50 8 84% 12-15, 31, 44-46
app/repositories.py 70 10 86% 20-25, 61-64
--------------------------------------------------
TOTAL 120 18 85%
coverage.py официально используется для измерения покрытия Python-кода: он отслеживает, какие части программы были выполнены, а затем показывает, какие могли быть выполнены, но не были.
HTML-отчёт #
Для удобного просмотра можно сгенерировать HTML:
coverage html
После этого появится папка:
htmlcov/
Открывать нужно файл:
htmlcov/index.html
В HTML-отчёте удобно смотреть, какие конкретно строки не были покрыты тестами. В документации coverage.py отдельно указаны команды coverage report -m и coverage html для текстового и HTML-отчёта.
Вариант для pytest: pytest-cov
#
На практике чаще используют плагин pytest-cov.
Установка:
pip install pytest-cov
Запуск:
pytest --cov=app
Где app — это папка или пакет, покрытие которого нужно проверить.
Например:
pytest --cov=src
или:
pytest --cov=users
Показать непокрытые строки #
Самая полезная команда:
pytest --cov=app --cov-report=term-missing
Она покажет не только общий процент, но и строки, которые тестами не были пройдены.
Пример:
Name Stmts Miss Cover Missing
--------------------------------------------------
app/auth.py 40 5 88% 18-20, 35-36
app/services.py 80 20 75% 10-15, 44-58
--------------------------------------------------
TOTAL 120 25 79%
В документации pytest-cov указано, что можно генерировать разные типы отчётов: terminal, terminal with missing lines, HTML, XML, JSON, Markdown, LCOV и другие.
HTML через pytest-cov #
pytest --cov=app --cov-report=html
После запуска появится HTML-отчёт.
Можно указать папку явно:
pytest --cov=app --cov-report=html:coverage_html
Документация pytest-cov показывает, что путь для HTML-отчёта можно задать через формат --cov-report html:cov_html.
Coverage с минимальным порогом #
Можно сделать так, чтобы тесты падали, если coverage ниже нужного процента.
Например, минимум 80%:
pytest --cov=app --cov-report=term-missing --cov-fail-under=80
Это удобно для CI/CD:
coverage >= 80% → pipeline проходит
coverage < 80% → pipeline падает
Пример для FastAPI / Django проекта #
Допустим структура такая:
project/
├── app/
│ ├── main.py
│ ├── services.py
│ └── repositories.py
└── tests/
├── test_services.py
└── test_api.py
Тогда команда:
pytest --cov=app --cov-report=term-missing
Проверит покрытие кода внутри app.
Для Django-проекта:
pytest --cov=accounts --cov=storage --cov-report=term-missing
Или несколько приложений сразу:
pytest --cov=. --cov-report=term-missing
Но --cov=. может захватить лишнее, поэтому обычно лучше явно указывать нужные приложения.
Что обычно смотрят в отчёте #
Stmts → сколько строк считается исполняемыми
Miss → сколько строк не было выполнено тестами
Cover → процент покрытия
Missing → номера непокрытых строк
Пример:
app/services.py 100 15 85% 22-28, 45, 70-76
Это значит:
100 исполняемых строк
15 строк не покрыто
85% coverage
строки 22-28, 45, 70-76 не были выполнены тестами
Коротко #
Для обычного проекта на pytest:
pip install pytest-cov
pytest --cov=app --cov-report=term-missing
Для HTML-отчёта:
pytest --cov=app --cov-report=html
Для проверки минимального порога:
pytest --cov=app --cov-report=term-missing --cov-fail-under=80
Главная идея:
coverage показывает не "качество тестов",
а то, какие участки кода реально выполнялись во время тестирования.
5. Как лучше организовать unit-тесты в Python-проекте? #
Общий принцип #
Unit-тесты лучше организовывать так, чтобы они проверяли маленькие изолированные части кода: функцию, метод, сервис, валидатор, репозиторий, use-case. Внешние зависимости лучше подменять фикстурами или mock-объектами.
Для Python-проектов чаще всего используют pytest, потому что он позволяет писать короткие тесты, удобно работать с фикстурами и хорошо масштабируется на большие проекты. В документации pytest отдельно подчёркивается, что фреймворк подходит и для маленьких тестов, и для сложных функциональных наборов тестов.
Рекомендуемая структура #
Обычно делают отдельную папку tests/ рядом с кодом проекта:
project/
├── app/
│ ├── services/
│ │ └── user_service.py
│ ├── repositories/
│ │ └── user_repository.py
│ └── utils/
│ └── tokens.py
│
├── tests/
│ ├── unit/
│ │ ├── services/
│ │ │ └── test_user_service.py
│ │ ├── repositories/
│ │ │ └── test_user_repository.py
│ │ └── utils/
│ │ └── test_tokens.py
│ │
│ ├── integration/
│ │ └── test_user_api.py
│ │
│ └── conftest.py
│
├── pytest.ini
└── pyproject.toml
Разделение полезное:
tests/unit/ → быстрые изолированные тесты
tests/integration/ → тесты с БД, API, Redis, S3, внешними компонентами
conftest.py → общие фикстуры pytest
pytest умеет автоматически находить тесты по соглашениям об именовании файлов и функций, а также позволяет настраивать правила discovery через конфигурацию.
Именование тестов #
Лучше придерживаться понятной схемы:
test_<что_тестируем>.py
test_<ожидаемое_поведение>
Пример:
def test_create_user_success():
...
def test_create_user_raises_error_when_email_exists():
...
def test_decode_token_returns_payload():
...
Плохой вариант:
def test_1():
...
def test_user():
...
def test_func():
...
Название теста должно объяснять, какое поведение проверяется.
Один тест — одна проверяемая идея #
Хороший unit-тест проверяет один сценарий.
Например:
def test_calculate_discount_for_regular_user():
result = calculate_discount(user_type="regular", amount=1000)
assert result == 50
Отдельно тестируется другой сценарий:
def test_calculate_discount_for_vip_user():
result = calculate_discount(user_type="vip", amount=1000)
assert result == 150
Не стоит в один тест складывать много разных случаев:
def test_calculate_discount():
assert calculate_discount("regular", 1000) == 50
assert calculate_discount("vip", 1000) == 150
assert calculate_discount("guest", 1000) == 0
Такой тест сложнее читать и отлаживать. Если он упадёт, нужно дополнительно разбираться, какой именно сценарий сломался.
Использовать Arrange / Act / Assert #
Удобная структура теста:
Arrange → подготовка данных
Act → выполнение действия
Assert → проверка результата
Пример:
def test_user_can_be_activated():
# Arrange
user = User(is_active=False)
# Act
user.activate()
# Assert
assert user.is_active is True
Так тест читается последовательно:
что подготовили
что вызвали
что ожидаем получить
Фикстуры выносить в conftest.py
#
Если фикстура нужна во многих тестах, её можно вынести в conftest.py.
# tests/conftest.py
import pytest
@pytest.fixture
def user_data():
return {
"username": "test_user",
"email": "test@example.com",
"password": "strong-password"
}
Потом использовать в тестах:
def test_create_user(user_data):
user = create_user(user_data)
assert user.email == "test@example.com"
Фикстуры pytest дают тестам заранее подготовленный и стабильный контекст: например, данные, окружение или настроенные ресурсы.
Не перегружать фикстуры #
Плохая фикстура:
@pytest.fixture
def full_app_state():
user = create_user()
token = create_token(user)
order = create_order(user)
payment = create_payment(order)
notification = create_notification(user)
return user, token, order, payment, notification
Проблема: тесты начинают зависеть от лишнего состояния.
Лучше разделить:
@pytest.fixture
def user():
return create_user()
@pytest.fixture
def token(user):
return create_token(user)
@pytest.fixture
def order(user):
return create_order(user)
Тогда каждый тест берёт только то, что ему реально нужно:
def test_order_belongs_to_user(user, order):
assert order.user_id == user.id
Внешние зависимости подменять mock-объектами #
Unit-тест не должен реально ходить во внешний API, Redis, S3, email-сервис или платёжный сервис.
Пример:
from unittest.mock import Mock
def test_send_welcome_email():
email_sender = Mock()
register_user(
email="test@example.com",
email_sender=email_sender
)
email_sender.send.assert_called_once_with("test@example.com")
Здесь тест не отправляет настоящее письмо. Он проверяет, что код правильно вызвал зависимость.
В Python для этого обычно используют unittest.mock. Официальная документация описывает Mock и patch() как инструменты для создания mock-объектов и временной подмены объектов во время теста.
Не тестировать реализацию вместо поведения #
Плохой тест:
def test_create_user_calls_hash_password():
password_hasher = Mock()
create_user("test@example.com", "123456", password_hasher)
password_hasher.hash.assert_called_once()
Такой тест слишком привязан к внутренней реализации.
Лучше проверять наблюдаемое поведение:
def test_create_user_saves_hashed_password():
user = create_user("test@example.com", "123456")
assert user.password != "123456"
assert user.check_password("123456") is True
Исключение: когда смысл функции именно во взаимодействии с зависимостью. Например, отправка email, публикация события в очередь, вызов S3-клиента.
Разделять unit и integration тесты #
Unit-тест:
быстрый
изолированный
без реальной БД
без настоящего HTTP-запроса
без настоящего Redis/S3
проверяет маленький участок логики
Integration-тест:
может использовать тестовую БД
может поднимать API-клиент
может проверять связку нескольких компонентов
медленнее, но ближе к реальному поведению системы
Пример разделения:
tests/unit/test_password_service.py
tests/unit/test_token_service.py
tests/unit/test_user_validator.py
tests/integration/test_auth_api.py
tests/integration/test_user_repository.py
tests/integration/test_notifications_api.py
Конфигурация pytest #
Минимальный pytest.ini:
[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
addopts = -ra
Можно добавить markers:
[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
addopts = -ra
markers =
unit: isolated unit tests
integration: tests with external dependencies
Тогда тесты можно помечать:
import pytest
@pytest.mark.unit
def test_calculate_discount():
assert calculate_discount(1000) == 50
Запуск только unit-тестов:
pytest -m unit
Запуск только integration-тестов:
pytest -m integration
Покрытие тестами #
Для контроля покрытия удобно использовать pytest-cov:
pytest --cov=app --cov-report=term-missing
Для минимального порога:
pytest --cov=app --cov-report=term-missing --cov-fail-under=80
coverage.py измеряет, какие части Python-кода были выполнены во время запуска тестов, а pytest-cov добавляет интеграцию coverage с pytest.
Практическое правило для backend-проекта #
Для FastAPI/Django/DRF проекта хорошая схема такая:
tests/
├── unit/
│ ├── test_services.py
│ ├── test_validators.py
│ ├── test_permissions.py
│ └── test_utils.py
│
├── integration/
│ ├── test_auth_api.py
│ ├── test_user_repository.py
│ └── test_file_upload_api.py
│
└── conftest.py
Что тестировать unit-тестами:
валидацию данных
сервисный слой
расчёты
парсинг
работу с токенами
права доступа
обработку ошибок
маппинг DTO/schema/entity
Что лучше оставить integration-тестам:
реальные запросы к тестовой БД
работу ORM-запросов
API endpoint целиком
миграции
авторизацию через middleware/dependencies
связку API + DB + serializer/schema
Коротко #
Хорошая организация unit-тестов в Python:
tests/ отдельно от app/
unit и integration разделены
имена тестов описывают поведение
один тест проверяет один сценарий
общая подготовка вынесена в фикстуры
внешние зависимости заменяются mock-объектами
тесты не зависят от порядка запуска
coverage проверяется отдельно
Минимальная рабочая схема:
project/
├── app/
├── tests/
│ ├── unit/
│ ├── integration/
│ └── conftest.py
└── pytest.ini
Главная идея:
unit-тест должен быстро и изолированно отвечать на вопрос:
"Правильно ли работает конкретный маленький участок логики?"
6. Что такое и зачем нужен parametrize в pytest? #
Что такое parametrize в pytest
#
parametrize — это механизм pytest, который позволяет запустить один и тот же тест несколько раз с разными входными данными.
Используется декоратор:
@pytest.mark.parametrize
Официальная документация pytest описывает pytest.mark.parametrize как встроенный декоратор для параметризации аргументов тестовой функции.
Зачем он нужен #
Он нужен, чтобы не писать много одинаковых тестов вручную.
Допустим, есть функция:
def is_even(number: int) -> bool:
return number % 2 == 0
Без parametrize:
def test_is_even_with_2():
assert is_even(2) is True
def test_is_even_with_3():
assert is_even(3) is False
def test_is_even_with_10():
assert is_even(10) is True
Работает, но много дублирования.
С parametrize:
import pytest
@pytest.mark.parametrize(
"number, expected",
[
(2, True),
(3, False),
(10, True),
]
)
def test_is_even(number, expected):
assert is_even(number) is expected
pytest запустит этот тест 3 раза:
test_is_even[2-True]
test_is_even[3-False]
test_is_even[10-True]
То есть один тест описывает одну логику, но проверяет несколько наборов данных.
Как это работает #
Строка:
@pytest.mark.parametrize("number, expected", [...])
говорит pytest:
number → первый аргумент теста
expected → второй аргумент теста
А список кортежей задаёт значения:
[
(2, True),
(3, False),
(10, True),
]
Значит pytest выполнит:
test_is_even(number=2, expected=True)
test_is_even(number=3, expected=False)
test_is_even(number=10, expected=True)
Пример с валидацией #
Допустим, есть функция проверки username:
def is_valid_username(username: str) -> bool:
return len(username) >= 3 and username.isalnum()
Тест:
import pytest
@pytest.mark.parametrize(
"username, expected",
[
("alex", True),
("ab", False),
("john123", True),
("bad-name", False),
("", False),
]
)
def test_is_valid_username(username, expected):
assert is_valid_username(username) is expected
Плюс в том, что сразу видно таблицу тестовых случаев:
"alex" → валидный username
"ab" → слишком короткий
"john123" → валидный username
"bad-name" → содержит дефис
"" → пустая строка
Пример с ожидаемым исключением #
parametrize удобен и для проверки ошибок.
import pytest
def divide(a, b):
return a / b
@pytest.mark.parametrize(
"a, b, expected",
[
(10, 2, 5),
(9, 3, 3),
(5, 1, 5),
]
)
def test_divide_success(a, b, expected):
assert divide(a, b) == expected
Отдельно лучше проверять ошибочный сценарий:
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
divide(10, 0)
Можно параметризовать и ошибки, но для читаемости успешные и ошибочные сценарии часто разделяют.
ids для понятных названий тестов
#
По умолчанию pytest сам генерирует имена параметризованных кейсов. Иногда они неудобные.
Можно задать ids:
@pytest.mark.parametrize(
"username, expected",
[
("alex", True),
("ab", False),
("bad-name", False),
],
ids=[
"valid_username",
"too_short",
"contains_invalid_symbol",
]
)
def test_is_valid_username(username, expected):
assert is_valid_username(username) is expected
Тогда в выводе pytest будет понятнее, какой именно кейс упал:
test_is_valid_username[valid_username]
test_is_valid_username[too_short]
test_is_valid_username[contains_invalid_symbol]
Когда стоит использовать parametrize
#
parametrize хорошо подходит, когда:
одна и та же логика проверяется на разных входных данных
есть много похожих тестов
нужно проверить граничные значения
нужно проверить несколько вариантов результата
нужно сделать тесты компактнее и понятнее
Примеры:
валидация email
валидация пароля
проверка ролей пользователя
расчёт скидки
парсинг строк
проверка статусов
проверка сериализаторов
проверка прав доступа
Когда лучше не использовать #
Не стоит использовать parametrize, если сценарии сильно отличаются друг от друга.
Плохо:
@pytest.mark.parametrize(
"data, expected",
[
(...), # регистрация пользователя
(...), # логин пользователя
(...), # удаление пользователя
]
)
def test_user_flow(data, expected):
...
Здесь разные бизнес-сценарии смешаны в один тест. Лучше сделать отдельные тесты:
def test_register_user_success():
...
def test_login_user_success():
...
def test_delete_user_success():
...
Коротко #
parametrize в pytest нужен для запуска одного теста с разными наборами данных.
Главная польза:
меньше дублирования
чище тесты
удобнее проверять много кейсов
проще добавлять новые входные данные
лучше видно таблицу сценариев
Минимальный пример:
import pytest
@pytest.mark.parametrize(
"value, expected",
[
(1, 2),
(2, 4),
(3, 6),
]
)
def test_double(value, expected):
assert value * 2 == expected
Идея:
одна тестовая логика
несколько наборов входных данных
несколько отдельных запусков теста
7. На каком этапе разработки писать unit-тесты? #
Unit-тесты лучше писать не в самом конце разработки, а параллельно с реализацией логики.
Оптимальный момент:
1. Понял требование
2. Спроектировал поведение функции/метода/сервиса
3. Написал или наметил unit-тесты
4. Реализовал код
5. Запустил тесты
6. Отрефакторил код, не ломая тесты
То есть unit-тесты должны появляться рядом с кодом, который они проверяют.
Лучший практический подход #
Есть два нормальных варианта.
1. До реализации кода #
Это подход TDD: сначала пишется тест, потом код.
Схема:
сначала тест → тест падает → пишем код → тест проходит → рефакторинг
Пример:
def test_calculate_discount_for_vip_user():
result = calculate_discount(user_type="vip", amount=1000)
assert result == 150
Потом пишется функция:
def calculate_discount(user_type: str, amount: int) -> int:
if user_type == "vip":
return 150
return 0
Плюс такого подхода: тест помогает заранее описать ожидаемое поведение.
Минус: не всегда удобно, если требования ещё сырые или интерфейс функции постоянно меняется.
2. Сразу после написания небольшой части логики #
На практике это самый частый и удобный вариант.
Например, написал функцию валидации — сразу написал тесты:
написал validate_email()
сразу написал test_validate_email_success()
сразу написал test_validate_email_invalid_format()
сразу написал test_validate_email_empty_string()
Так код ещё свежий в голове, и проще проверить граничные случаи.
Когда точно не стоит писать unit-тесты #
Плохой вариант — писать unit-тесты только в конце задачи.
сначала написал весь feature
потом начал думать, как это тестировать
Проблемы:
код может оказаться плохо разделённым на части
сложно изолировать бизнес-логику
много зависимостей уже смешано вместе
тесты превращаются в тяжёлые integration-тесты
приходится переписывать код ради тестируемости
Unit-тесты хорошо работают, когда код изначально написан так, чтобы его можно было вызвать отдельно: без реальной БД, HTTP-запросов, Redis, S3 и других внешних зависимостей.
На каком этапе задачи #
Хорошая схема для backend-разработки:
1. Анализ требования
→ понять, какие сценарии нужно проверить
2. Проектирование интерфейса
→ какие функции, сервисы, методы будут отвечать за логику
3. Реализация маленького блока
→ например, валидатор, сервис, permission, use-case
4. Unit-тесты на этот блок
→ success case, error case, edge cases
5. Integration/API-тесты
→ проверить связку endpoint + service + DB
6. Рефакторинг
→ тесты должны остаться зелёными
Что тестировать сразу #
Unit-тесты стоит писать сразу для логики, где есть условия, ветвления и ошибки:
валидация данных
расчёты
проверка прав доступа
обработка токенов
сервисный слой
парсинг данных
маппинг DTO/schema/entity
бизнес-правила
обработка исключений
Пример:
def can_delete_file(user, file):
return file.owner_id == user.id or user.is_admin
Такую функцию удобно тестировать unit-тестами сразу:
def test_owner_can_delete_file():
user = User(id=1, is_admin=False)
file = File(owner_id=1)
assert can_delete_file(user, file) is True
def test_non_owner_cannot_delete_file():
user = User(id=2, is_admin=False)
file = File(owner_id=1)
assert can_delete_file(user, file) is False
def test_admin_can_delete_file():
user = User(id=2, is_admin=True)
file = File(owner_id=1)
assert can_delete_file(user, file) is True
Что не обязательно тестировать unit-тестами сразу #
Не всё нужно покрывать именно unit-тестами.
Например:
тонкие CRUD-view без логики
простые ORM-модели без кастомного поведения
обычные настройки роутинга
код, который только вызывает framework
Для такого часто полезнее integration/API-тесты.
Например, в Django/DRF или FastAPI endpoint может быть слишком тонким:
@router.get("/users/{user_id}")
def get_user(user_id: int):
return user_service.get_user(user_id)
Здесь сам endpoint почти не содержит логики. Unit-тест лучше писать на user_service, а endpoint проверить integration-тестом.
Как понять, что тест нужен сейчас #
Unit-тест нужен сразу, если на вопрос есть ответ «да»:
есть несколько сценариев поведения?
есть if/else?
есть обработка ошибок?
есть важное бизнес-правило?
есть риск сломать это при рефакторинге?
есть граничные значения?
есть зависимость, которую нужно замокать?
Пример:
def calculate_upload_path(user_id: int, filename: str) -> str:
return f"users/{user_id}/{filename}"
Для такой функции можно быстро написать unit-тест, потому что она содержит важное правило формирования пути:
def test_calculate_upload_path():
result = calculate_upload_path(10, "avatar.png")
assert result == "users/10/avatar.png"
Роль фикстур и mock #
Когда unit-тест пишется во время разработки, сразу видно, какие зависимости мешают тестированию.
Например, плохой вариант:
def register_user(data):
user = User.objects.create(**data)
send_email(user.email)
redis.set(f"user:{user.id}", "created")
return user
Здесь смешаны:
создание пользователя
email
Redis
побочные эффекты
Лучше вынести зависимости:
def register_user(data, user_repo, email_sender, cache):
user = user_repo.create(data)
email_sender.send(user.email)
cache.set(f"user:{user.id}", "created")
return user
Теперь unit-тест можно написать через mock:
from unittest.mock import Mock
def test_register_user_sends_email_and_updates_cache():
user_repo = Mock()
email_sender = Mock()
cache = Mock()
user_repo.create.return_value = User(id=1, email="test@example.com")
user = register_user(
{"email": "test@example.com"},
user_repo,
email_sender,
cache
)
assert user.email == "test@example.com"
email_sender.send.assert_called_once_with("test@example.com")
cache.set.assert_called_once_with("user:1", "created")
То есть unit-тесты помогают не только проверять код, но и проектировать его более тестируемым.
pytest-фикстуры дают тестам стабильный и воспроизводимый контекст, например подготовленные данные или окружение. Это полезно, когда одни и те же объекты нужны во многих тестах.
Итоговая рекомендация #
Писать unit-тесты лучше:
до кода — если поведение понятно заранее
сразу после маленького блока кода — если удобнее идти практично
до рефакторинга — обязательно, чтобы зафиксировать текущее поведение
после найденного бага — обязательно, чтобы баг не вернулся
Не стоит ждать конца всей задачи.
Коротко #
Правильный ответ для собеседования:
Unit-тесты лучше писать на раннем этапе разработки: либо до реализации в стиле TDD, либо сразу после написания небольшого участка логики.
Их не стоит откладывать до конца, потому что тесты помогают быстрее находить ошибки, фиксировать ожидаемое поведение, безопасно делать рефакторинг и проектировать код так, чтобы его можно было изолированно проверять.
Главная идея:
unit-тесты должны сопровождать разработку,
а не быть финальной формальностью после готового feature.
8. Какие проблемы могут возникнуть из-за отсутствия тестов? #
Отсутствие тестов приводит к тому, что качество кода проверяется в основном вручную и случайно. Из-за этого ошибки чаще находят поздно: на ревью, в QA, на staging или уже в production.
Главная проблема:
без тестов нет автоматической проверки, что старое поведение не сломалось после изменений
1. Регрессии после изменений #
Регрессия — это ситуация, когда раньше код работал правильно, но после нового изменения сломался.
Например:
def calculate_discount(user_type, amount):
if user_type == "vip":
return amount * 0.15
return 0
Потом разработчик добавил новый тип пользователя и случайно сломал старую логику:
def calculate_discount(user_type, amount):
if user_type == "premium":
return amount * 0.10
return 0
Без теста на vip это можно не заметить.
С тестом:
def test_calculate_discount_for_vip_user():
assert calculate_discount("vip", 1000) == 150
ошибка обнаружится сразу при запуске тестов.
2. Страшно делать рефакторинг #
Если тестов нет, любое изменение внутренней структуры кода становится рискованным.
Например, нужно разнести большой сервис на несколько функций:
было:
register_user()
стало:
validate_user_data()
create_user()
send_welcome_email()
save_user_to_cache()
Без тестов сложно понять, сохранилось ли поведение. Поэтому разработчик часто начинает бояться менять код и оставляет технический долг.
С тестами рефакторинг безопаснее: тесты фиксируют ожидаемое поведение и быстро показывают, что сломалось. pytest как раз ориентирован на написание маленьких читаемых тестов, которые затем можно масштабировать для сложных приложений.
3. Больше ручной проверки #
Без автоматических тестов каждое изменение приходится проверять руками:
запустить сервер
создать пользователя
получить токен
отправить запрос
проверить БД
проверить ответ API
проверить ошибочные сценарии
Это долго и ненадёжно. Человек может забыть проверить редкий сценарий:
пустое поле
невалидный токен
чужой объект
дублирующий email
недоступный внешний сервис
ошибку Redis/S3/API
4. Ошибки находятся поздно #
Чем позже найдена ошибка, тем дороже её исправлять.
Без тестов проблема часто всплывает уже после объединения кода:
локально всё выглядело нормально
на ревью ошибку не заметили
на staging сломался сценарий
в production пользователь получил ошибку
Тесты уменьшают этот риск, потому что автоматическая проверка запускается раньше: локально, в pre-commit, в CI/CD pipeline.
5. Сложнее понять ожидаемое поведение #
Хороший тест — это ещё и документация поведения.
Например:
def test_user_cannot_delete_another_user_file():
...
Из названия уже понятно бизнес-правило:
пользователь не может удалить чужой файл
Если тестов нет, поведение приходится искать в коде, ТЗ, комментариях или спрашивать у других разработчиков.
6. Выше риск сломать edge cases #
Edge cases — это граничные и редкие случаи.
Например:
пустой список
None вместо строки
0 вместо положительного числа
очень длинное имя файла
повторная регистрация email
истёкший JWT
удаление уже удалённого объекта
загрузка файла с таким же именем
Без тестов такие случаи часто проверяются только «по памяти». В итоге основная ветка работает, а редкие сценарии ломаются.
7. CI/CD становится менее полезным #
CI/CD без тестов проверяет в основном только то, что проект собирается и линтеры проходят.
Но он не отвечает на главный вопрос:
работает ли логика приложения после изменения?
С тестами pipeline становится полноценным защитным слоем:
код собрался
линтер прошёл
unit-тесты прошли
integration-тесты прошли
coverage не упал ниже порога
Coverage-инструменты, например coverage.py, показывают, какие части Python-кода были выполнены во время запуска тестов. Это помогает видеть, какие участки вообще не проверяются автоматически.
8. Сложнее ловить ошибки в интеграциях #
В backend-проекте без тестов особенно часто ломаются связки:
endpoint → serializer/schema → service → repository → database
endpoint → dependency/middleware → auth → permissions
service → Redis
service → S3/MinIO
service → external API
Unit-тесты помогают проверить отдельную бизнес-логику, а integration-тесты — связку компонентов. Google Testing Blog отдельно указывает, что хороший набор тестов обычно строится как пирамида: больше unit-тестов, меньше крупных end-to-end тестов.
9. Код хуже проектируется #
Когда тестов нет, код легко становится труднотестируемым:
def register_user(data):
user = User.objects.create(**data)
requests.post("https://email-service/send", json={"email": user.email})
redis_client.set(f"user:{user.id}", "created")
return user
Здесь внутри функции сразу смешаны:
бизнес-логика
БД
HTTP-запрос
Redis
побочные эффекты
Такой код сложно изолированно проверить.
Когда тесты пишутся параллельно с кодом, разработчик чаще выносит зависимости наружу:
def register_user(data, user_repo, email_sender, cache):
user = user_repo.create(data)
email_sender.send(user.email)
cache.set(f"user:{user.id}", "created")
return user
Такой код проще тестировать, мокать и сопровождать.
10. Больше багов после исправления багов #
Частая ситуация:
нашли баг
исправили баг
через месяц баг вернулся
Если после исправления бага не добавить тест, который воспроизводит этот сценарий, нет защиты от повторного появления той же ошибки.
Правильная практика:
1. Воспроизвести баг тестом
2. Убедиться, что тест падает
3. Исправить код
4. Убедиться, что тест проходит
5. Оставить тест в проекте
Что особенно опасно оставлять без тестов #
В первую очередь нужно покрывать:
бизнес-правила
деньги, скидки, лимиты
авторизацию и права доступа
валидацию входных данных
работу с токенами
обработку ошибок
сложные условия if/else
код, который уже ломался раньше
интеграции с БД, Redis, S3, внешними API
Не весь код требует одинакового уровня покрытия. Простые тонкие view, которые только вызывают сервис, можно проверять integration/API-тестами. Но бизнес-логику лучше покрывать unit-тестами.
Коротко для собеседования #
Из-за отсутствия тестов повышается риск регрессий, усложняется рефакторинг, появляется зависимость от ручной проверки, ошибки находятся поздно, CI/CD хуже защищает проект, а поведение системы становится менее зафиксированным.
Тесты нужны не только для поиска багов, но и как страховка при изменениях кода.
Главная мысль:
без тестов разработчик не знает наверняка,
сломал он старое поведение или нет
9. Какую БД лучше использовать для тестов? #
Для тестов лучше использовать такую же СУБД, как в production.
Если в production PostgreSQL, то для серьёзных тестов лучше использовать PostgreSQL, а не SQLite.
production PostgreSQL → tests PostgreSQL
production MySQL → tests MySQL
production SQLite → tests SQLite
Главная причина: разные БД могут по-разному работать с типами данных, транзакциями, индексами, ограничениями, SQL-функциями и поведением ORM. Даже Django прямо указывает, что поддерживаемые backend’ы БД не одинаковы, и не все их особенности совпадают.
Для unit-тестов #
Для чистых unit-тестов БД обычно вообще не нужна.
Unit-тест должен проверять маленький участок логики изолированно:
валидатор
сервис
расчёт
permission
парсер
работу с токеном
бизнес-правило
Например:
def can_delete_file(user, file):
return file.owner_id == user.id or user.is_admin
Такой код можно тестировать без базы:
def test_owner_can_delete_file():
user = User(id=1, is_admin=False)
file = File(owner_id=1)
assert can_delete_file(user, file) is True
Для unit-тестов лучше использовать:
обычные объекты
dataclass / pydantic-модели
mock-объекты
fake repository
in-memory структуры
БД подключать только там, где реально тестируется работа с ORM/SQL.
Для integration-тестов #
Для integration-тестов лучше использовать реальную тестовую БД того же типа, что и production.
Например, если проект на Django/FastAPI + PostgreSQL:
tests → отдельная PostgreSQL database
Потому что integration-тесты проверяют связку:
endpoint → service → repository → ORM → database
Здесь важно, чтобы поведение было максимально близко к production.
Почему не всегда стоит использовать SQLite #
SQLite часто используют для простоты, потому что она быстрая и не требует отдельного сервера. Но это не всегда безопасная замена PostgreSQL/MySQL.
Проблемы могут быть такие:
другое поведение типов данных
отличия в транзакциях
отличия в блокировках
отличия в autoincrement
отличия в JSON/ARRAY/UUID полях
отличия в индексах
отличия в constraint-проверках
отличия в SQL-функциях
Например, в SQLAlchemy документации отдельно описаны особенности SQLite: свои правила AUTOINCREMENT, особенности DATE/TIME/DATETIME, отдельное поведение in-memory БД и pooling.
Поэтому SQLite допустима, если:
проект реально использует SQLite
тесты проверяют простую ORM-логику
нет PostgreSQL-specific возможностей
нужны очень быстрые локальные тесты
Но если production на PostgreSQL, а тесты на SQLite, можно получить ложную уверенность:
тесты проходят
а на PostgreSQL код падает
Лучший вариант для backend-проекта #
Для твоего стека вроде Django/FastAPI + PostgreSQL оптимальный вариант такой:
unit-тесты:
БД не использовать, зависимости мокать
integration-тесты:
использовать отдельную PostgreSQL test database
CI/CD:
поднимать PostgreSQL сервисом в Docker
Пример логики разделения:
tests/unit/
→ без БД
→ быстро
→ mock/fake объекты
tests/integration/
→ PostgreSQL
→ ORM-запросы
→ API endpoint
→ транзакции
→ миграции/схемы
Django #
В Django тестовый runner сам создаёт отдельную тестовую базу данных. Обычно она создаётся на основе настроек DATABASES, но с именем вида test_<имя_основной_базы>. Django также выполняет тесты так, чтобы изолировать их друг от друга. В django.test.TestCase каждый тест выполняется внутри транзакции для изоляции. (
Django Project)
Если используется pytest-django, доступ к БД по умолчанию запрещён. Его нужно явно включать через @pytest.mark.django_db. Это сделано специально, чтобы было видно, какие тесты действительно зависят от базы.
Пример:
import pytest
@pytest.mark.django_db
def test_user_created():
user = User.objects.create(username="alex")
assert user.username == "alex"
Для ускорения можно использовать:
pytest --reuse-db
pytest-django позволяет переиспользовать тестовую БД между запусками, что ускоряет старт тестов, но при изменениях схемы её нужно пересоздавать.
FastAPI / SQLAlchemy #
Для FastAPI + SQLAlchemy лучше разделять:
unit-тесты сервисов:
mock repository / fake repository
integration-тесты repository/API:
тестовый PostgreSQL
Пример идеи:
def test_create_user_service():
user_repo = Mock()
user_repo.create.return_value = User(id=1, email="test@example.com")
result = create_user_service(
email="test@example.com",
user_repo=user_repo
)
assert result.id == 1
user_repo.create.assert_called_once()
А repository уже проверять на настоящей тестовой БД:
UserRepository.create()
UserRepository.get_by_id()
NotificationRepository.list_by_user()
Testcontainers / Docker #
Хороший вариант для integration-тестов — поднимать БД в Docker-контейнере.
Например, через testcontainers-python можно поднять PostgreSQL-контейнер прямо для тестов. В официальной документации показан пример с PostgresContainer("postgres:16"), который создаёт PostgreSQL в контейнере и возвращает connection URL для подключения.
Плюсы:
близко к production
чистое окружение
удобно для CI
не нужно вручную держать локальную test-БД
Минусы:
медленнее SQLite
нужен Docker
сложнее настройка
Практическая рекомендация #
Для нормального backend-проекта:
1. Unit-тесты
→ без БД
→ mock/fake зависимости
2. Repository-тесты
→ реальная тестовая PostgreSQL
3. API/integration-тесты
→ реальная тестовая PostgreSQL
4. E2E-тесты
→ окружение максимально близкое к production
Что выбрать #
SQLite in-memory
→ только для простых тестов или если production тоже SQLite
Отдельная PostgreSQL test database
→ лучший вариант, если production PostgreSQL
Docker Compose PostgreSQL
→ хороший вариант для локальной разработки и CI
Testcontainers PostgreSQL
→ хороший вариант для изолированных integration-тестов
Mock/Fake repository
→ лучший вариант для чистых unit-тестов
Итог #
Лучший ответ:
Для unit-тестов БД лучше не использовать вообще: зависимости стоит мокать или заменять fake-репозиториями.
Для integration-тестов лучше использовать такую же БД, как в production. Если production на PostgreSQL, то и тесты лучше запускать на отдельной PostgreSQL test database, а не на SQLite.
Главное правило:
чем ближе тест к бизнес-логике → тем меньше нужна реальная БД
чем ближе тест к ORM/API/integration → тем важнее использовать production-like БД