Testing logic
Зачем и как писать тесты
Рассмотрим тестирование: REST API, использующего реляционную СУБД и возвращающего ответы в формате JSON.
Это один из наиболее распространенных подходов к интеграционному тестированию, который эмулирует ручную проверку API методов. Такой подход позволяет покрыть значительную часть бизнес-логики тестами, поэтому автоматизация тестирования API – хороший старт. Эти тесты не только воспроизводят ручное тестирование, но и позволяют проверять побочные эффекты в базе данных, такие как создание новых записей после выполнения POST-запросов.
Реализовать такие тесты в Django достаточно просто благодаря встроенным инструментам, однако в FastAPI эта задача требует большего внимания. Поэтому я разработал компоненты, которые позволяют создавать интеграционные тесты API на FastAPI так же удобно и быстро, как и в Django.
Предварительная настройка для тестирования
- Установка
poetry add pytest pytest-asyncio httpx
- Создать файл
app/pytest.ini
[pytest]
; Доп аргументы к запуску
addopts = -v -l -x -s --lf --disable-warnings
; Маска для поиска файлов с тестами
python_files = tests.py test_*.py *_tests.py
; Позволяет выводить логи (logs) в консоль при запуске тестов.
log_cli = true
- Создать файл
app/conftest.py
from app.core.config import TEST_DATABASE_URL
from fastapi_accelerator.db.dbsession import MainDatabaseManager
# Вы можете указать точный список импорта, это для простоты мы импортируем все
from fastapi_accelerator.testutils import * # noqa E402
# Нужно создать менеджер БД до импорта APP
# чтобы паттерн одиночка создал только тестовое instance
# и в APP уже взялся тестовый instance
TestDatabaseManager = MainDatabaseManager(
TEST_DATABASE_URL, echo=False, DEV_STATUS=True
)
from main import app # noqa E402
# Отключить кеширование во время тестов
app.state.CACHE_STATUS = False
SettingTest(TestDatabaseManager, app, alembic_migrate=True, keepdb=True) # noqa F405
Основные компоненты для тестирования
Для упрощения написания тестов, их стандартизацию, вы можете использовать следующие компоненты:
-
Фикстуры:
client- Клиент для выполнения тестовых API запросовtest_app- Тестовое FastAPI приложениеurl_path_for- Получить полный URL путь, по имени функции обработчикаengine- Синхронный двигательaengine- Асинхронный двигательdb_session- Соединение с тестовой БДdb_manager- Менеджер с тестовой БД
-
Функции:
check_response_json- Функция, которая объединяет основные проверки для API ответаrm_key_from_deep_dict- Функция для отчистить не нужные ключи у API ответа
-
Классы:
BasePytest- Базовый класс для тестирования через классыBaseAuthJwtPytest- Добавление аутентификации по JWT(@client_auth_jwt) дляBasePytest
-
Контекстный менеджер:
track_queries- Перехват выполняемых SQL команд, во время контекста, для последующего анализ - например подсчёта количества.
-
Декораторы:
@apply_fixture_db(ФункцияВозвращающаяФикстуры)- Декоратор, который добавляет фикстуры в БД перед тестом и удаляет их после теста.@client_auth_jwt()- Декоратор который аутентифицирует тестового клиента по JWT.@patch_integration(ПравилаПодмены)- Декоратор который подменяет методы интеграций на Mock функции.
Подробнее про компоненты для тестирования
Fixture - client
Основная фикстура для выполнения тестовых API запросов.
Порядок работы фикстуры client:
-
Этапы на уровней всей тестовой сессии:
- (before) Создастся тестовая БД если её нет;
-
(before) В зависимости от настройки
SettingTest.alembic_migrate;- Если
True-> Создаст таблицы через миграцииalembic - Если
False-> Создаст таблицы черезcreate_all()
- Если
-
(after) После завершение всех тестов, в зависимости от настройки
SettingTest.keepdb;- Если
True-> Ничего - Если
False-> Удаляться все таблицы из тестовой БД
- Если
-
Этапы на уровней каждого тестового функции/метода:
- В тестовую функцию/метода попадает аргумент
client: TestClient; - (after) После выхода из тестовой функции/метода, все данных во всех таблицах отчищаются(кроме таблицы
alembic_version, так как саму БД мы не удаляем);
- В тестовую функцию/метода попадает аргумент
from fastapi.testclient import TestClient
def test_имя(client: TestClient):
response = client.get('url')
Decorator - @client_auth_jwt
На практике нам часто приходиться тестировать API методы которые требуют аутентификацию. Делать обход аутентификации в тестах плохой вариант, так как можно упустить некоторые исключения, или логику API метода которая завязана на данных аутентифицированного пользователя. Поэтому чтобы аутентифицировать тестового клиента, укажите декоратор @client_auth_jwt для тестовой функции/метода
- Пример использование декоратора для тестовой функции:
from fastapi.testclient import TestClient
from fastapi_accelerator.testutils.fixture_auth import client_auth_jwt
@client_auth_jwt(username='test')
def test_имя(client: TestClient):
print(client.headers['authorization']) # 'Bearer ...'
- Пример использование декоратора для тестового метода в классе
BasePytest:
from fastapi.testclient import TestClient
from fastapi_accelerator.testutils.fixture_base import BasePytest
from fastapi_accelerator.testutils.fixture_auth import client_auth_jwt
class TestИмяКласса(BasePytest):
@client_auth_jwt()
def test_имя(self, client: TestClient):
print(client.headers['authorization']) # 'Bearer ...'
Если вы используете декоратор
@client_auth_jwtв классеBasePytest, то он возьметusernameизself.TEST_USER['username'], этот атрибут уже определен вBasePytestи равен по умолчаниюtest.
Decorator - @apply_fixture_db
Идея взята из тестирования Django, в котором можно указать в атрибуте fixtures список файлов с фикстурами, которые будут загружены для тестов, и удалены после окончания. Этот очень удобно для переиспользовать тестовых данных.
Но я решил модифицировать этот вариант и сделать фикстуры не в виде JSON а виде объектов SqlAlchemy. Использование JSON лучше когда нужно переносить эти данные на другие платформы, но такое встречается редко, чаще всего фикстуры для backend тестов используются только на backend, и горазда удобнее и быстрее писать в формате объектов БД, чем в формате JSON. Поэтому выбран формат объектов.
Порядок работы декоратора @apply_fixture_db:
- Получает записи из переданной функции
export_func; - Создает записи в БД;
- Выполняется тестовая функция. Если она ожидает аргумент
fixtures, то в него передадутся записи изexport_func; - Удаляет записи из БД:
- Если вы используете фикстуру
client, то она автоматически отчистить все данные в таблицах, после выполнения тестовой функции. - Если вы не используете фикстуру
client, то для отчистки данных укажите в декоратор аргументflush=True
- Если вы используете фикстуру
- Оформление файлами с тестовыми данными
app.fixture.items_v1.py:
from fastapi_accelerator.utils import to_namedtuple
from app.models.timemeasurement import Task, TaskExecution, TaskUser
def export_fixture_task():
# Создание пользователей и задач
user1 = TaskUser(id=0, name="Alice")
user2 = TaskUser(id=1, name="Bob")
task1 = Task(id=9, name="Admins")
task2 = Task(id=8, name="Users")
# Связывание пользователей с задачами
user1.tasks.append(task1)
user2.tasks.append(task1)
user2.tasks.append(task2)
# Вернуть именований картеж
return to_namedtuple(
user1=user1,
user2=user2,
task1=task1,
task2=task2,
task_execution1=TaskExecution(
id=91,
task=task1,
start_time="2024-09-06T10:55:43",
end_time="2024-09-06T10:59:43",
),
)
- Использование декоратора в тестовых функциях:
from fastapi_accelerator.test_utils import apply_fixture_db
from app.fixture.items_v1 import export_fixture_task
@apply_fixture_db(export_fixture_task)
def test_имя(client: TestClient):
response = client.get('url')
- Использование декоратора в тестовых методах, в этом варианте вы можно указывать только для
setUp, тогда он будет применен для всех тестовых методов:
from fastapi.testclient import TestClient
from fastapi_accelerator.testutils.fixture_base import BasePytest
from fastapi_accelerator.test_utils import apply_fixture_db
from app.fixture.items_v1 import export_fixture_task
class TestИмяКласса(BasePytest):
@apply_fixture_db(export_fixture_task)
def setUp(self, fixtures: NamedTuple):
self.fixtures = fixtures
def test_имя(self, client: TestClient):
response = client.get('url')
print(self.fixtures)
Decorator - @patch_integration
Тестирование интеграций с внешними API
Самым сложным аспектом тестирования являются интеграции с внешними API, поскольку во время тестов необходимо избегать выполнения реальных запросов к этим API. Поэтому нам приходится самостоятельно разрабатывать логику для имитации работы внешнего API. Хотя наша имитация может не полностью отражать реальную работу API, это все же лучше, чем игнорировать интеграцию.
В командах часто каждый разработчик создает свои собственные моки для интеграций, что приводит к путанице и отсутствию единого стандарта. Существует высокая вероятность ошибок, когда мок может не сработать, и произойдет отправка запроса в реальный API.
Для решения этой проблемы мы используем классы интеграции EndpointsDeclaration с декоратором @integration.endpoint, что позволяет создать единую точку входа, которую можно легко заменить во время тестирования и исключить возможность выполнения реального метода интеграции.
Пример тестирования метода FastAPI, который вызывает метод интеграции:
- Обработчик FastAPI:
@router.get("/translate")
async def translate_api(
text: str, from_lang: str = "en", to_lang: str = "ru"
) -> GoogleTranslateEndpoints.Schema.TranslateV2:
# Вызвать метод интеграции
return await gtapi.translate(text, from_lang, to_lang)
test_имя.pyпример интеграции сgoogleпереводчик:
from fastapi_accelerator.testutils.fixture_integration import patch_integration
from app.integration.google_translate.mock import google_translate_mock_rules
# Правила подмены методов интеграции на mock.
# Если в коде вызывается интеграция, которая не указана в mock_rules, возникает исключение.
# Это предотвращает случайные реальные запросы, если вы забыли указать mock.
@patch_integration(mock_rules=google_translate_mock_rules)
def test_integration_google_translate(client: TestClient, url_path_for: Callable):
# Выполнение тестового запроса
response = client.get(
url_path_for("translate_api"),
params=dict(text="Hello", from_lang="en", to_lang="ru"),
)
# Проверка ответа
assert response.json() == {"text": "Привет"}
Значение для
mock_rulesможно использовать откуда угодно, но рекомендую хранить и брать изapp/integration/ПакетИнтеграции/mock.py
- Рекомендуется хранить подменные функции в одном пакете с интеграцией в
app/integration/ПакетИнтеграции/mock.py, чтобы при импорте этого пакета в другой проект также можно было использовать функции изmock.py, не создавая свои имитации.
from app.integration.google_translate.endpoint import GoogleTranslateEndpoints
from fastapi_accelerator.integration.http_integration import ApiHTTP
from fastapi_accelerator.testutils.fixture_integration import MockRules
async def overwrite_translate(api: ApiHTTP, *args, **kwargs):
# Удобный вариант имитации, когда через match аргументов, возвращаем определенный ответ.
match args:
case ("hello", "en", "ru"):
return {"text": "Привет"}
return None
# Правила замены методов интеграции на mock
google_translate_mock_rules = MockRules(
# Реальный метод интеграции: замена на mock функцию
{GoogleTranslateEndpoints.translate: overwrite_translate}
)
К мок-функциям применяются те же требования к формату ответа, что и к реальному методу интеграции.
Context manager - track_queries
Идея взята из тестирования Django метода self.assertNumQueries, который поваляет проверять количество выполненных SQL команд в контексте. Это очень полезно когда используется ORM, который может из за неаккуратного использования, генерировать сотни SQL команд. Поэтому лучше у каждого вызова тестового API метода отлеживать количество выполненных SQL команд.
- Пример использования контекстного менеджера
track_queries:
from fastapi_accelerator.testutils.fixture_db.trace_sql import track_queries
def test_имя(client: TestClient, db_manager: MainDatabaseManager):
with track_queries(db_manager, expected_count=3):
response = client.get('url')
- Вы можете получить полный список выполненных SQL команд из
tracker.queries:
from fastapi_accelerator.testutils.fixture_db.trace_sql import track_queries
def test_имя(client: TestClient, db_manager: MainDatabaseManager):
with track_queries(db_manager) as tracker:
response = client.get('url')
# Если количество измениться, то выведется список всех выполненных SQL команд
assert tracker.count == 3, tracker.queries
Func - check_response_json
По опыту написания тестов, могу выделить несколько основных проверок для ответов API JSON.
- Проверить статус ответа
- Получить ответ в формате JSON
- Если нужно, то удалить динамические ключи из ответа, например дату создания, дату обновления, первичный ключ новой записи. Работает через функцию
rm_key_from_deep_dict - Сравнить ответ с ожидаемым
Эти проверки выполняются в функции check_response_json
Пример использования:
def test_имя(client: TestClient):
response = client.post('url', json={...})
check_response_json(
response,
200,
{
"page": 1,
"size": 10,
"count": 1,
"items": [
{
"end_time": "2024-09-06T10:59:43",
"start_time": "2024-09-06T10:55:43",
"task": {
"description": None,
"name": "Admins",
},
}
],
},
exclude_list=['id','task_id']
)
Тестирование через классы
Класс BasePytest
Удобнее и понятнее, создавать логически связанные тесты в одном классе, и указывать в методе setUp общую для них инициализацию, например общий url, или создание тестовых объектов в БД, или создание переменных хранящих ожидаемый JSON ответ.
- Пример создания класса для тестирования на основание
BasePytest:
from fastapi.testclient import TestClient
from fastapi_accelerator.testutils.fixture_base import BasePytest
class TestИмяКласса(BasePytest):
def setUp(self):
# Метод для выполнения необходимой настройки перед каждым тестом.
...
def test_имя(self, client: TestClient):
...
- Вы можете использовать фикстуры и декораторы для тестовых методах, например аутентификаций по JWT:
from fastapi.testclient import TestClient
from fastapi_accelerator.testutils.fixture_base import BasePytest
from fastapi_accelerator.testutils.fixture_auth import client_auth_jwt
class TestИмяКласса(BasePytest):
def setUp(self):
# Метод для выполнения необходимой настройки перед каждым тестом.
...
@client_auth_jwt()
def test_имя(self, client: TestClient):
print(client.headers['authorization']) # 'Bearer ...'
...
Класс BaseAuthJwtPytest
Чтобы не писать для каждого тестового метода в классе, декоратор @client_auth_jwt вы можете наследоваться от BaseAuthJwtPytest, в котором эта логика уже реализована.
- Пример создания класса для тестирования на основание
BaseAuthJwtPytest:
from fastapi.testclient import TestClient
from fastapi_accelerator.testutils.fixture_base import BaseAuthJwtPytest
class TestИмяКласса(BaseAuthJwtPytest):
def setUp(self):
# Метод для выполнения необходимой настройки перед каждым тестом.
...
def test_имя(self, client: TestClient):
print(client.headers['authorization']) # 'Bearer ...'
...
Примеры тестов
Классическая тестовой функции
Проверки REST API метода, который использует РСУБД, и возвращает ответ в формате JSON:
from typing import Callable, NamedTuple
from fastapi.testclient import TestClient
from app.fixture.items_v1 import export_fixture_file
from fastapi_accelerator.db.dbsession import MainDatabaseManager
from fastapi_accelerator.testutils import apply_fixture_db, client_auth_jwt, track_queries, check_response_json
# Аутентифицировать тестового клиента
@client_auth_jwt(username="test")
# Создать тестовые данных из функции с фикстурами
@apply_fixture_db(export_fixture_file)
def test_имя(
client: TestClient, # Тестовый клиент для API запросов
url_path_for: Callable, # Функция для получения url по имени функции обработчика
db_manager: MainDatabaseManager, # Менеджер тестовой БД
fixtures: NamedTuple, # Хранит созданные данные из фикстур
):
# Проверка количество выполняемых SQL команд
with track_queries(db_manager, expected_count=3):
# Запрос в API
response = client.get(url_path_for("ИмяФункции"))
# Проверка JSON API ответа
check_response_json(
response,
200,
{
"id": fixtures.Имя.id,
},
)
# TODO Можно для методов POST, UPDATE, DELETE добавить проверку на изменения записей в БД.
...
Классический тестовый класс
Проверки REST API метода, который использует РСУБД, и возвращает ответ в формате JSON:
from typing import Callable, NamedTuple
from fastapi.testclient import TestClient
from app.fixture.items_v1 import export_fixture_file
from fastapi_accelerator.db.dbsession import MainDatabaseManager
from fastapi_accelerator.testutils import apply_fixture_db
from fastapi_accelerator.testutils.fixture_auth import client_auth_jwt
from fastapi_accelerator.testutils.fixture_db.trace_sql import track_queries
from fastapi_accelerator.testutils.utils import BaseAuthJwtPytest, check_response_json
BASE_URL_V1 = "/api/v1/"
class TestИмя(BaseAuthJwtPytest):
# Создать тестовые данных из функции с фикстурами
@apply_fixture_db(export_fixture_file)
def setUp(self, fixtures: NamedTuple):
self.url = BASE_URL_V1 + "taskexecution"
self.fixtures = fixtures # Хранит созданные данные из фикстур
def test_имя(self, client: TestClient, db_manager: MainDatabaseManager):
# Проверка количество выполняемых SQL команд
with track_queries(db_manager, expected_count=3):
# Запрос в API
response = client.get(self.url)
# Проверка JSON API ответа
check_response_json(
response,
200,
{
"id": self.fixtures.Имя.id,
},
)
# TODO Можно для методов POST, UPDATE, DELETE добавить проверку на изменения записей в БД.
...