Зачем может быть нужна типизация в тестах?В последние годы
python проекты сплош и рядом обмазаны тайпингом. Мы очень много внимания уделяем основному коду, но при этом в тестах мы продолжаем разбирать словари и строки.
По-моему опыту и опыту коллег, с кем общаюсь, в средне статистическом бэкенде на
python тесты на API выглядят примерно вот так:
async def test_api(http_client: TestClient) -> None:
payment: PaymentDB = ...
result = await http_client.get(f"/api/payments/{payment.id}")
assert result.status_code == 200
assert result.json() == {"id": payment.id, "status": payment.status}
У нас есть какой-то http клиент, мы им делаем запросы. Урлы собираем прямо в теле теста, подставляем параметры сущностей, которые запрашиваем.
В конце смотрим статус коды, парсим
json и либо прямо в теле теста разбираем поля словаря, либо передаем этот словарь в
функции-ассерты.
Можно разбирать разные плюсы и минусы такого подхода, но сегодня я хочу заострить внимание на нескольких.
Во-первых, когда начинаешь менять схемы запроса, ответа, эндпоинта (осознанно ломаем), узнать о поломке теста можно только в рантайме. При работе с обычным http клиентом в тестах мы работаем с сырыми данными на вход и на выход, а значит там может быть все что угодно с точки зрения линтеров по типу
mypyА во-вторых, что лично меня иногда задалбывает, при написании ассертов нужно бегать смотреть схему ответа, знать, как собирается итоговый полный урл со всеми префиксами и пр.
Все это терпимо, да, но лакшери жизни иногда тоже хочется. И вот в рамках эээксперимента я написал надстройку в одном из пет проектов для
blacksheep, которая дает типизацию тестовому клиенту
Вот так выглядит объявление метода, который мы будем тестировать:
class PaymentsController(ApiController):
_get_payment: GetPaymentQuery
@router.get("/{payment_id}")
async def get_payment(self, payment_id: int, user: UserIdentity) -> PaymentResponse:
payment = await self._get_payment.execute(...)
return PaymentResponse(id=payment.id, status=payment.status)
А вот так выглядит тест с типизированным клиентом:
class TestGetPayment(CasesTest[PaymentsController]):
async def test_api(self) -> None:
payment: PaymentDB = ...
# mypy понимает, что в result находится PaymentResponse
result = await self.controller.get_payment(payment_id=payment.id, user=self.DI)
# а тут он уже найдет ошибку, потому что id с типом int
# error: Non-overlapping equality check
# (left operand type: "int", right operand type: "Literal['12']")
assert result.id == "12"
По-моему круто. Можно рефакторить и отлавливать ошибки не запуская дорогие интеграционные тесты. А если еще рефакторишь не сам, а через
LLM, то и на токенах сэкономишь, по-скольку агенту нужно сильно меньше приседаний для поиска связанных тестов и их запуска, разбора
По сути мы здесь выкинули
http часть и превратили контроллер в обычную функцию. Но прелесть в том, что вся остальная логика фреймворка и приложения остается и выполняется как раньше:
- все стартап хуки отработали
- мидлвари отработали
- обработчики исключений тоже
И приятный бонус, мы не гоняем объекты в
json туда-обратно. Когда тестов переваливает за 2к это тоже играет роль на скорости и потреблении цпу. Особенно если ранеры у вас не такие мощные как личная машинка...
Однако тут не обошлось без минусов. Из очевидных - если мы поменяем через алиасы имена полей в ответе, поменяем
path,
method, то такой тест
не упадет. Мы сломаем контракт, но не узнаем об этом.
Впрочем и эту проблему можно закрыть, расскажу в другом посте
Напишите в комментах, каким способом можно закрыть эту проблему с непойманным изменением контракта?
И как вам в целом такой подход с "типизированным" клиентом?