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‑строки. Так тесты остаются сфокусированными, детерминированными и согласованными с реальным поведением слоя доступа к данным.