При работе над API lapateka.ru - это сервис про питомцев - у меня в интеграционных тестах накопилось слишком много кода вот такого вида:

latest = client.get("/pets/rx-7/vaccinations/latest").json()
assert latest["date"] == "2026-03-10"
assert latest["vaccine"] == "Nobivac DHPPi"
assert latest["next_due"] == "2027-03-10"

Работает, но читать неприятно, и падает такое плохо: первое несовпавшее поле обрывает тест, остальные расхождения увидите следующими прогонами. Я полез искать, есть ли способ описать ожидание целиком, и нашёл dirty-equals - крошечную библиотеку без зависимостей от автора pydantic. Идея простая: специальный объект на правой стороне ==, который сам решает, считать значения равными или нет. pytest показывает обычный дифф, потому что для него это обычная проверка равенства.

Представим, что у нас есть API ветклиники. Там есть питомцы и прививки, и запрос последней вакцинации возвращает примерно такое:

{
  "id": "v-104",
  "pet_id": "rx-7",
  "date": "2026-03-10",
  "vaccine": "Nobivac DHPPi",
  "next_due": "2027-03-10"
}

Часть находок вокруг этой библиотеки оказалась про неё саму, а часть - про то, как мы вообще пишем ассерты.

Запах 1: цепочка ассертов по полям

Код из начала превращается в одно выражение:

assert client.get("/pets/rx-7/vaccinations/latest").json() == IsPartialDict(
    date="2026-03-10",
    vaccine="Nobivac DHPPi",
    next_due="2027-03-10",
)

Показательнее всего - как это падает. Подставим реалистичную регрессию: бэкенд вдруг стал возвращать самую раннюю прививку вместо последней, и врут сразу три поля - дата, вакцина и next_due. Вот как это это выглядит в pytest с dirty-equals:

>       assert latest == IsPartialDict(
            date="2026-03-10",
            vaccine="Nobivac DHPPi",
            next_due="2027-03-10",
        )
E       AssertionError: assert {'id': 'v-104', 'pet_id': 'rx-7', 'date': '2026-01-15', 'vaccine': 'Nobivac Rabies', 'next_due': '2027-01-15'} == IsPartialDict(date='2026-03-10', vaccine='Nobivac DHPPi', next_due='2027-03-10')

Фактический ответ целиком слева, ожидание справа - все три расхождения видны за один прогон. Цепочка ассертов упала бы на первом же сравнении, показала только '2026-01-15' != '2026-03-10', и всё, остальное ждало бы следующих прогонов.

Заодно здесь видно подмножество в действии: на лишние поля id и pet_id в ответе никто не ругается, потому что IsPartialDict проверяет только то, что перечислено в ассерте. Это важно для читаемых тестов - мы заявляеем то, что проверяете, а не весь снимок ответа.

Запах 2: извлечение ради ассерта

Следующий уровень проблемы - тесты, которые сначала достают элемент из ответа, а потом проверяют уже его. Скажем, мы сделали питомцу две прививки и читаем список:

shots = client.get("/pets/rx-7/vaccinations").json()
assert {s["id"] for s in shots} == {first, second}

by_id = {s["id"]: s for s in shots}
assert by_id[first]["vaccine"] == "Nobivac DHPPi"
assert by_id[first]["clinic"] is None
assert by_id[second]["clinic"] == "ВетПлюс"

Словарь by_id здесь существует только ради ассертов. Тест знает, как искать, вместо того чтобы заявить ожидаемую форму целиком. Правильнее перевернуть задачу: не “найти элемент и проверить”, а “проверить всю коллекцию, зашив известные ключи в матчер”:

assert shots == IsList(
    IsPartialDict(id=first, vaccine="Nobivac DHPPi", clinic=None),
    IsPartialDict(id=second, clinic="ВетПлюс"),
    check_order=False,
)

IsList с check_order=False - это точное сравнение мультимножества: каждый матчер обязан найти свой элемент, лишних элементов быть не должно, дубликаты ловятся. Это сильнее сравнения множеств id, которое схлопывает дубликаты и ничего не знает о форме элементов. Заодно сравнение со списком из двух матчеров содержит проверку длины: элементов должно быть ровно два.

Порядок не проверяется, если API его не обещает. Я предпочитаю не закладываться на порядок ответа, пока он не объявлен контрактом.

Если элементы различаются контентом (дата, вакцина, клиника), id в матчер можно вообще не класть - в коллекции фиксированной длины контент сам идентифицирует запись.

Запах 3: поисковые хелперы с next() внутри

Когда извлечение нужно не одному тесту, оно обычно эволюционирует в хелпер:

def _shot_by_id(client, pet_id, shot_id):
    shots = client.get(f"/pets/{pet_id}/vaccinations").json()
    return next(s for s in shots if s["id"] == shot_id)

Двойной запах. Во-первых, фильтр прячет проверяемое утверждение: если тест нашёл прививку поиском, факт “прививка существует и она одна” доказан неявно, и в теле теста его не видно. Во-вторых, режим падения: элемента нет - получаем StopIteration с трейсбеком в кишках хелпера вместо честного диффа.

Вместо этого - сначала ассерт, потом индекс. Питомцу сделали первую прививку, тесту нужны её детали:

shots = client.get("/pets/rx-7/vaccinations").json()
assert shots == [IsPartialDict(vaccine="Nobivac DHPPi")]
detail = client.get(f"/pets/rx-7/vaccinations/{shots[0]['id']}").json()

Индекс [0] легитимен ровно потому, что предыдущая строка закрепила “в списке ровно один элемент”. Голая индексация без такого обоснования - тоже запах: shots[0] проверяет “первую из возможно многих”, а это случайная семантика.

Вложенность - бесплатно

Самое приятное в матчерах - композиция. Возьмём расписание клиники: список врачей, у каждого - свои свободные слоты:

assert client.get("/schedule").json() == IsList(
    IsPartialDict(
        doctor="Иванова",
        slots=IsList(
            IsPartialDict(time="10:00"),
            IsPartialDict(time="14:30"),
            check_order=False,
        ),
    ),
    IsPartialDict(
        doctor="Петров",
        slots=[IsPartialDict(time="11:00")],
    ),
    check_order=False,
)

Врачи не упорядочены, слоты внутри расписания врача не упорядочены, а состав и количество - строгие. Раньше на такую проверку уходили проекции с извлечением полей, теперь это один декларативный кусок.

Глобальные списки: Contains

Точное сравнение годится, когда тест - единственный хозяин данных. Для общих списков (в выдуманной клинике - справочник вакцин, который пополняется без ведома тестов) есть Contains:

assert client.get("/vaccines").json() == Contains(
    IsPartialDict(
        code="DHPPi",
        requires_revaccination=True,
    )
)

Элемент с нужными полями есть, остальные элементы не важны.

Итого

Общий принцип, к которому я пришёл: тест декларирует ожидаемую форму целиком, и любой поиск в теле теста - сигнал, что ассерт недостаточно специфичен.

Ссылки