├── .coveragerc ├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── pyproject.toml ├── pytest_unordered ├── __init__.py └── py.typed ├── setup.py ├── tests ├── __init__.py ├── conftest.py └── test_unordered.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | pragma: no cover 4 | if TYPE_CHECKING: 5 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish on PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: '3.x' 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install --upgrade setuptools wheel 21 | python setup.py sdist bdist_wheel 22 | - name: pypi-publish 23 | uses: pypa/gh-action-pypi-publish@v1.3.1 24 | with: 25 | user: __token__ 26 | password: ${{ secrets.PYPI_TOKEN }} 27 | - name: Upload packages 28 | uses: actions/upload-artifact@v4 29 | with: 30 | name: dist 31 | path: dist 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Python 13 | uses: actions/setup-python@v5 14 | with: 15 | python-version: '3.x' 16 | - name: Install pre-commit 17 | run: python -m pip install pre-commit 18 | - name: Run pre-commit 19 | run: pre-commit run --all-files 20 | 21 | test: 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | python-version: 26 | - "3.13" 27 | - "3.12" 28 | - "3.11" 29 | - "3.10" 30 | - "3.9" 31 | - "3.8" 32 | - "pypy3.8" 33 | - "pypy3.9" 34 | - "pypy3.10" 35 | - "pypy3.11" 36 | pytest-version: 37 | - "pytest<8" 38 | - "pytest<9" 39 | - "pytest" 40 | - "git+https://github.com/pytest-dev/pytest.git@main" 41 | exclude: 42 | - python-version: "3.8" 43 | pytest-version: "git+https://github.com/pytest-dev/pytest.git@main" 44 | - python-version: "pypy3.8" 45 | pytest-version: "git+https://github.com/pytest-dev/pytest.git@main" 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: actions/checkout@v4 49 | with: 50 | fetch-depth: 2 51 | - name: Set up Python 52 | uses: actions/setup-python@v5 53 | with: 54 | python-version: "${{ matrix.python-version }}" 55 | - name: Install dependencies 56 | run: python -m pip install tox "${{ matrix.pytest-version }}" pytest-cov . 57 | - name: Test 58 | run: | 59 | coverage run --branch --source=pytest_unordered -m pytest tests/ 60 | coverage xml -o ./coverage.xml 61 | - name: Upload coverage to Codecov 62 | uses: codecov/codecov-action@v4 63 | with: 64 | fail_ci_if_error: false 65 | files: ./coverage.xml 66 | token: ${{ secrets.CODECOV_TOKEN }} 67 | verbose: true 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Environments 87 | .env 88 | .venv 89 | env/ 90 | venv/ 91 | ENV/ 92 | env.bak/ 93 | venv.bak/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # mypy 106 | .mypy_cache/ 107 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.11.6 4 | hooks: 5 | - id: ruff-format 6 | args: [., --check] 7 | - id: ruff-format 8 | args: [.] 9 | - id: ruff 10 | args: [., --fix, --exit-non-zero-on-fix, --show-fixes] 11 | 12 | - repo: https://github.com/pre-commit/mirrors-mypy 13 | rev: v1.15.0 14 | hooks: 15 | - id: mypy 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | ## [0.7.0] - 2025-06-03 5 | - Add Python 3.13 support 6 | - Add deep unordered for nested data structures (#17) 7 | - Switch to ruff for linting and formatting 8 | 9 | ## [0.6.1] - 2024-07-05 10 | - Fix matching with `mock.ANY` (#16) 11 | 12 | 13 | ## [0.6.0] - 2024-03-13 14 | - Add Pytest 8 support 15 | - Add Python 3.12 support 16 | - Drop Pytest 6 support 17 | - Drop Python 3.7 support 18 | 19 | 20 | ## [0.5.2] - 2022-11-28 21 | - Reorder items on __eq__ for better diff 22 | 23 | 24 | ## [0.5.1] - 2022-07-08 25 | - Convert to a package 26 | - Add `py.typed` so type checkers can use the type hints 27 | 28 | 29 | ## [0.5.0] - 2022-07-07 30 | - Drop Python 3.6 support 31 | - Improve type hints 32 | 33 | 34 | ## [0.4.1] - 2021-03-28 35 | - Add `check_type` argument to make it possible to disable type checking for single non-generators positional argument 36 | 37 | 38 | ## [0.4.0] - 2020-11-02 39 | - Add sequence type check when using `unordered` with single argument 40 | 41 | 42 | ## [0.3.0] - 2020-10-20 43 | - Allow passing a generator to unordered() 44 | 45 | 46 | ## [0.2.0] - 2020-08-10 47 | - Better diffs both in command line and in IDE 48 | 49 | 50 | ## [0.1.1] - 2019-11-27 51 | - Better type checking 52 | 53 | 54 | ## [0.1.0] - 2019-11-26 55 | - First version 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Ivan Zaikin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pytest-unordered: Test collection content, ignoring order 2 | 3 | [![Build Status](https://github.com/utapyngo/pytest-unordered/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/utapyngo/pytest-unordered/actions/workflows/test.yml?query=branch%3Amaster) 4 | [![Coverage Status](https://codecov.io/gh/utapyngo/pytest-unordered/branch/master/graph/badge.svg)](https://codecov.io/gh/utapyngo/pytest-unordered) 5 | ![Language](https://img.shields.io/github/languages/top/utapyngo/pytest-unordered) 6 | [![Python Compatibility](https://img.shields.io/pypi/pyversions/pytest-unordered)](https://pypi.python.org/pypi/pytest-unordered) 7 | [![PyPI](https://img.shields.io/pypi/v/pytest-unordered?color=rgb%2852%2C%20208%2C%2088%29)](https://pypi.org/project/pytest-unordered/) 8 | 9 | 10 | `pytest_unordered` allows you to write simple (pytest) assertions 11 | to test whether collections have the same content, regardless of order. 12 | For example: 13 | 14 | assert [1, 20, 300] == unordered([20, 300, 1]) 15 | 16 | 17 | It is especially useful when testing APIs that return some complex data structures 18 | in an arbitrary order, e.g.: 19 | 20 | assert response.json() == { 21 | "people": unordered( 22 | # Here we test that the collection type is list 23 | [ 24 | { 25 | "name": "Alice", 26 | "age": 20, 27 | "children": unordered( 28 | # Here the collection type is not important 29 | {"name": "Bob", "age": 2}, 30 | {"name": "Carol", "age": 3}, 31 | ), 32 | }, 33 | { 34 | "name": "Dave", 35 | "age": 30, 36 | "children": unordered( 37 | {"name": "Eve", "age": 5}, 38 | {"name": "Frank", "age": 6}, 39 | ), 40 | }, 41 | ] 42 | ), 43 | } 44 | 45 | 46 | 47 | ## Installation 48 | 49 | pip install pytest-unordered 50 | 51 | 52 | ## Usage 53 | 54 | ### Basics 55 | 56 | In most cases you just need the `unordered()` helper function: 57 | 58 | from pytest_unordered import unordered 59 | 60 | Compare list or tuples by wrapping your expected value with `unordered()`: 61 | 62 | assert [1, 20, 300] == unordered([20, 300, 1]) # Pass 63 | assert (1, 20, 300) == unordered((20, 300, 1)) # Pass 64 | 65 | Excessive/missing items will be reported by pytest: 66 | 67 | assert [1, 20, 300] == unordered([20, 300, 1, 300]) 68 | 69 | E Extra items in the right sequence: 70 | E 300 71 | 72 | By default, the container type has to match too: 73 | 74 | assert (1, 20, 300) == unordered([20, 300, 1]) 75 | 76 | E Type mismatch: 77 | E != 78 | 79 | 80 | 81 | ### Nesting 82 | 83 | A seasoned developer will notice that the simple use cases above 84 | can also be addressed with appropriate usage 85 | of builtins like `set()`, `sorted()`, `isinstance()`, `repr()`, etc, 86 | but these solutions scale badly (in terms of boilerplate code) 87 | with the complexity of your data structures. 88 | For example: naively implementing order ignoring comparison 89 | with `set()` or `sorted()` does not work with lists of dictionaries 90 | because dictionaries are not hashable or sortable. 91 | `unordered()` supports this out of the box however: 92 | 93 | assert [{"bb": 20}, {"a": 1}] == unordered([{"a": 1}, {"bb": 20}]) # Pass 94 | 95 | 96 | The true value of `unordered()` lies in the fact that you 97 | can apply it inside large nested data structures to skip order checking 98 | only in desired places with surgical precision 99 | and without a lot of boilerplate code. 100 | For example: 101 | 102 | expected = unordered([ 103 | {"customer": "Alice", "orders": unordered([123, 456])}, 104 | {"customer": "Bob", "orders": [789, 1000]}, 105 | ]) 106 | 107 | actual = [ 108 | {"customer": "Bob", "orders": [789, 1000]}, 109 | {"customer": "Alice", "orders": [456, 123]}, 110 | ] 111 | 112 | assert actual == expected 113 | 114 | In this example we wrapped the outer customer list and the order list of Alice 115 | with `unordered()`, but didn't wrap Bob's order list. 116 | With the `actual` value of above (where customer order is different 117 | and Alice's orders are reversed), the assertion will pass. 118 | But if the orders of Bob would be swapped in `actual`, the assertion 119 | will fail and pytest will report: 120 | 121 | E Differing items: 122 | E {'orders': [1000, 789]} != {'orders': [789, 1000]} 123 | 124 | 125 | 126 | ### Container type checking 127 | 128 | As noted, the container types should be (by default) equal to pass the 129 | assertion. If you don't want this type check, call `unordered()` 130 | in a variable argument fashion (instead of passing 131 | a container as single argument): 132 | 133 | assert [1, 20, 300] == unordered(20, 300, 1) # Pass 134 | assert (1, 20, 300) == unordered(20, 300, 1) # Pass 135 | 136 | This pattern also allows comparing with iterators, generators and alike: 137 | 138 | assert iter([1, 20, 300]) == unordered(20, 300, 1) # Pass 139 | assert unordered(i for i in range(3)) == [2, 1, 0] # Pass 140 | 141 | If you want to enforce type checking when passing a single generator expression, 142 | pass `check_type=True`: 143 | 144 | assert unordered((i for i in range(3)), check_type=True) == [2, 1, 0] # Fail 145 | assert unordered((i for i in range(3)), check_type=True) == (i for i in range(2, -1, -1)) # Pass 146 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | line-length = 100 3 | target-version = "py38" 4 | exclude =[ 5 | "__pycache__", 6 | ".git", 7 | ".venv*/*", 8 | "venv*/*", 9 | "*/site-packages/*", 10 | ] 11 | 12 | [tool.ruff.format] 13 | indent-style = "space" 14 | 15 | [tool.ruff.lint] 16 | select = ["ALL"] 17 | ignore = [ 18 | "ANN401", # Dynamically typed expressions (typing.Any) are disallowed 19 | "COM812", # missing-trailing-comma: May conflict with the formatter 20 | "D", # pydocstyle 21 | "Q", # flake8-quotes (may conflict with the formatter) 22 | "PLR2004", # Magic value used in comparison, consider replacing with a constant variable 23 | "S101", # Use of `assert` detected 24 | "SIM201", # negate-equal-op 25 | "SIM202", # negate-not-equal-op 26 | "UP038", # non-pep604-isinstance: This rule is deprecated 27 | ] 28 | 29 | [tool.ruff.lint.isort] 30 | force-single-line = true 31 | 32 | [tool.mypy] 33 | disallow_untyped_defs = true 34 | exclude = [ 35 | '\.eggs', 36 | '\.git', 37 | '\.pytest_cache', 38 | '\.tox', 39 | 'build', 40 | 'dist', 41 | 'venv', 42 | ] 43 | -------------------------------------------------------------------------------- /pytest_unordered/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Generator 4 | from collections.abc import Iterable 5 | from collections.abc import Mapping 6 | from typing import TYPE_CHECKING 7 | from typing import Any 8 | 9 | import pytest 10 | from _pytest._io.saferepr import saferepr 11 | from _pytest.assertion.util import _compare_eq_any 12 | 13 | if TYPE_CHECKING: 14 | from _pytest.config import Config 15 | 16 | 17 | class UnorderedList(list): 18 | def __init__(self, expected: Iterable, *, check_type: bool = True) -> None: 19 | if not isinstance(expected, Iterable): 20 | msg = f"cannot make unordered comparisons to non-iterable: {expected!r}" 21 | raise TypeError( 22 | msg, 23 | ) 24 | if isinstance(expected, Mapping): 25 | msg = f"cannot make unordered comparisons to mapping: {expected!r}" 26 | raise TypeError(msg) 27 | super().__init__(expected) 28 | self.expected_type = type(expected) if check_type else None 29 | 30 | def __eq__(self, actual: object) -> bool: 31 | if self.expected_type is not None and self.expected_type is not type(actual): 32 | return False 33 | if not isinstance(actual, Iterable): 34 | return self.copy() == actual 35 | actual_list = list(actual) 36 | if len(actual_list) != len(self): 37 | return False 38 | extra_left, extra_right = self.compare_to(actual_list) 39 | return not extra_left and not extra_right 40 | 41 | def __ne__(self, actual: object) -> bool: 42 | return not (self == actual) 43 | 44 | def compare_to(self, other: list) -> tuple[list, list]: 45 | extra_left = list(self) 46 | extra_right: list[Any] = [] 47 | reordered: list[Any] = [] 48 | placeholder = object() 49 | for elem in other: 50 | if elem in extra_left: 51 | i = extra_left.index(elem) 52 | reordered.append(extra_left.pop(i)) 53 | else: 54 | extra_right.append(elem) 55 | reordered.append(placeholder) 56 | placeholder_fillers = extra_left.copy() 57 | for i, elem in reversed(list(enumerate(reordered))): 58 | if not placeholder_fillers: 59 | break 60 | if elem == placeholder: 61 | reordered[i] = placeholder_fillers.pop() 62 | self[:] = [e for e in reordered if e is not placeholder] 63 | return extra_left, extra_right 64 | 65 | 66 | def unordered(*args: Any, check_type: bool | None = None) -> UnorderedList: 67 | if len(args) == 1: 68 | if check_type is None: 69 | check_type = not isinstance(args[0], Generator) 70 | return UnorderedList(args[0], check_type=check_type) 71 | return UnorderedList(args, check_type=False) 72 | 73 | 74 | def unordered_deep(obj: Any) -> Any: 75 | if isinstance(obj, dict): 76 | return {k: unordered_deep(v) for k, v in obj.items()} 77 | if isinstance(obj, (list, tuple)): 78 | return unordered(unordered_deep(x) for x in obj) 79 | return obj 80 | 81 | 82 | def _compare_eq_unordered(left: Iterable, right: Iterable) -> tuple[list, list]: 83 | extra_left: list[Any] = [] 84 | extra_right = list(right) 85 | for elem in left: 86 | if elem in extra_right: 87 | extra_right.remove(elem) 88 | else: 89 | extra_left.append(elem) 90 | return extra_left, extra_right 91 | 92 | 93 | def pytest_assertrepr_compare( 94 | config: Config, 95 | op: str, 96 | left: Any, 97 | right: Any, 98 | ) -> list[str] | None: 99 | if (isinstance(left, UnorderedList) or isinstance(right, UnorderedList)) and op == "==": 100 | verbose = config.getoption("verbose") 101 | left_repr = saferepr(left) 102 | right_repr = saferepr(right) 103 | result = [f"{left_repr} {op} {right_repr}"] 104 | left_type = left.expected_type if isinstance(left, UnorderedList) else type(left) 105 | right_type = right.expected_type if isinstance(right, UnorderedList) else type(right) 106 | if left_type and right_type and left_type != right_type: 107 | result.append("Type mismatch:") 108 | result.append(f"{left_type} != {right_type}") 109 | extra_left, extra_right = _compare_eq_unordered(left, right) 110 | if len(extra_left) == 1 and len(extra_right) == 1: 111 | result.append("One item replaced:") 112 | if pytest.version_tuple < (8, 0, 0): # pragma: no cover 113 | result.extend( 114 | _compare_eq_any(extra_left[0], extra_right[0], verbose=verbose), # type: ignore[call-arg] 115 | ) 116 | else: 117 | result.extend( 118 | _compare_eq_any( 119 | extra_left[0], 120 | extra_right[0], 121 | highlighter=config.get_terminal_writer()._highlight, # noqa: SLF001 122 | verbose=verbose, 123 | ), 124 | ) 125 | else: 126 | if extra_left: 127 | result.append("Extra items in the left sequence:") 128 | result.extend(saferepr(item) for item in extra_left) 129 | if extra_right: 130 | result.append("Extra items in the right sequence:") 131 | result.extend(saferepr(item) for item in extra_right) 132 | return result 133 | return None 134 | -------------------------------------------------------------------------------- /pytest_unordered/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utapyngo/pytest-unordered/9552c8ad921190821cf45aff8bcfdb31343b1a01/pytest_unordered/py.typed -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from pathlib import Path 4 | 5 | from setuptools import setup 6 | 7 | 8 | def read(fname: str) -> str: 9 | return (Path(__file__).parent / fname).read_text(encoding="utf-8") 10 | 11 | 12 | setup( 13 | name="pytest-unordered", 14 | version="0.7.0", 15 | author="Ivan Zaikin", 16 | author_email="ut@pyngo.tom.ru", 17 | maintainer="Ivan Zaikin", 18 | maintainer_email="ut@pyngo.tom.ru", 19 | license="MIT", 20 | url="https://github.com/utapyngo/pytest-unordered", 21 | description="Test equality of unordered collections in pytest", 22 | long_description=read("README.md"), 23 | long_description_content_type="text/markdown", 24 | packages=["pytest_unordered"], 25 | package_data={"pytest_unordered": ["py.typed"]}, 26 | install_requires=["pytest>=7.0.0"], 27 | classifiers=[ 28 | "Development Status :: 4 - Beta", 29 | "Framework :: Pytest", 30 | "Intended Audience :: Developers", 31 | "License :: OSI Approved :: MIT License", 32 | "Operating System :: OS Independent", 33 | "Programming Language :: Python", 34 | "Programming Language :: Python :: 3", 35 | "Programming Language :: Python :: 3 :: Only", 36 | "Programming Language :: Python :: 3.8", 37 | "Programming Language :: Python :: 3.9", 38 | "Programming Language :: Python :: 3.10", 39 | "Programming Language :: Python :: 3.11", 40 | "Programming Language :: Python :: 3.12", 41 | "Programming Language :: Python :: 3.13", 42 | "Programming Language :: Python :: Implementation :: PyPy", 43 | "Topic :: Software Development :: Libraries", 44 | "Topic :: Software Development :: Testing", 45 | "Topic :: Software Development :: Quality Assurance", 46 | "Topic :: Utilities", 47 | "Typing :: Typed", 48 | ], 49 | entry_points={"pytest11": ["unordered = pytest_unordered"]}, 50 | ) 51 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utapyngo/pytest-unordered/9552c8ad921190821cf45aff8bcfdb31343b1a01/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | pytest_plugins = "pytester" 2 | -------------------------------------------------------------------------------- /tests/test_unordered.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import collections 4 | from typing import TYPE_CHECKING 5 | from typing import Any 6 | from unittest.mock import ANY 7 | 8 | import pytest 9 | 10 | from pytest_unordered import UnorderedList 11 | from pytest_unordered import _compare_eq_unordered 12 | from pytest_unordered import unordered 13 | from pytest_unordered import unordered_deep 14 | 15 | if TYPE_CHECKING: 16 | from collections.abc import Iterable 17 | from collections.abc import Mapping 18 | 19 | from _pytest.pytester import Pytester 20 | 21 | 22 | @pytest.mark.parametrize( 23 | ("expected", "actual"), 24 | [ 25 | (unordered(1, 2, 3), [3, 2, 1]), 26 | (unordered(1, 2, 3), (3, 2, 1)), 27 | (unordered(1, 2, 3), {3, 2, 1}), 28 | (unordered([1, 2, 3]), [3, 2, 1]), 29 | (unordered((1, 2, 3)), (3, 2, 1)), 30 | (unordered({1, 2, 3}), {3, 2, 1}), 31 | (unordered(1, 2, {"a": unordered(4, 5, 6)}), [{"a": [6, 5, 4]}, 2, 1]), 32 | (unordered([{1: unordered(["a", "b"])}, 2, 3]), [3, 2, {1: ["b", "a"]}]), 33 | (unordered(x for x in range(3)), [2, 1, 0]), 34 | (unordered(x for x in range(3)), (2, 1, 0)), 35 | (unordered(x for x in range(3)), {2, 1, 0}), 36 | (unordered(x for x in range(3)), range(3)), 37 | (unordered("abc"), "bac"), 38 | (unordered("a", "b", "c"), ["b", "a", "c"]), 39 | (unordered("a", "b", "c"), "bac"), 40 | ], 41 | ) 42 | def test_unordered(expected: UnorderedList, actual: Iterable) -> None: 43 | assert expected == actual 44 | assert actual == expected 45 | assert not (expected != actual) 46 | assert not (actual != expected) 47 | 48 | 49 | @pytest.mark.parametrize( 50 | ("left", "right"), 51 | [ 52 | (unordered(2, 1, 0), (x for x in range(3))), 53 | ((x for x in range(3)), unordered(2, 1, 0)), 54 | (unordered(x for x in range(3)), (2, 1, 0)), 55 | ((2, 1, 0), unordered(x for x in range(3))), 56 | (unordered("a", "b", "c"), (x for x in "bac")), 57 | ((x for x in "bac"), unordered("a", "b", "c")), 58 | ], 59 | ) 60 | def test_unordered_generators(left: Iterable, right: Iterable) -> None: 61 | # Because general generators can only be consumed once, 62 | # we can only do one assert 63 | assert left == right 64 | 65 | 66 | @pytest.mark.parametrize( 67 | ("expected", "actual"), 68 | [ 69 | (unordered([1, 2, 3]), [1, 2, 3, 4]), 70 | (unordered([1, 2, 3]), [1, 2, 3, 1]), 71 | (unordered([1, 2, 3]), (1, 2, 3)), 72 | (unordered([1, 2, 3]), {1, 2, 3}), 73 | (unordered([1, 2, 3]), (x + 1 for x in range(3))), 74 | (unordered((1, 2, 3)), (1, 2, 3, 4)), 75 | (unordered((1, 2, 3)), (1, 2, 3, 1)), 76 | (unordered((1, 2, 3)), [1, 2, 3]), 77 | (unordered((1, 2, 3)), {1, 2, 3}), 78 | (unordered((1, 2, 3)), (x + 1 for x in range(3))), 79 | (unordered({1, 2, 3}), {1, 2, 3, 4}), 80 | (unordered({1, 2, 3}), [1, 2, 3]), 81 | (unordered({1, 2, 3}), (1, 2, 3)), 82 | (unordered({1, 2, 3}), (x + 1 for x in range(3))), 83 | (unordered("abc"), ["b", "a", "c"]), 84 | (unordered("abc"), ("b", "a", "c")), 85 | (unordered("abc"), {"b", "a", "c"}), 86 | ], 87 | ) 88 | def test_unordered_reject(expected: UnorderedList, actual: Iterable) -> None: 89 | assert expected != actual 90 | assert actual != expected 91 | assert not (expected == actual) 92 | assert not (actual == expected) 93 | 94 | 95 | @pytest.mark.parametrize("value", [None, True, 42, object(), type, TypeError]) 96 | def test_non_sized_expected(value: Any) -> None: 97 | with pytest.raises(TypeError, match="cannot make unordered comparisons to non-iterable"): 98 | UnorderedList(value) 99 | 100 | 101 | @pytest.mark.parametrize("value", [None, True, 42, object(), type, TypeError]) 102 | def test_non_iterable_actual(value: Any) -> None: 103 | assert not (unordered(1, 2, 3) == value) 104 | assert not (value == unordered(1, 2, 3)) 105 | 106 | 107 | @pytest.mark.parametrize( 108 | "value", 109 | [ 110 | {1: 2, 3: 4}, 111 | collections.defaultdict(int, a=5), 112 | collections.OrderedDict({1: 2, 3: 4}), 113 | collections.Counter("count this"), 114 | ], 115 | ) 116 | def test_mapping_expected(value: Mapping) -> None: 117 | with pytest.raises(TypeError, match="cannot make unordered comparisons to mapping"): 118 | unordered(value) 119 | 120 | 121 | @pytest.mark.parametrize("value", [None, type, TypeError]) 122 | def test_compare_to_non_sequence(value: Any) -> None: 123 | assert not unordered("x") == value 124 | assert unordered("x") != value 125 | 126 | 127 | def test_check_type() -> None: 128 | assert not unordered([1]) == {1} 129 | assert not unordered([1], check_type=True) == {1} 130 | assert unordered([1], check_type=False) == {1} 131 | 132 | 133 | @pytest.mark.parametrize( 134 | ("left", "right", "extra_left", "extra_right"), 135 | [ 136 | ([1, 2, 3], [1, 2, 3, 4, 5], [], [4, 5]), 137 | ([3, 2, 1], [1, 2, 3, 4, 5], [], [4, 5]), 138 | ([3, 2, {1: ["a", "b"]}], [{1: ["a", "b"]}, 2, 3, 4, 5], [], [4, 5]), 139 | ([3, 2, {1: ["a", "b"]}], [{1: unordered("b", "a")}, 2, 3, 4, 5], [], [4, 5]), 140 | ], 141 | ) 142 | def test_compare_eq_unordered( 143 | left: Iterable, 144 | right: Iterable, 145 | extra_left: list, 146 | extra_right: list, 147 | ) -> None: 148 | assert _compare_eq_unordered(left, right) == (extra_left, extra_right) 149 | 150 | 151 | def test_len() -> None: 152 | assert len(unordered({1: ["a", "b"]}, 2, 3, 4, 5)) == 5 153 | 154 | 155 | def test_fail_nonunique_left(pytester: Pytester) -> None: 156 | pytester.makepyfile( 157 | """ 158 | from pytest_unordered import unordered 159 | 160 | def test_unordered(): 161 | assert unordered(1, 2, 3, 3) == [1, 2, 3] 162 | """, 163 | ) 164 | result = pytester.runpytest() 165 | result.assert_outcomes(failed=1, passed=0) 166 | result.stdout.fnmatch_lines( 167 | [ 168 | "E Extra items in the left sequence:", 169 | "E 3", 170 | ], 171 | ) 172 | 173 | 174 | def test_fail_nonunique_right(pytester: Pytester) -> None: 175 | pytester.makepyfile( 176 | """ 177 | from pytest_unordered import unordered 178 | 179 | def test_unordered(): 180 | assert [1, 2, 3] == unordered(1, 2, 3, 3) 181 | """, 182 | ) 183 | result = pytester.runpytest() 184 | result.assert_outcomes(failed=1, passed=0) 185 | result.stdout.fnmatch_lines( 186 | [ 187 | "E Extra items in the right sequence:", 188 | "E 3", 189 | ], 190 | ) 191 | 192 | 193 | def test_replace(pytester: Pytester) -> None: 194 | pytester.makepyfile( 195 | """ 196 | from pytest_unordered import unordered 197 | 198 | def test_unordered(): 199 | assert [{"a": 1, "b": 2}, 2, 3] == unordered(2, 3, {"b": 2, "a": 3}) 200 | """, 201 | ) 202 | result = pytester.runpytest() 203 | result.assert_outcomes(failed=1, passed=0) 204 | result.stdout.fnmatch_lines( 205 | [ 206 | "E One item replaced:", 207 | "E Omitting 1 identical items, use -vv to show", 208 | "E Differing items:", 209 | "E {'a': 1} != {'a': 3}", 210 | ], 211 | ) 212 | 213 | 214 | def test_in(pytester: Pytester) -> None: 215 | pytester.makepyfile( 216 | """ 217 | from pytest_unordered import unordered 218 | 219 | def test_unordered(): 220 | assert 1 in unordered(2, 3) 221 | """, 222 | ) 223 | result = pytester.runpytest() 224 | result.assert_outcomes(failed=1, passed=0) 225 | result.stdout.fnmatch_lines( 226 | [ 227 | "E assert 1 in [2, 3]", 228 | "E + where [2, 3] = unordered(2, 3)", 229 | ], 230 | ) 231 | 232 | 233 | def test_type_check(pytester: Pytester) -> None: 234 | pytester.makepyfile( 235 | """ 236 | from pytest_unordered import unordered 237 | 238 | def test_unordered(): 239 | assert [3, 2, 1] == unordered((1, 2, 3)) 240 | """, 241 | ) 242 | result = pytester.runpytest() 243 | result.assert_outcomes(failed=1, passed=0) 244 | result.stdout.fnmatch_lines( 245 | [ 246 | "E Type mismatch:", 247 | "E != ", 248 | ], 249 | ) 250 | 251 | 252 | def test_reorder_on_eq() -> None: 253 | unordered_list = unordered([1, 2, 3]) 254 | assert unordered_list == [3, 1, 2] 255 | assert list(unordered_list) == [3, 1, 2] 256 | 257 | 258 | def test_mock_any() -> None: 259 | p_unordered = {"results": unordered({"foo1": ANY}, {"foo2": ANY})} 260 | test_1 = {"results": [{"foo1": "value10"}, {"foo2": "value20"}]} 261 | test_2 = {"results": [{"foo1": "value11"}, {"foo2": "value21"}]} 262 | test_3 = {"results": [{"foo1": "value10"}, {"foo2": "value20"}]} 263 | assert p_unordered == test_1 264 | assert p_unordered == test_2 265 | assert p_unordered == test_3 266 | 267 | 268 | @pytest.mark.parametrize( 269 | ("expected", "actual"), 270 | [ 271 | (unordered_deep([1, 2, 3]), [3, 2, 1]), 272 | (unordered_deep((1, 2, 3)), (3, 2, 1)), 273 | (unordered_deep({1, 2, 3}), {3, 2, 1}), 274 | (unordered_deep([1, 2, {"a": (4, 5, 6)}]), [{"a": [6, 5, 4]}, 2, 1]), # fmt: skip 275 | (unordered_deep([{1: (["a", "b"])}, 2, 3]), [3, 2, {1: ["b", "a"]}]), # fmt: skip 276 | (unordered_deep(("a", "b", "c")), ["b", "a", "c"]), 277 | ], 278 | ) 279 | def test_unordered_deep(expected: UnorderedList, actual: Iterable) -> None: 280 | assert expected == actual 281 | assert actual == expected 282 | assert not (expected != actual) 283 | assert not (actual != expected) 284 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py38-{pytest7,pytest8}, 4 | {py39,py310,py311,py312,py313,pypy3}-{pytest7,pytest8,pytestlatest}, 5 | pre-commit 6 | 7 | [testenv] 8 | commands = 9 | coverage run --branch --source=pytest_unordered -m pytest tests/ {posargs} 10 | deps = 11 | coverage 12 | codecov 13 | pytest7: pytest>=7.4.4,<8 14 | pytest8: pytest>=8.1.1,<9 15 | pytestlatest: pytest 16 | 17 | [testenv:pre-commit] 18 | skip_install = true 19 | deps = pre-commit 20 | commands = pre-commit run --all-files 21 | --------------------------------------------------------------------------------