pytest-unordered: сравнение коллекций без учёта порядка
Во время работы над проектом на 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
есть свои недостатки:
Некрасиво:
Невозможность сравнения сложных структур данных:
Подход 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. В разработке помимо меня поучаствовали ещё три человека, за что я им очень благодарен. Приглашаю заинтересованных членов сообщества обсудить проект и поучаствовать в его развитии.