Настройка тестов¶
Как набор тестов получает базу: плагин pytest из hare, фикстура hare_test_context() с повторно
используемыми базами PostgreSQL, init_memory_database() для быстрого скрипта, предупреждение о смене
цикла событий между тестами и пропуск теста, который база не может выполнить. Объекты для тестов создают
фабрики.
Плагин pytest¶
Достаточно pip install hare-orm[pytest] (pytest и pytest-asyncio): pytest сам загружает плагин hare, а
плагин ничего не делает, пока тест не возьмёт одну из его фикстур. Он читает настройки hare, названные в
настройках pytest, — иначе HARE_ORM из окружения или hare_orm из [tool.hare] в pyproject.toml, как
команда hare:
[tool.pytest.ini_options]
hare_config = "library.settings.HARE_ORM"
hare_db_url = "postgresql://postgres@localhost/test_{}"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
Базы из настроек никогда не открываются: каждое соединение получает тестовую базу из hare_db_url — без
неё SQLite в памяти; {} в имени базы заменяется новым именем для каждого соединения и запуска. Базы
создаются с таблицами моделей один раз на сессию и удаляются в её конце (TemporaryDatabases). Фикстуры
и тесты выполняются в цикле событий сессии — отсюда две настройки asyncio_default_*_loop_scope.
| Фикстура | Что получает тест |
|---|---|
hare_db |
Контекст, записи которого откатываются в конце теста (RollbackIsolation). Транзакция, открытая тестом, — точка сохранения, а функции on_commit() не выполняются. |
hare_transactional_db |
Контекст, записи которого действительно фиксируются — функции on_commit() выполняются, — а все таблицы очищаются в конце теста (truncate_all_models()). Для кода, которому нужна настоящая фиксация. |
hare_database |
Контекст сессии, общий для всех тестов, — ничего не откатывается. |
hare_app_db |
hare_db для тестируемого приложения — см. Тесты приложения. |
hare_app_transactional_db |
hare_transactional_db для тестируемого приложения, работающего в своём цикле событий. |
hare_assert_query_count |
assert_query_count(): async with hare_assert_query_count(1): .... |
hare_capture_on_commit |
RollbackIsolation.capture_on_commit() от hare_db теста. |
hare_rollback_isolation |
RollbackIsolation от hare_db теста. |
@pytest.mark.asyncio
async def test_new_reader(hare_db, hare_assert_query_count):
async with hare_assert_query_count(1):
await Reader.objects.create(name="ann")
@pytest.mark.hare_requires(supports_transactions=True)
@pytest.mark.asyncio
async def test_rollback(hare_db): ...
Маркер hare_requires(connection_alias=None, **conditions) пропускает тест, соединение которого не
выполняет условия, — те же, что у requires_features(); такой тест берёт одну из
фикстур hare_app_db, hare_app_transactional_db, hare_db, hare_transactional_db или
hare_database.
| Параметр | Что задаёт |
|---|---|
--hare-db-url URL |
URL, из которого создаются тестовые базы, вместо hare_db_url из настроек. |
--hare-reuse-db |
Брать тестовые базы PostgreSQL из ReusableTestDatabases вместо создания и удаления. |
Без pytest-asyncio тест, взявший фикстуру, падает с сообщением, что установить.
Тесты приложения¶
Приложение, запущенное HareRobyn, HarePlugin (Litestar), HareLifecycle (FastAPI) или HareTaskiq,
при старте открывает базы своей конфигурации. Под hare_app_db или hare_app_transactional_db оно берёт
вместо этого контекст теста — как свой глобальный запасной, который видят запросы без собственного
контекста, — и базы его конфигурации не открываются никогда; остановка приложения возвращает контекст
тесту открытым. Несколько приложений одного теста (веб-приложение и обработчик задач) делят его: он
остаётся их запасным, пока не остановится последнее из них, а приложение, которое тест оставил
работать, возвращает его вместе с тестом. Соединения транзакций приложения и его запросы проверяются
так же, как при настоящем старте.
| Фикстура | Для чего |
|---|---|
hare_app_db |
Записи приложения идут в изолирующую транзакцию теста и откатываются вместе с записями теста — для приложения, чьи запросы выполняются в цикле событий теста (ниже). |
hare_app_transactional_db |
Записи приложения фиксируются, а все таблицы очищаются в конце теста — на любом драйвере, где бы ни работало приложение. Нужна серверная база или файл SQLite — база в памяти живёт в соединении одного цикла событий. |
Клиент httpx с ASGITransport для FastAPI и InMemoryBroker taskiq запускают приложение в цикле событий
теста. AsyncTestClient Litestar и любой TestClient (Robyn, Starlette) — в своём потоке и цикле событий:
драйверы postgresql (rust_pg) и SQLite выполняют транзакцию теста и оттуда, поэтому с ними hare_app_db
работает; драйвер, чьи соединения принадлежат циклу, в котором открыты (postgresql+asyncpg), отказывает
оператору транзакции теста из другого цикла с ConfigurationError — там берите hare_app_transactional_db.
@pytest.mark.asyncio
async def test_create_reader(hare_app_db):
transport = httpx.ASGITransport(app=app) # FastAPI - приложение работает в цикле теста
async with LifespanManager(app), httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post("/readers", json={"name": "ann"})
assert response.status_code == 201
assert await Reader.objects.filter(name="ann").exists() # откатится после теста
Контекст на тест: hare_test_context()¶
def hare_test_context(
modules: list[str],
db_url: str = "sqlite+aiosqlite://:memory:",
app_label: str = "models",
*,
connection_label: str | None = None,
use_timezone: bool = True,
timezone: str = "UTC",
routers: list[str | type] | None = None,
reuse_databases: bool | None = None,
) -> AbstractAsyncContextManager[HareContext]: ...
Рекомендуемый способ подготовить базу для теста: каждый вызов — полностью отдельный HareContext (свои
подключения, свой реестр приложений, своя база), поэтому тесты, идущие параллельно (pytest-xdist),
никогда не мешают друг другу:
import pytest_asyncio
from hare.contrib.test import hare_test_context
@pytest_asyncio.fixture
async def database():
async with hare_test_context(["library.models"]) as context:
yield context
async def test_create_author(database):
author = await Author.objects.create(name="Ursula K. Le Guin")
assert author.id is not None
Для SQLite в файле такая независимость требует либо своего пути на каждый вызов (фикстура tmp_path из
pytest и так своя у каждого рабочего процесса), либо места {} в пути
(db_url="sqlite+aiosqlite://test-{}.sqlite", файл в текущем каталоге), которое при каждом вызове
заполняется случайным UUID. SQLite выполняет запись по очереди даже между разными соединениями к
одному файлу, поэтому два
рабочих процесса xdist, одновременно работающих с одним и тем же постоянным путём, получили бы
database is locked; поэтому при запуске с -n (задан PYTEST_XDIST_WORKER) hare добавляет к
постоянному пути SQLite идентификатор рабочего процесса. Без -n путь используется ровно как задан.
Как и CREATE DATABASE в PostgreSQL, создание базы не принимает уже существующую: существующий файл
SQLite даёт OperationalError раньше, чем его что-либо коснётся, поэтому hare_test_context() (или
Hare.init(_create_db=True)), направленный на настоящий файл базы, никогда не удалит его при выходе.
:memory: это не касается.
База удаляется при выходе, даже если создание таблиц не удалось при входе в блок: модель, команды создания которой база отклоняет, не оставляет после себя базу.
С reuse_databases=True тестовая база PostgreSQL берётся из пула, а не создаётся и удаляется — см.
PostgreSQL: тестирование.
Быстрые скрипты: init_memory_database()¶
from hare import Hare, fields
from hare.contrib.test import init_memory_database
from hare.models import Model
class Book(Model):
id = fields.IntField(primary_key=True)
title = fields.TextField()
@init_memory_database
async def run():
obj = await Book.objects.create(title="Moby Dick")
assert obj.id == 1
if __name__ == "__main__":
Hare.run_async(run())
Поднимает базу SQLite в памяти и создаёт таблицы перед вызовом обёрнутой функции — для разовых скриптов и
примеров, а не для наборов тестов (для них используйте hare_test_context()). Принимает необязательный
models=[...] (по умолчанию ["__main__"]), если модели лежат не в вызывающем модуле. Его адрес
подключения — hare.contrib.test.MEMORY_DATABASE (sqlite+aiosqlite://:memory:).
Смена цикла событий между тестами: HareLoopSwitchWarning¶
Выдаётся, когда пул соединений, созданный в одном цикле событий, используется из другого: hare незаметно
открывает новое соединение для нового цикла, но об этом стоит знать. В тестовом окружении (фикстуры с
областью действия функции, TestClient Starlette и т. п.) это ожидаемо, и hare_test_context() уже сам
это предупреждение подавляет. Если вы работаете не через hare_test_context(), отключите его вручную:
import warnings
from hare.warnings import HareLoopSwitchWarning
warnings.filterwarnings("ignore", category=HareLoopSwitchWarning)
Заменённое подключение закрывается в цикле событий, который его открыл, пока этот цикл ещё работает (цикл другого потока); если тот цикл уже закрыт, его соединения разрываются — сервер закрывает их сессии. В обоих случаях на сервере ничего не остаётся открытым.
База SQLite в памяти живёт в своём единственном соединении: запрос из другого цикла событий падает с
ConfigurationError, а не открывает новую, пустую базу, — работайте с ней из одного цикла событий или
возьмите файл базы.
Вне тестов это предупреждение обычно указывает на настоящую ошибку — выясните, почему цикл событий изменился между созданием соединения и его использованием.
Пропуск теста по возможностям базы: requires_features()¶
def requires_features(
connection_alias: str | None = None,
**conditions: Any,
) -> Callable[[TestItem], TestItem]: ...
@requires_features(supports_transactions=True)
async def test_rolls_back(database): ...
@requires_features(dialect="sqlite")
async def test_sqlite_sql(database): ...
Каждое условие называет поле Features подключения (supports_transactions, supports_returning и т.
п.), иначе — атрибут SqlLiterals его диалекта (identifier_quote_char и т. п.), иначе — атрибут самого
диалекта (minimum_server_version и т. п.), или dialect — имя диалекта. См.
Диалекты и их возможности. Тест, которому нужна возможность,
называет эту возможность, поэтому выполняется на каждом диалекте, где она есть; dialect= — для теста
собственного SQL или типов одного диалекта.
Каждое условие проверяется для подключения connection_alias текущего контекста — для подключения по
умолчанию при None (единственного, которое создаёт hare_test_context(); если подключений несколько и
ни одно не называется "default" — для первого настроенного), — и если хоть одно не совпадает, бросается
unittest.SkipTest. Работает как декоратор отдельной тестовой функции или целого тестового класса
(действует на каждый метод test_*).