2025, Dec 13 12:02
Как сравнивать SQL в pytest для SQLAlchemy/SQLModel
Разбираем, почему падают тесты с моками в pytest для SQLAlchemy/SQLModel, и показываем правильный способ: сравнение скомпилированного SQL вместо объектов.
При юнит‑тестировании асинхронного кода работы с базой данных с помощью pytest и моков легко поддаться соблазну сравнивать «сырые» выражения SQLAlchemy/SQLModel напрямую. На этом месте и ломаются многие тесты: два визуально одинаковых запроса не будут равны по идентичности экземпляров. Правильный подход — сравнивать фактический SQL, который будет выполнен, а не находящиеся в памяти объекты Python.
Постановка задачи
Рассмотрим асинхронный метод сервиса, который делает простой поиск по логину. Логика прямолинейна: сформировать select, выполнить его через сессию и вернуть первую строку.
from typing import Optional
from sqlalchemy import select
class ProfileService:
@classmethod
async def fetch_by_handle(cls, db_session: "SessionStub", handle: str) -> Optional["Person"]:
query_stmt = select(Person).where(Person.login == handle)
outcome = await db_session.exec(query_stmt)
return outcome.first()
Наивный тест пытается утверждать, что у сессии вызвали метод ровно с тем же экземпляром SQL‑выражения:
import pytest
from unittest.mock import AsyncMock
from sqlalchemy import select
USER_HANDLE = "jdoe"
@pytest.mark.asyncio
async def test_fetch_by_handle(mocker):
result_mock = AsyncMock(return_value=None)
session_mock = mocker.AsyncMock()
session_mock.exec.return_value = result_mock
expected_query = select(Person).where(Person.login == USER_HANDLE)
await ProfileService.fetch_by_handle(session_mock, USER_HANDLE)
session_mock.exec.assert_called_once_with(expected_query)
session_mock.exec.assert_called_once()
result_mock.first.assert_called_once_with()
Такой тест падает, потому что два независимо созданных SQL‑выражения — это разные объекты, даже если они представляют один и тот же SQL. В выводе об ошибке будут разные адреса памяти у ожидаемого и фактического аргументов.
Почему проверка не проходит
Объекты SQL‑выражений в SQLAlchemy/SQLModel — это структуры Python. Построить select дважды — значит получить два разных экземпляра. Используя assert_called_once_with для этих экземпляров, вы сравниваете идентичность и структуру объектов, а не скомпилированный SQL‑текст, который уйдет в базу. Иными словами, вы сопоставляете обертки, а не сам запрос. Поэтому проверка вызова не совпадает, даже если выполняемое выражение логически эквивалентно.
Решение: сравнивайте скомпилированный SQL
Вместо сравнения экземпляров объектов сравнивайте SQL, который они генерируют. Переданный в мок аргумент доступен через await_args у корутинного мока. Скомпилируйте фактическое и ожидаемое выражения с подходящим диалектом SQLAlchemy и сравните их строковые представления.
import pytest
from unittest.mock import AsyncMock
from sqlalchemy import select
from sqlalchemy.dialects import postgresql
USER_HANDLE = "jdoe"
@pytest.mark.asyncio
async def test_fetch_by_handle_compares_sql(mocker):
result_mock = AsyncMock(return_value=None)
session_mock = mocker.AsyncMock()
session_mock.exec.return_value = result_mock
expected_stmt = select(Person).where(Person.login == USER_HANDLE)
await ProfileService.fetch_by_handle(session_mock, USER_HANDLE)
sent_stmt = session_mock.exec.await_args[0][0]
assert str(sent_stmt.compile(dialect=postgresql.dialect())) == str(
expected_stmt.compile(dialect=postgresql.dialect())
)
session_mock.exec.assert_called_once()
result_mock.first.assert_called_once_with()
Такой подход концентрирует тест на реальном SQL, а не на идентичности Python‑объектов. Если выражение изменится в будущем, тест корректно упадет. Убедитесь, что компилируете с нужным диалектом вашей СУБД, чтобы итоговый SQL‑текст совпадал с ожиданиями.
Альтернативный подход к сопоставлению
Есть и общий прием для проверки аргументов вызова: создайте объекты, реализующие __eq__, и передайте их в assert_called_once_with — логика равенства решит, подходит ли значение. Этот метод широко применим к проверке аргументов моков и не специфичен для SQL. См. модель данных Python для object.__eq__ и этот пример.
Почему это важно
Тесты, сравнивающие экземпляры SQL‑выражений, хрупкие и вводят в заблуждение. По мере эволюции кода вы можете собирать эквивалентные выражения немного по‑разному, что приведет к несоответствию экземпляров и ложным срабатываниям. Проверка скомпилированного SQL гарантирует, что юнит‑тест валидирует именно то, что действительно получит база данных, фиксируя значимые регрессии и отсекая шум, связанный с внутренними отличиями Python‑объектов.
Вывод
Пишете юнит‑тесты коду на SQLAlchemy/SQLModel в pytest — относитесь к SQL‑выражениям как к построителям, а не к значениям для прямого сравнения. Извлеките аргумент, переданный в замоканный исполнитель, скомпилируйте фактическое и ожидаемое выражения с правильным диалектом и сравните получившиеся SQL‑строки. Так тесты остаются сфокусированными, детерминированными и согласованными с реальным поведением слоя доступа к данным.