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

Комментарии (13)


  1. kosdmit
    14.07.2024 11:58
    +3

    Когда читал статью, сложилось впечатление, что проект достаточно свежий, на самом деле - проект живёт уже более 4x лет.
    В любом случае, спасибо за качественную статью, плагин кажется разумным и полезным.


    1. utapyngo Автор
      14.07.2024 11:58

      Всё верно. Проект зародился ещё в 2019 году, и изначально работал на DjangoJSONEncoder. Только сейчас руки дошли статью написать.
      Спасибо за поддержку.


  1. danilovmy
    14.07.2024 11:58

    это неплохо вы так переизобрели Deep Equality Test for Nested Python Structures которому уже лет 10. Но так в питоне кругом происходит, так что принимается.

    Вопрос первый - почему не сравниваете json структуры? В там есть и параметр сортировки по ключу, и проверка циклических ссылок.

    Вопрос второй - тесты с хардкодом ответа в коде, это как бы не очень тесты. Не думали, что этот тест стоило бы переписать? Вам же не равенство константе надо проверять.


    1. 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 году именно так это и работало у нас. С этим есть свои сложности. Например, не все объекты можно сериализовать. Опять же, некрасивая подготовка, если в каждом тесте это делать руками. И такой гибкости не было, как сейчас.

      А второй вопрос не понял к какому тесту относится. Если к примерам в статье - это же просто самодостаточные примеры, которые легко понять и запустить.


  1. sshikov
    14.07.2024 11:58
    +1

    элементы множества должны быть хэшируемыми, коими словари не являются.

    Это относится только к конкретной реализации. Разве в питоне нельзя реализовать множество на базе древовидной структуры? Ну или посчитать хеш для словаря? Ни то ни другое не выглядит логически невозможным.


    1. utapyngo Автор
      14.07.2024 11:58
      +2

      Реализовать можно, но это только всё усложнит. Словари в Python не являются хешируемыми объектами, потому что они изменяемые (mutable).


      1. danilovmy
        14.07.2024 11:58

        Ответ не совсем корректный. У класса словарь не реализован метод __hash__ поэтому объекты не являются хешируемыми в python. Но это происходит вне зависимости от мутабельности объектов этого класса. Другой вопрос - почему этот метод не реализован.


        1. utapyngo Автор
          14.07.2024 11:58
          +1

          Изменяемые объекты не должны быть хешируемыми. Если хеш объекта изменится, это всё сломает. А если содержимое объекта не будет влиять на хеш, то сравнение будет некорректно работать.


      1. sshikov
        14.07.2024 11:58
        +1

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


  1. SuperFly
    14.07.2024 11:58

    Сортировка данных в API не требовалась, и делать её только ради тестов казалось нелогичным

    Может пропустил, но почему бы в таком случае не отсортировать прямо в тесте? Типа `assert sorted(expected) == sorted(actual)` (ну помимо красивых сообщений об ошибках)


    1. 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'


      1. SuperFly
        14.07.2024 11:58

        понял. Каюсь, статью по диагонали прочитал. Просто и из заголовка, и из названия либы, и из процитированного вступления подумалось, что речь идет о сортируемых данных, которые в данном конкретном апи не упорядочены. А тут скорее речь не об unordered а об unorderable


    1. utapyngo Автор
      14.07.2024 11:58
      +1

      Оставлю тут ссылку на вопрос со StackOverflow, которому семь с половиной лет. Уже пробовали и sorted(), и set(), и Counter().