pytest-unordered: сравнение коллекций без учёта порядка

от автора

Во время работы над проектом на Django Rest Framework (DRF) я столкнулся с необходимостью писать тесты для API, которые возвращали неотсортированные данные. Сортировка данных в API не требовалась, и делать её только ради тестов казалось нелогичным. Использовать для решения этой задачи множества оказалось невозможным, так как элементы множества должны быть хэшируемыми, коими словари не являются. Я искал встроенный способ сравнивать неотсортированные данные в pytest, но таких средств не нашёл. Зато наткнулся на обсуждение в сообществе pytest, где пользователи просили реализовать такую возможность, а разработчики pytest предлагали сделать это кому-то другому в виде плагина. Так родилась идея создания pytest-unordered.


Множества

На первый взгляд, использование множеств (set) кажется естественным решением для таких задач. Однако у этого подхода есть несколько существенных ограничений:

  1. Невозможность работы с нехэшируемыми элементами:

    • Множества требуют, чтобы элементы были хэшируемыми. Это делает невозможным их использование с такими элементами, как списки или словари.

    • Попытка преобразовать коллекции со сложными структурами данных в множества приводит к ошибкам и необходимости дополнительных преобразований.

  2. Потеря информации о структуре:

    • Множества не поддерживают дубликаты. В реальных задачах часто важно сохранить количество вхождений каждого элемента.

  3. Невозможность сравнения вложенных структур:

    • Использование множеств не работает для вложенных структур, таких как списки словарей или сложные 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 есть свои недостатки:

  1. Некрасиво:

    • camelCase

    • При использовании assertCountEqual в тестах pytest приходится создавать ненужный экземпляр классаunittest.TestCase

  2. Невозможность сравнения сложных структур данных:

    • Метод предназначен для работы с простыми списками. Нельзя пометить отдельные списки внутри вложенных структур как неупорядоченные.

Подход 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 предоставляет множество преимуществ по сравнению с использованием множеств или предварительной сортировкой данных:

  1. Плагин устраняет необходимость в дополнительных преобразованиях данных и делает тесты проще и понятнее.

  2. pytest-unordered позволяет легко сравнивать сложные и вложенные структуры данных без дополнительных усилий.

  3. В отличие от множеств, pytest-unordered корректно обрабатывает дубликаты.

  4. Код тестов с использованием unordered становится более читаемым и легко поддерживаемым, поскольку он отражает истинное намерение теста — сравнить набор элементов, игнорируя их порядок.

  5. Переупорядочивание элементов делает визуальное сравнение отличающихся коллекций более наглядным в среде разработки.

Заключение

Если вы работаете с данными, порядок которых не имеет значения, попробуйте pytest-unordered в своих проектах. Это поможет вам писать более простые, эффективные и понятные тесты.

На момент написания статьи репозиторий pytest-unordered собрал 40 звёздочек на GitHub. В разработке помимо меня поучаствовали ещё три человека, за что я им очень благодарен. Приглашаю заинтересованных членов сообщества обсудить проект и поучаствовать в его развитии.


ссылка на оригинал статьи https://habr.com/ru/articles/828732/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *