Тестирование

Тестирование #

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 БД