Во время работы над проектом на Django Rest Framework (DRF) я столкнулся с необходимостью писать тесты для API, которые возвращали неотсортированные данные. Сортировка данных в API не требовалась, и делать её только ради тестов казалось нелогичным. Использовать для решения этой задачи множества оказалось невозможным, так как элементы множества должны быть хэшируемыми, коими словари не являются. Я искал встроенный способ сравнивать неотсортированные данные в pytest, но таких средств не нашёл. Зато наткнулся на обсуждение в сообществе pytest, где пользователи просили реализовать такую возможность, а разработчики pytest предлагали сделать это кому-то другому в виде плагина. Так родилась идея создания pytest-unordered.
Множества
На первый взгляд, использование множеств (set
) кажется естественным решением для таких задач. Однако у этого подхода есть несколько существенных ограничений:
-
Невозможность работы с нехэшируемыми элементами:
Множества требуют, чтобы элементы были хэшируемыми. Это делает невозможным их использование с такими элементами, как списки или словари.
Попытка преобразовать коллекции со сложными структурами данных в множества приводит к ошибкам и необходимости дополнительных преобразований.
-
Потеря информации о структуре:
Множества не поддерживают дубликаты. В реальных задачах часто важно сохранить количество вхождений каждого элемента.
-
Невозможность сравнения вложенных структур:
Использование множеств не работает для вложенных структур, таких как списки словарей или сложные JSON-объекты. Сравнение таких структур требует более гибкого подхода.
assertCountEqual
Для сравнения неупорядоченных коллекций в библиотеке стандартных модулей Python также существует метод unittest.TestCase.assertCountEqual
. Этот метод проверяет, что два списка содержат одинаковое число вхождений каждого элемента, независимо от их порядка:
import unittest
def test_list_equality(self):
actual = [3, 1, 2, 2]
expected = [1, 2, 3, 2]
assert unittest.TestCase().assertCountEqual(actual, expected)
Однако у unittest.TestCase.assertCountEqual
есть свои недостатки:
-
Некрасиво:
camelCase
При использовании
assertCountEqual
в тестах pytest приходится создавать ненужный экземпляр классаunittest.TestCase
-
Невозможность сравнения сложных структур данных:
Метод предназначен для работы с простыми списками. Нельзя пометить отдельные списки внутри вложенных структур как неупорядоченные.
Подход pytest-unordered
Для решения вышеуказанных проблем в pytest-unordered используется подход, аналогичный используемому в pytest.approx
: функция unordered
создаёт объект, который переопределяет метод сравнения __eq__
. Благодаря этому становится возможным следующее:
Поддержка сложных структур данных:
pytest-unordered
позволяет сравнивать списки, кортежи и даже вложенные структуры данных, такие как списки словарей, без необходимости преобразования их в множества. Достаточно просто обернуть нужные элементы структуры вunordered()
.Сохранение дубликатов:
В отличие от множеств,pytest-unordered
корректно обрабатывает случаи, когда в коллекциях могут быть дублирующиеся элементы, сохраняя их количество.Упрощение кода тестов:
Использованиеpytest-unordered
делает тесты более читаемыми и понятными. Не нужно заботиться о предварительной сортировке или преобразовании данных перед их сравнением.
Возможности и примеры использования pytest-unordered
Посмотрим на примеры использования pytest-unordered
.
Для начала нужно установить пакет с помощью pip
:
pip install pytest-unordered
Сравнение списков без учёта порядка
Рассмотрим простой пример сравнения списков:
from pytest_unordered import unordered
def test_list_equality():
actual = [3, 1, 2]
expected = [1, 2, 3]
assert actual == unordered(expected)
Здесь unordered
позволяет проверить, что два списка содержат одинаковые элементы, независимо от их порядка.
Сравнение списков словарей
pytest-unordered
также поддерживает сравнение списков словарей:
def test_lists_of_dicts():
actual = [
{"name": "Alice", "age": 30},
{"name": "Bob", "age": 25}
]
expected = unordered([
{"name": "Bob", "age": 25},
{"name": "Alice", "age": 30}
])
assert actual == expected
Этот тест проверяет, что оба списка содержат одинаковые словари, независимо от порядка их следования.
Сложные структуры данных
pytest-unordered
позволяет помечать отдельные коллекции внутри сложных структур как неупорядоченные.
def test_nested():
expected = unordered([
{"customer": "Alice", "orders": unordered([123, 456])},
{"customer": "Bob", "orders": [789, 1000]},
])
actual = [
{"customer": "Bob", "orders": [789, 1000]},
{"customer": "Alice", "orders": [456, 123]},
]
assert actual == expected
Здесь внешние списки клиентов, а также заказы Алисы проверяются без учёта порядка элементов, в то время как заказы Боба проверяются с учётом порядка.
Работа с дубликатами
pytest-unordered
корректно обрабатывает случаи, когда коллекции содержат дубликаты:
def test_with_duplicates():
actual = [1, 2, 2, 3]
expected = [3, 2, 1]
assert actual == unordered(expected)
def test_with_duplicates():
actual = [1, 2, 2, 3]
expected = [3, 2, 1]
> assert actual == unordered(expected)
E assert [1, 2, 2, 3] == [3, 2, 1]
E Extra items in the left sequence:
E 2
Легко увидеть, что при использовании множеств для сравнения данных списков получился бы другой результат.
Проверка типов коллекций
Если в функцию unordered в качестве коллекции передан один аргумент, будет выполнена проверка соответствия типов коллекций:
assert [1, 20, 300] == unordered([20, 300, 1])
assert (1, 20, 300) == unordered((20, 300, 1))
Если типы контейнеров различаются, проверка не пройдёт:
assert [1, 20, 300] == unordered((20, 300, 1))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError
Для генераторов сделано исключение:
assert [4, 0, 1] == unordered((i*i for i in range(3)))
Чтобы отключить проверку типов, можно передать элементы как отдельные аргументы:
assert [1, 20, 300] == unordered(20, 300, 1)
assert (1, 20, 300) == unordered(20, 300, 1)
Также можно явно указать параметр check_type
:
assert [1, 20, 300] == unordered((20, 300, 1), check_type=False)
Причём тут pytest
Функция для сравнения коллекций без учёта порядка не является специфичной для pytest
, но pytest-unordered
интегрируется с ним благодаря реализации хука pytest_assertrepr_compare
. Это позволяет использовать возможности плагина непосредственно в тестах на pytest
и получать удобные сообщения об ошибках при неудачных проверках. Выше уже был пример с дубликатами. Вот пример сообщения при замене одного элемента:
def test_unordered():
assert [{"a": 1, "b": 2}, 2, 3] == unordered(2, 3, {"b": 2, "a": 3})
def test_unordered():
> assert [{"a": 1, "b": 2}, 2, 3] == unordered(2, 3, {"b": 2, "a": 3})
E AssertionError: assert [{'a': 1, 'b': 2}, 2, 3] == [2, 3, {'b': 2, 'a': 3}]
E One item replaced:
E Common items:
E {'b': 2}
E Differing items:
E {'a': 1} != {'a': 3}
E
E Full diff:
E {
E - 'a': 3,
E ? ^
E + 'a': 1,
E ? ^
E 'b': 2,
E }
Реализация алгоритма сравнения
Ключевая часть pytest-unordered
— это класс UnorderedList
, который реализует логику сравнения коллекций без учёта порядка в методе compare_to
.
Код метода compare_to
def compare_to(self, other: List) -> Tuple[List, List]:
extra_left = list(self)
extra_right = []
reordered = []
placeholder = object()
for elem in other:
try:
extra_left.remove(elem)
reordered.append(elem)
except ValueError:
extra_right.append(elem)
reordered.append(placeholder)
placeholder_fillers = extra_left.copy()
for i, elem in reversed(list(enumerate(reordered))):
if not placeholder_fillers:
break
if elem == placeholder:
reordered[i] = placeholder_fillers.pop()
self[:] = [e for e in reordered if e is not placeholder]
return extra_left, extra_right
Метод compare_to
осуществляет фактическое сравнение элементов коллекций. Изначально все элементы self
копируются в extra_left
. Элементы сравниваемого списка проверяются на наличие в extra_left
. Найденные элементы удаляются из extra_left
, а отсутствующие добавляются в extra_right
. Если все элементы найдены, они располагаются в reordered
в правильном порядке. Для отсутствующих элементов используются заполнители, которые затем заменяются на элементы сравниваемого списка в том порядке, в котором они встретились. В итоге возвращаются оставшиеся элементы из extra_left
и extra_right
.
Переупорядочивание элементов в compare_to
выполняется для создания наглядного отображения данных при визуальном сравнении в среде разработки. Если элементы находятся не на своих местах, использование заполнителей помогает определить точные позиции отсутствующих и ошибочных элементов. Это значительно улучшает читаемость сообщений об ошибках в IDE и упрощает отладку тестов. Вот пример:
def test_reordering():
expected = unordered([
{"customer": "Charlie", "orders": [123, 456]},
{"customer": "Alice", "orders": unordered([123, 456])},
{"customer": "Bob", "orders": [789, 1000]},
])
actual = [
{"customer": "Alice", "orders": [456, 123]},
{"customer": "Bob", "orders": [789, 1000]},
{"customer": "Charles", "orders": [123, 456]},
]
assert actual == expected
Преимущества pytest-unordered
pytest-unordered
предоставляет множество преимуществ по сравнению с использованием множеств или предварительной сортировкой данных:
Плагин устраняет необходимость в дополнительных преобразованиях данных и делает тесты проще и понятнее.
pytest-unordered
позволяет легко сравнивать сложные и вложенные структуры данных без дополнительных усилий.В отличие от множеств,
pytest-unordered
корректно обрабатывает дубликаты.Код тестов с использованием
unordered
становится более читаемым и легко поддерживаемым, поскольку он отражает истинное намерение теста — сравнить набор элементов, игнорируя их порядок.Переупорядочивание элементов делает визуальное сравнение отличающихся коллекций более наглядным в среде разработки.
Заключение
Если вы работаете с данными, порядок которых не имеет значения, попробуйте pytest-unordered
в своих проектах. Это поможет вам писать более простые, эффективные и понятные тесты.
На момент написания статьи репозиторий pytest-unordered
собрал 40 звёздочек на GitHub. В разработке помимо меня поучаствовали ещё три человека, за что я им очень благодарен. Приглашаю заинтересованных членов сообщества обсудить проект и поучаствовать в его развитии.
Комментарии (13)
danilovmy
14.07.2024 11:58это неплохо вы так переизобрели Deep Equality Test for Nested Python Structures которому уже лет 10. Но так в питоне кругом происходит, так что принимается.
Вопрос первый - почему не сравниваете json структуры? В там есть и параметр сортировки по ключу, и проверка циклических ссылок.
Вопрос второй - тесты с хардкодом ответа в коде, это как бы не очень тесты. Не думали, что этот тест стоило бы переписать? Вам же не равенство константе надо проверять.
utapyngo Автор
14.07.2024 11:58+3Что-то не вижу в Deep Equality Test for Nested Python Structures параметра, чтобы без учёта порядка сравнивать. Это же просто глубокое сравнение структур.
deep_eq([1,2,3], [3,2,1])
Out[7]: False
Конечно, json-структуры пытались сравнивать. В 2019 году именно так это и работало у нас. С этим есть свои сложности. Например, не все объекты можно сериализовать. Опять же, некрасивая подготовка, если в каждом тесте это делать руками. И такой гибкости не было, как сейчас.
А второй вопрос не понял к какому тесту относится. Если к примерам в статье - это же просто самодостаточные примеры, которые легко понять и запустить.
sshikov
14.07.2024 11:58+1элементы множества должны быть хэшируемыми, коими словари не являются.
Это относится только к конкретной реализации. Разве в питоне нельзя реализовать множество на базе древовидной структуры? Ну или посчитать хеш для словаря? Ни то ни другое не выглядит логически невозможным.
utapyngo Автор
14.07.2024 11:58+2Реализовать можно, но это только всё усложнит. Словари в Python не являются хешируемыми объектами, потому что они изменяемые (mutable).
danilovmy
14.07.2024 11:58Ответ не совсем корректный. У класса словарь не реализован метод __hash__ поэтому объекты не являются хешируемыми в python. Но это происходит вне зависимости от мутабельности объектов этого класса. Другой вопрос - почему этот метод не реализован.
utapyngo Автор
14.07.2024 11:58+1Изменяемые объекты не должны быть хешируемыми. Если хеш объекта изменится, это всё сломает. А если содержимое объекта не будет влиять на хеш, то сравнение будет некорректно работать.
sshikov
14.07.2024 11:58+1Насчет мутабельности я согласен, мутабельные ключи - это немного нонсенс. Но дерево все равно можно построить. Оно не будет такое быстрое, возможно, как хеш таблица, но работать будет.
SuperFly
14.07.2024 11:58Сортировка данных в API не требовалась, и делать её только ради тестов казалось нелогичным
Может пропустил, но почему бы в таком случае не отсортировать прямо в тесте? Типа `assert sorted(expected) == sorted(actual)` (ну помимо красивых сообщений об ошибках)
utapyngo Автор
14.07.2024 11:58Я не писал об этом, потому что думал, что это и так очевидно...
>>> sorted([{}, {}]) Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: '<' not supported between instances of 'dict' and 'dict'
SuperFly
14.07.2024 11:58понял. Каюсь, статью по диагонали прочитал. Просто и из заголовка, и из названия либы, и из процитированного вступления подумалось, что речь идет о сортируемых данных, которые в данном конкретном апи не упорядочены. А тут скорее речь не об unordered а об unorderable
utapyngo Автор
14.07.2024 11:58+1Оставлю тут ссылку на вопрос со StackOverflow, которому семь с половиной лет. Уже пробовали и sorted(), и set(), и Counter().
kosdmit
Когда читал статью, сложилось впечатление, что проект достаточно свежий, на самом деле - проект живёт уже более 4x лет.
В любом случае, спасибо за качественную статью, плагин кажется разумным и полезным.
utapyngo Автор
Всё верно. Проект зародился ещё в 2019 году, и изначально работал на DjangoJSONEncoder. Только сейчас руки дошли статью написать.
Спасибо за поддержку.