Во время работы над проектом на 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. В разработке помимо меня поучаствовали ещё три человека, за что я им очень благодарен. Приглашаю заинтересованных членов сообщества обсудить проект и поучаствовать в его развитии.
ссылка на оригинал статьи https://habr.com/ru/articles/828732/
Добавить комментарий