pax_global_header00006660000000000000000000000064142310316130014504gustar00rootroot0000000000000052 comment=2b0268cd26f8c72303b320e27c553583d64c5c11 pytest-snapshot-0.9.0/000077500000000000000000000000001423103161300146775ustar00rootroot00000000000000pytest-snapshot-0.9.0/.coveragerc000066400000000000000000000001301423103161300170120ustar00rootroot00000000000000[run] branch = 1 [report] include = pytest_snapshot/* tests/* skip_covered = 1 pytest-snapshot-0.9.0/.github/000077500000000000000000000000001423103161300162375ustar00rootroot00000000000000pytest-snapshot-0.9.0/.github/workflows/000077500000000000000000000000001423103161300202745ustar00rootroot00000000000000pytest-snapshot-0.9.0/.github/workflows/CI.yml000066400000000000000000000035471423103161300213230ustar00rootroot00000000000000# This workflow will run tox parallelized over Python versions. # For more information see: https://hynek.me/articles/python-github-actions/ name: CI on: push: schedule: - cron: '0 4 * * *' jobs: tests: name: Python ${{ matrix.python-version }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} continue-on-error: ${{ matrix.experimental }} strategy: fail-fast: false matrix: os: [ubuntu-latest] python-version: [3.5, 3.6, 3.7, 3.8, 3.9, 3.10 - 4.0.0-alpha, pypy3] experimental: [false] include: # To save resources, we only test the latest version (and 3.9 for pytest <6) - python-version: 3.9 os: windows-latest experimental: false - python-version: 3.10 - 4.0.0-alpha os: windows-latest experimental: false - python-version: 3.9 os: macos-latest # macos builds sometimes get stuck starting "python -m tox". experimental: true - python-version: 3.10 - 4.0.0-alpha os: macos-latest # macos builds sometimes get stuck starting "python -m tox". experimental: true steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -VV python -m site python -m pip install --upgrade pip setuptools wheel python -m pip install --upgrade coverage[toml] virtualenv tox tox-gh-actions - name: Run tox targets for ${{ matrix.python-version }} run: python -m tox - name: Upload coverage report uses: codecov/codecov-action@v2 with: directory: coverage-reports name: ${{ matrix.python-version }} on ${{ matrix.os }} pytest-snapshot-0.9.0/.github/workflows/pythonpublish.yml000066400000000000000000000015271423103161300237340ustar00rootroot00000000000000# This workflow will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Python Package on: release: types: [created] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v2 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install --upgrade build setuptools setuptools-scm wheel twine - name: Build and publish env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} run: | python -m build twine upload dist/* pytest-snapshot-0.9.0/.gitignore000066400000000000000000000016731423103161300166760ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg pip-wheel-metadata/ # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* coverage-reports/ .cache nosetests.xml coverage.xml *,cover .hypothesis/ .pytest_cache # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask instance folder instance/ # Sphinx documentation docs/_build/ # MkDocs documentation /site/ # PyBuilder target/ # IPython Notebook .ipynb_checkpoints # pyenv .python-version pytest_snapshot/_version.py pytest-snapshot-0.9.0/LICENSE000066400000000000000000000020721423103161300157050ustar00rootroot00000000000000 The MIT License (MIT) Copyright (c) 2020 Joseph Roitman Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. pytest-snapshot-0.9.0/MANIFEST.in000066400000000000000000000001411423103161300164310ustar00rootroot00000000000000include LICENSE include README.rst recursive-exclude * __pycache__ recursive-exclude * *.py[co] pytest-snapshot-0.9.0/README.rst000066400000000000000000000202021423103161300163620ustar00rootroot00000000000000=============== pytest-snapshot =============== .. image:: https://img.shields.io/pypi/v/pytest-snapshot.svg :target: https://pypi.org/project/pytest-snapshot :alt: PyPI version .. image:: https://img.shields.io/pypi/pyversions/pytest-snapshot.svg :target: https://pypi.org/project/pytest-snapshot :alt: Python versions .. image:: https://github.com/joseph-roitman/pytest-snapshot/workflows/CI/badge.svg?branch=master :target: https://github.com/joseph-roitman/pytest-snapshot/actions?workflow=CI :alt: CI Status .. image:: https://img.shields.io/codecov/c/github/joseph-roitman/pytest-snapshot.svg?style=flat :alt: Coverage :target: https://codecov.io/gh/joseph-roitman/pytest-snapshot A plugin for snapshot testing with pytest. This library was inspired by `jest's snapshot testing`_. Snapshot testing can be used to test that the value of an expression does not change unexpectedly. The added benefits of snapshot testing are that * They are easy to create. * They are easy to update when the expected value of a test changes. Instead of manually updating tests when the expected value of an expression changes, the developer simply needs to 1. run ``pytest --snapshot-update`` to update the snapshot tests 2. verify that the snapshot files contain the new expected results 3. commit the snapshot changes to version control Features -------- * snapshot testing of strings and bytes * snapshot testing of (optionally nested) collections of strings and bytes * complete control of the snapshot file path and content Requirements ------------ * Python 3.5+ or `PyPy`_ * `pytest`_ 3.0+ Installation ------------ You can install "pytest-snapshot" via `pip`_ from `PyPI`_:: $ pip install pytest-snapshot Usage ----- assert_match ============ A classic equality test looks like: .. code-block:: python def test_function_output(): assert foo('function input') == 'expected result' It could be re-written using snapshot testing as: .. code-block:: python def test_function_output_with_snapshot(snapshot): snapshot.snapshot_dir = 'snapshots' # This line is optional. snapshot.assert_match(foo('function input'), 'foo_output.txt') The author of the test should then 1. run ``pytest --snapshot-update`` to generate the snapshot file ``snapshots/foo_output.txt`` containing the output of ``foo()``. 2. verify that the content of the snapshot file is valid. 3. commit it to version control. Now, whenever the test is run, it will assert that the output of ``foo()`` is equal to the snapshot. What if the behaviour of ``foo()`` changes and the test starts to fail? In the first example, the developer would need to manually update the expected result in ``test_function_output``. This could be tedious if the expected result is large or there are many tests. In the second example, the developer would simply 1. run ``pytest --snapshot-update`` 2. verify that the snapshot file contains the new expected result 3. commit it to version control. Snapshot testing can be used for expressions whose values are strings or bytes. For other types, you should first create a *human readable* representation of the value. For example, to snapshot test a *json-serializable* value, you could either convert it into json or preferably convert it into the more readable yaml format using `PyYAML`_: .. code-block:: python snapshot.assert_match(yaml.dump(foo()), 'foo_output.yml') assert_match_dir ================ When snapshot testing a *collection* of values, ``assert_match_dir`` comes in handy. It will save a snapshot of a collection of values as a directory of snapshot files. ``assert_match_dir`` takes a dictionary from file name to value. Dictionaries can also be nested to create nested directories containing snapshot files. For example, the following code creates the directory ``snapshots/people`` containing files ``john.json`` and ``jane.json``. .. code-block:: python def test_something(snapshot): snapshot.snapshot_dir = 'snapshots' snapshot.assert_match_dir({ 'john.json': '{"first name": "John", "last name": "Doe"}', 'jane.json': '{"first name": "Jane", "last name": "Doe"}', }, 'people') When running ``pytest --snapshot-update``, snapshot files will be added, updated, or deleted as necessary. As a safety measure, snapshots will only be deleted when using the ``--allow-snapshot-deletion`` flag. Common use case =============== A quick way to create snapshot tests is to create a directory containing many test case directories. In each test case, add files containing the inputs to the function you wish to test. For example: .. code-block:: test_cases case1 input.json case2 input.json ... Next, add a test that is parametrized on all test case directories. The test should * read input from the test case directory * call the function to be tested * snapshot the result to the test case directory .. code-block:: python import json import os import pytest import yaml from pathlib import Path def json_to_yaml(json_string): obj = json.loads(json_string) return yaml.dump(obj, indent=2) @pytest.mark.parametrize('case_dir', list(Path('test_cases').iterdir())) def test_json(case_dir, snapshot): # Read input files from the case directory. input_json = case_dir.joinpath('input.json').read_text() # Call the tested function. output_yaml = json_to_yaml(input_json) # Snapshot the return value. snapshot.snapshot_dir = case_dir snapshot.assert_match(output_yaml, 'output.yml') Now, we can run ``pytest --snapshot-update`` to create an ``output.yml`` snapshot for each test case. If we later decide to modify the tested function's behaviour, we can fix the test cases with another ``pytest --snapshot-update``. Similar Packages ---------------- Another python package that can be used for snapshot testing is `snapshottest`_. While this package and snapshottest fulfill the same role, there are some differences. With pytest-snapshot: * Every snapshot is saved to a separate file. * The paths to snapshot files are fully customizable. * The serialization of objects to snapshots is fully customizable (the library does not serialize). This allows the user to organize snapshots in the most human-readable and logical place in their code repository. This is highly beneficial since snapshots will be viewed by users many times during development and code reviews. Contributing ------------ Contributions are very welcome. Before contributing, please discuss the change with me. I wish to keep this plugin flexible and not enforce any project layout on the user. Tests can be run with `tox`_ or ``python -m pytest``. Note that the test suite does not pass when run with ``--assert=plain``. License ------- Distributed under the terms of the `MIT`_ license, "pytest-snapshot" is free and open source software. Issues ------ If you encounter any problems, please `file an issue`_ along with a detailed description. Links ----- * Releases: https://pypi.org/project/pytest-snapshot/ * Code: https://github.com/joseph-roitman/pytest-snapshot ---- This `pytest`_ plugin was generated with `Cookiecutter`_ along with `@hackebrot`_'s `cookiecutter-pytest-plugin`_ template. .. _`Cookiecutter`: https://github.com/audreyr/cookiecutter .. _`@hackebrot`: https://github.com/hackebrot .. _`MIT`: http://opensource.org/licenses/MIT .. _`BSD-3`: http://opensource.org/licenses/BSD-3-Clause .. _`GNU GPL v3.0`: http://www.gnu.org/licenses/gpl-3.0.txt .. _`Apache Software License 2.0`: http://www.apache.org/licenses/LICENSE-2.0 .. _`cookiecutter-pytest-plugin`: https://github.com/pytest-dev/cookiecutter-pytest-plugin .. _`file an issue`: https://github.com/joseph-roitman/pytest-snapshot/issues .. _`pytest`: https://github.com/pytest-dev/pytest .. _`tox`: https://tox.readthedocs.io/en/latest/ .. _`pip`: https://pypi.org/project/pip/ .. _`PyPI`: https://pypi.org .. _`PyPy`: https://www.pypy.org/ .. _`jest's snapshot testing`: https://jestjs.io/docs/en/snapshot-testing .. _`PyYAML`: https://pypi.org/project/PyYAML/ .. _`snapshottest`: https://github.com/syrusakbary/snapshottest pytest-snapshot-0.9.0/pyproject.toml000066400000000000000000000001731423103161300176140ustar00rootroot00000000000000[build-system] requires = [ "setuptools>=40.0", "setuptools-scm", "wheel", ] build-backend = "setuptools.build_meta" pytest-snapshot-0.9.0/pytest_snapshot/000077500000000000000000000000001423103161300201465ustar00rootroot00000000000000pytest-snapshot-0.9.0/pytest_snapshot/__init__.py000066400000000000000000000002671423103161300222640ustar00rootroot00000000000000try: from ._version import version as __version__ except ImportError: # broken installation, we don't even try (copied from pytest implementation) __version__ = "unknown" pytest-snapshot-0.9.0/pytest_snapshot/_utils.py000066400000000000000000000071551423103161300220270ustar00rootroot00000000000000import os import re from pathlib import Path import pytest SIMPLE_VERSION_REGEX = re.compile(r'([0-9]+)\.([0-9]+)\.([0-9]+)') ILLEGAL_FILENAME_CHARS = r'\/:*?"<>|' def shorten_path(path: Path) -> Path: """ Returns the path relative to the current working directory if possible. Otherwise return the path unchanged. """ try: return path.relative_to(os.getcwd()) except ValueError: return path def get_valid_filename(s: str) -> str: """ Return the given string converted to a string that can be used for a clean filename. Taken from https://github.com/django/django/blob/master/django/utils/text.py """ s = str(s).strip().replace(' ', '_') s = re.sub(r'(?u)[^-\w.]', '', s) s = {'': 'empty', '.': 'dot', '..': 'dotdot'}.get(s, s) return s def might_be_valid_filename(s: str) -> bool: """ Returns false if the given string is definitely a path traversal or not a valid filename. Returns true if the string might be a valid filename. Note: This isn't secure, it just catches most accidental path traversals or invalid filenames. """ return not ( len(s) == 0 or s == '.' or s == '..' or any(c in s for c in ILLEGAL_FILENAME_CHARS) ) def simple_version_parse(version: str): """ Returns a 3 tuple of the versions major, minor, and patch. Raises a value error if the version string is unsupported. """ match = SIMPLE_VERSION_REGEX.match(version) if match is None: raise ValueError('Unsupported version format') return tuple(int(part) for part in match.groups()) def _pytest_expected_on_right() -> bool: """ Returns true if pytest prints string diffs correctly for: assert tested_value == expected_value Returns false if pytest prints string diffs correctly for: assert expected_value == tested_value """ # pytest diffs before version 5.4.0 assumed expected to be on the left hand side. try: pytest_version = simple_version_parse(pytest.__version__) except ValueError: return True else: return pytest_version >= (5, 4, 0) def flatten_dict(d: dict): """ Returns the flattened dict representation of the given dict. Example: >>> flatten_dict({ ... 'a': 1, ... 'b': { ... 'c': 2 ... }, ... 'd': {}, ... }) [(['a'], 1), (['b', 'c'], 2)] """ assert type(d) is dict result = [] _flatten_dict(d, result, []) return result def _flatten_dict(obj, result, prefix): if type(obj) is dict: for k, v in obj.items(): prefix.append(k) _flatten_dict(v, result, prefix) prefix.pop() else: result.append((list(prefix), obj)) def flatten_filesystem_dict(d): """ Returns the flattened dict of a nested dictionary structure describing a filesystem. Raises ``ValueError`` if any of the dictionary keys are invalid filenames. Example: >>> flatten_filesystem_dict({ ... 'file1.txt': '111', ... 'dir1': { ... 'file2.txt': '222' ... }, ... }) {'file1.txt': '111', 'dir1/file2.txt': '222'} """ result = {} for key_list, obj in flatten_dict(d): for i, key in enumerate(key_list): if not might_be_valid_filename(key): key_list_str = ''.join('[{!r}]'.format(k) for k in key_list[:i]) raise ValueError('Key {!r} in d{} must be a valid file name.'.format(key, key_list_str)) result['/'.join(key_list)] = obj return result pytest-snapshot-0.9.0/pytest_snapshot/plugin.py000066400000000000000000000267421423103161300220310ustar00rootroot00000000000000import operator import os import re from pathlib import Path from typing import List, Union import pytest import _pytest.python from pytest_snapshot._utils import shorten_path, get_valid_filename, _pytest_expected_on_right, flatten_filesystem_dict PARAMETRIZED_TEST_REGEX = re.compile(r'^.*?\[(.*)]$') def pytest_addoption(parser): group = parser.getgroup('snapshot') group.addoption( '--snapshot-update', action='store_true', help='Update snapshot files instead of testing against them.', ) group.addoption( '--allow-snapshot-deletion', action='store_true', help='Allow snapshot deletion when updating snapshots.', ) @pytest.fixture def snapshot(request): default_snapshot_dir = _get_default_snapshot_dir(request.node) with Snapshot(request.config.option.snapshot_update, request.config.option.allow_snapshot_deletion, default_snapshot_dir) as snapshot: yield snapshot def _assert_equal(value, snapshot) -> None: if _pytest_expected_on_right(): assert value == snapshot else: assert snapshot == value def _file_encode(string: str) -> bytes: """ Returns the bytes that would be in a file created using ``path.write_text(string)``. See universal newlines documentation. """ if '\r' in string: raise ValueError('''\ Snapshot testing strings containing "\\r" is not supported. To snapshot test non-standard newlines you should convert the tested value to bytes. Warning: git may decide to modify the newlines in the snapshot file. To avoid this read \ https://docs.github.com/en/get-started/getting-started-with-git/configuring-git-to-handle-line-endings''') return string.replace('\n', os.linesep).encode() def _file_decode(data: bytes) -> str: """ Returns the string that would be read from a file using ``path.read_text(string)``. See universal newlines documentation. """ return data.decode().replace('\r\n', '\n').replace('\r', '\n') class Snapshot: _snapshot_update = None # type: bool _allow_snapshot_deletion = None # type: bool _created_snapshots = None # type: List[Path] _updated_snapshots = None # type: List[Path] _snapshots_to_delete = None # type: List[Path] _snapshot_dir = None # type: Path def __init__(self, snapshot_update: bool, allow_snapshot_deletion: bool, snapshot_dir: Path): self._snapshot_update = snapshot_update self._allow_snapshot_deletion = allow_snapshot_deletion self.snapshot_dir = snapshot_dir self._created_snapshots = [] self._updated_snapshots = [] self._snapshots_to_delete = [] def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): if self._created_snapshots or self._updated_snapshots or self._snapshots_to_delete: message_lines = ['Snapshot directory was modified: {}'.format(shorten_path(self.snapshot_dir)), ' (verify that the changes are expected before committing them to version control)'] if self._created_snapshots: message_lines.append(' Created snapshots:') message_lines.extend(' ' + str(s.relative_to(self.snapshot_dir)) for s in self._created_snapshots) if self._updated_snapshots: message_lines.append(' Updated snapshots:') message_lines.extend(' ' + str(s.relative_to(self.snapshot_dir)) for s in self._updated_snapshots) if self._snapshots_to_delete: if self._allow_snapshot_deletion: for path in self._snapshots_to_delete: path.unlink() message_lines.append(' Deleted snapshots:') else: message_lines.append(' Snapshots that should be deleted: ' '(run pytest with --allow-snapshot-deletion to delete them)') message_lines.extend(' ' + str(s.relative_to(self.snapshot_dir)) for s in self._snapshots_to_delete) pytest.fail('\n'.join(message_lines), pytrace=False) @property def snapshot_dir(self): return self._snapshot_dir @snapshot_dir.setter def snapshot_dir(self, value): self._snapshot_dir = Path(value).absolute() def _snapshot_path(self, snapshot_name: Union[str, Path]) -> Path: """ Returns the absolute path to the given snapshot. """ if isinstance(snapshot_name, Path): snapshot_path = snapshot_name.absolute() else: snapshot_path = self.snapshot_dir.joinpath(snapshot_name) # TODO: snapshot_path = snapshot_path.resolve(strict=False). Requires Python >3.6 for strict=False. if self.snapshot_dir not in snapshot_path.parents: raise ValueError('Snapshot path {} is not in {}'.format( shorten_path(snapshot_path), shorten_path(self.snapshot_dir))) return snapshot_path def _get_compare_encode_decode(self, value: Union[str, bytes]): """ Returns a 3-tuple of a compare function, an encoding function, and a decoding function. * The compare function should compare the object to the value of its snapshot, raising an AssertionError with a useful error message if they are different. * The encoding function should encode the value into bytes for saving to a snapshot file. * The decoding function should decode bytes from a snapshot file into a object. """ if isinstance(value, str): return _assert_equal, _file_encode, _file_decode elif isinstance(value, bytes): return _assert_equal, lambda x: x, lambda x: x else: raise TypeError('value must be str or bytes') def assert_match(self, value: Union[str, bytes], snapshot_name: Union[str, Path]): """ Asserts that ``value`` equals the current value of the snapshot with the given ``snapshot_name``. If pytest was run with the --snapshot-update flag, the snapshot will instead be updated to ``value``. The test will fail if there were any changes to the snapshot. """ __tracebackhide__ = operator.methodcaller("errisinstance", AssertionError) compare, encode, decode = self._get_compare_encode_decode(value) snapshot_path = self._snapshot_path(snapshot_name) if snapshot_path.is_file(): encoded_expected_value = snapshot_path.read_bytes() elif snapshot_path.exists(): raise AssertionError('snapshot exists but is not a file: {}'.format(shorten_path(snapshot_path))) else: encoded_expected_value = None if self._snapshot_update: encoded_value = encode(value) if encoded_expected_value is None or encoded_value != encoded_expected_value: decoded_encoded_value = decode(encoded_value) if decoded_encoded_value != value: raise ValueError("value is not supported by pytest-snapshot's serializer.") snapshot_path.parent.mkdir(parents=True, exist_ok=True) snapshot_path.write_bytes(encoded_value) if encoded_expected_value is None: self._created_snapshots.append(snapshot_path) else: self._updated_snapshots.append(snapshot_path) else: if encoded_expected_value is not None: expected_value = decode(encoded_expected_value) try: compare(value, expected_value) except AssertionError as e: snapshot_diff_msg = str(e) else: snapshot_diff_msg = None if snapshot_diff_msg is not None: snapshot_diff_msg = 'value does not match the expected value in snapshot {}\n' \ ' (run pytest with --snapshot-update to update snapshots)\n{}'.format( shorten_path(snapshot_path), snapshot_diff_msg) raise AssertionError(snapshot_diff_msg) else: raise AssertionError( "snapshot {} doesn't exist. (run pytest with --snapshot-update to create it)".format( shorten_path(snapshot_path))) def assert_match_dir(self, dir_dict: dict, snapshot_dir_name: Union[str, Path]): """ Asserts that the values in dir_dict equal the current values in the given snapshot directory. If pytest was run with the --snapshot-update flag, the snapshots will be updated. The test will fail if there were any changes to the snapshots. """ __tracebackhide__ = operator.methodcaller("errisinstance", AssertionError) if not isinstance(dir_dict, dict): raise TypeError('dir_dict must be a dictionary') snapshot_dir_path = self._snapshot_path(snapshot_dir_name) values_by_filename = flatten_filesystem_dict(dir_dict) if snapshot_dir_path.is_dir(): existing_names = {p.relative_to(snapshot_dir_path).as_posix() for p in snapshot_dir_path.rglob('*') if p.is_file()} elif snapshot_dir_path.exists(): raise AssertionError('snapshot exists but is not a directory: {}'.format(shorten_path(snapshot_dir_path))) else: existing_names = set() names = set(values_by_filename) added_names = names - existing_names removed_names = existing_names - names if self._snapshot_update: self._snapshots_to_delete.extend(snapshot_dir_path.joinpath(name) for name in sorted(removed_names)) else: if added_names or removed_names: message_lines = ['Values do not match snapshots in {}'.format(shorten_path(snapshot_dir_path)), ' (run pytest with --snapshot-update to update the snapshot directory)'] if added_names: message_lines.append(" Values without snapshots:") message_lines.extend(' ' + s for s in added_names) if removed_names: message_lines.append(" Snapshots without values:") message_lines.extend(' ' + s for s in removed_names) raise AssertionError('\n'.join(message_lines)) # Call assert_match to add, update, or assert equality for all snapshot files in the directory. for name, value in values_by_filename.items(): self.assert_match(value, snapshot_dir_path.joinpath(name)) def _get_default_snapshot_dir(node: _pytest.python.Function) -> Path: """ Returns the default snapshot directory for the pytest test. """ test_module_dir = node.fspath.dirpath() test_module = node.fspath.purebasename if '[' not in node.name: test_name = node.name parametrize_name = None else: test_name = node.originalname parametrize_match = PARAMETRIZED_TEST_REGEX.match(node.name) assert parametrize_match is not None, 'Expected request.node.name to be of format TEST_FUNCTION[PARAMS]' parametrize_name = parametrize_match.group(1) parametrize_name = get_valid_filename(parametrize_name) default_snapshot_dir = test_module_dir.join('snapshots', test_module, test_name) if parametrize_name is not None: default_snapshot_dir = default_snapshot_dir.join(parametrize_name) return Path(str(default_snapshot_dir)) pytest-snapshot-0.9.0/setup.cfg000066400000000000000000000022501423103161300165170ustar00rootroot00000000000000[metadata] name = pytest-snapshot author = Joseph Roitman author_email = joseph.roitman@gmail.com maintainer = Joseph Roitman maintainer_email = joseph.roitman@gmail.com license = MIT description = A plugin for snapshot testing with pytest. url = https://github.com/joseph-roitman/pytest-snapshot long_description = file: README.rst classifiers = Development Status :: 4 - Beta Framework :: Pytest Intended Audience :: Developers Topic :: Software Development :: Testing Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: Implementation :: CPython Programming Language :: Python :: Implementation :: PyPy Operating System :: OS Independent License :: OSI Approved :: MIT License [options] packages = pytest_snapshot python_requires = >=3.5 install_requires = pytest >= 3.0.0 [options.entry_points] pytest11 = snapshot = pytest_snapshot.plugin pytest-snapshot-0.9.0/setup.py000066400000000000000000000005511423103161300164120ustar00rootroot00000000000000# This file is required for tox and setuptools-scm<6.2. from setuptools import setup setup( name='pytest-snapshot', # This line is only needed to make the Github dependency graph work packages=['pytest_snapshot'], # This line is only needed to make the Github dependency graph work use_scm_version={"write_to": "pytest_snapshot/_version.py"}, ) pytest-snapshot-0.9.0/tests/000077500000000000000000000000001423103161300160415ustar00rootroot00000000000000pytest-snapshot-0.9.0/tests/__init__.py000066400000000000000000000000001423103161300201400ustar00rootroot00000000000000pytest-snapshot-0.9.0/tests/conftest.py000066400000000000000000000000341423103161300202350ustar00rootroot00000000000000pytest_plugins = 'pytester' pytest-snapshot-0.9.0/tests/test_assert_match.py000066400000000000000000000406751423103161300221430ustar00rootroot00000000000000import os from pathlib import Path import pytest from pytest_snapshot._utils import simple_version_parse from pytest_snapshot.plugin import _file_encode from tests.utils import assert_pytest_passes, runpytest_with_assert_mode @pytest.fixture def basic_case_dir(testdir): case_dir = testdir.mkdir('case_dir') case_dir.join('snapshot1.txt').write_text('the valuÉ of snapshot1.txt\n', 'utf-8') return case_dir def test_assert_match_with_external_snapshot_path(testdir, basic_case_dir): testdir.makepyfile(r""" from pathlib import Path def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match('the value of snapshot1.txt\n', Path('not_case_dir/snapshot1.txt').absolute()) """) result = testdir.runpytest('-v') result.stdout.fnmatch_lines([ '*::test_sth FAILED*', "E* ValueError: Snapshot path not_case_dir?snapshot1.txt is not in case_dir", ]) assert result.ret == 1 def test_assert_match_success_string(testdir, basic_case_dir): testdir.makepyfile(r""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match('the valuÉ of snapshot1.txt\n', 'snapshot1.txt') """) assert_pytest_passes(testdir) def test_assert_match_success_bytes(testdir, basic_case_dir): testdir.makepyfile(r""" import os def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match(b'the valu\xc3\x89 of snapshot1.txt' + os.linesep.encode(), 'snapshot1.txt') """) assert_pytest_passes(testdir) def test_assert_match_failure_string(request, testdir, basic_case_dir): testdir.makepyfile(r""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match('the INCORRECT value of snapshot1.txt\n', 'snapshot1.txt') """) result = runpytest_with_assert_mode(testdir, request, '-v', '--assert=rewrite') result.stdout.fnmatch_lines([ '*::test_sth FAILED*', r">* snapshot.assert_match('the INCORRECT value of snapshot1.txt\n', 'snapshot1.txt')", 'E* AssertionError: value does not match the expected value in snapshot case_dir?snapshot1.txt', "E* (run pytest with --snapshot-update to update snapshots)", "E* assert * == *", "E* - the valuÉ of snapshot1.txt", "E* ? ^", "E* + the INCORRECT value of snapshot1.txt", "E* ? ++++++++++ ^", ]) assert result.ret == 1 def test_assert_match_failure_bytes(request, testdir, basic_case_dir): testdir.makepyfile(r""" import os def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match(b'the INCORRECT value of snapshot1.txt' + os.linesep.encode(), 'snapshot1.txt') """) result = runpytest_with_assert_mode(testdir, request, '-v', '--assert=rewrite') result.stdout.fnmatch_lines([ r'*::test_sth FAILED*', r">* snapshot.assert_match(b'the INCORRECT value of snapshot1.txt' + os.linesep.encode(), 'snapshot1.txt')", r'E* AssertionError: value does not match the expected value in snapshot case_dir?snapshot1.txt', r'E* (run pytest with --snapshot-update to update snapshots)', r"E* assert * == *", r"E* At index 4 diff: * != *", r"E* Full diff:", r"E* - b'the valu\xc3\x89 of snapshot1.txt{}'".format(repr(os.linesep)[1:-1]), r"E* + b'the INCORRECT value of snapshot1.txt{}'".format(repr(os.linesep)[1:-1]), ]) assert result.ret == 1 @pytest.mark.skipif(simple_version_parse(pytest.__version__) < (5, 0, 0), reason="consecutive flag not supported.") def test_assert_match_failure_assert_plain(request, testdir, basic_case_dir): """ Testing plugin behavior when users run "pytest --assert=plain" is complicated and fragile since the outer pytest and inner pytest affect one another. To get around this, we call pytest in a subprocess. If you wish to run this test in a debugger, consider replacing `runpytest_subprocess` with `runpytest` and running the test with "--assert=plain". """ testdir.makepyfile(r""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match('the INCORRECT value of snapshot1.txt\n', 'snapshot1.txt') """) result = runpytest_with_assert_mode(testdir, request, '-v', '--assert=plain') result.stdout.fnmatch_lines([ '*::test_sth FAILED*', ]) # Use consecutive=True to verify that --assert=rewrite was not triggered somehow. result.stdout.fnmatch_lines([ r">* snapshot.assert_match('the INCORRECT value of snapshot1.txt\n', 'snapshot1.txt')", 'E* AssertionError: value does not match the expected value in snapshot case_dir?snapshot1.txt', 'E* (run pytest with --snapshot-update to update snapshots)', '', '*test_assert_match_failure_assert_plain.py:*: AssertionError', ], consecutive=True) assert result.ret == 1 def test_assert_match_invalid_type(testdir, basic_case_dir): testdir.makepyfile(r""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match(123, 'snapshot1.txt') """) result = testdir.runpytest('-v') result.stdout.fnmatch_lines([ '*::test_sth FAILED*', 'E* TypeError: value must be str or bytes', ]) assert result.ret == 1 def test_assert_match_missing_snapshot(testdir, basic_case_dir): testdir.makepyfile(r""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match('something', 'snapshot_that_doesnt_exist.txt') """) result = testdir.runpytest('-v') result.stdout.fnmatch_lines([ '*::test_sth FAILED*', "E* snapshot case_dir?snapshot_that_doesnt_exist.txt doesn't exist. " "(run pytest with --snapshot-update to create it)", ]) assert result.ret == 1 def test_assert_match_update_existing_snapshot_no_change(testdir, basic_case_dir): testdir.makepyfile(r""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match('the valuÉ of snapshot1.txt\n', 'snapshot1.txt') """) result = testdir.runpytest('-v', '--snapshot-update') result.stdout.fnmatch_lines([ '*::test_sth PASSED*', ]) assert result.ret == 0 assert_pytest_passes(testdir) # assert that snapshot update worked @pytest.mark.parametrize('case_dir_repr', ["'case_dir'", "str(Path('case_dir').absolute())", "Path('case_dir')", "Path('case_dir').absolute()"], ids=['relative_string_case_dir', 'abs_string_case_dir', 'relative_path_case_dir', 'abs_path_case_dir']) @pytest.mark.parametrize('snapshot_name_repr', ["'snapshot1.txt'", "str(Path('case_dir/snapshot1.txt').absolute())", "Path('case_dir/snapshot1.txt')", # TODO: support this or "Path('snapshot1.txt')"? "Path('case_dir/snapshot1.txt').absolute()"], ids=['relative_string_snapshot_name', 'abs_string_snapshot_name', 'relative_path_snapshot_name', 'abs_path_snapshot_name']) def test_assert_match_update_existing_snapshot(testdir, basic_case_dir, case_dir_repr, snapshot_name_repr): """ Tests that `Snapshot.assert_match` works when updating an existing snapshot. Also tests that `Snapshot` supports absolute/relative str/Path snapshot directories and snapshot paths. """ testdir.makepyfile(r""" from pathlib import Path def test_sth(snapshot): snapshot.snapshot_dir = {case_dir_repr} snapshot.assert_match('the NEW value of snapshot1.txt\n', {snapshot_name_repr}) """.format(case_dir_repr=case_dir_repr, snapshot_name_repr=snapshot_name_repr)) result = testdir.runpytest('-v', '--snapshot-update') result.stdout.fnmatch_lines([ '*::test_sth PASSED*', '*::test_sth ERROR*', '* ERROR at teardown of test_sth *', 'Snapshot directory was modified: case_dir', ' (verify that the changes are expected before committing them to version control)', ' Updated snapshots:', ' snapshot1.txt', ]) assert result.ret == 1 assert_pytest_passes(testdir) # assert that snapshot update worked def test_assert_match_update_existing_snapshot_and_exception_in_test(testdir, basic_case_dir): """ Tests that `Snapshot.assert_match` works when updating an existing snapshot and then the test function fails. In this case, both the snapshot update error and the test function error are printed out. """ testdir.makepyfile(r""" from pathlib import Path def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match('the NEW value of snapshot1.txt\n', 'snapshot1.txt') assert False """) result = testdir.runpytest('-v', '--snapshot-update') result.stdout.fnmatch_lines([ '*::test_sth FAILED*', '*::test_sth ERROR*', '* ERROR at teardown of test_sth *', 'Snapshot directory was modified: case_dir', ' (verify that the changes are expected before committing them to version control)', ' Updated snapshots:', ' snapshot1.txt', 'E* assert False', ]) assert result.ret == 1 def test_assert_match_create_new_snapshot(testdir, basic_case_dir): testdir.makepyfile(r""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match('the NEW value of new_snapshot1.txt', 'sub_dir/new_snapshot1.txt') """) result = testdir.runpytest('-v', '--snapshot-update') result.stdout.fnmatch_lines([ '*::test_sth PASSED*', '*::test_sth ERROR*', '* ERROR at teardown of test_sth *', 'Snapshot directory was modified: case_dir', ' (verify that the changes are expected before committing them to version control)', ' Created snapshots:', ' sub_dir?new_snapshot1.txt', ]) assert result.ret == 1 assert_pytest_passes(testdir) # assert that snapshot update worked def test_assert_match_create_new_snapshot_in_default_dir(testdir): testdir.makepyfile(r""" def test_sth(snapshot): snapshot.assert_match('the value of new_snapshot1.txt', 'sub_dir/new_snapshot1.txt') """) result = testdir.runpytest('-v', '--snapshot-update') result.stdout.fnmatch_lines([ '*::test_sth PASSED*', '*::test_sth ERROR*', '* ERROR at teardown of test_sth *', 'Snapshot directory was modified: snapshots?test_assert_match_create_new_snapshot_in_default_dir?test_sth', ' (verify that the changes are expected before committing them to version control)', ' Created snapshots:', ' sub_dir?new_snapshot1.txt', ]) assert result.ret == 1 assert testdir.tmpdir.join( 'snapshots/test_assert_match_create_new_snapshot_in_default_dir/test_sth/sub_dir/new_snapshot1.txt' ).read_text('utf-8') == 'the value of new_snapshot1.txt' assert_pytest_passes(testdir) # assert that snapshot update worked def test_assert_match_existing_snapshot_is_not_file(testdir, basic_case_dir): basic_case_dir.mkdir('directory1') testdir.makepyfile(r""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match('something', 'directory1') """) result = testdir.runpytest('-v', '--snapshot-update') result.stdout.fnmatch_lines([ '*::test_sth FAILED*', "E* AssertionError: snapshot exists but is not a file: case_dir?directory1", ]) assert result.ret == 1 @pytest.mark.parametrize('tested_value', [ b'', '', bytes(bytearray(range(256))), ''.join(chr(i) for i in range(0, 10000)).replace('\r', ''), ' \n \t \n Whitespace! \n\t Whitespace! \n \t \n ', # We don't support \r due to cross-compatibility and git by default modifying snapshot files... pytest.param('\r', marks=pytest.mark.xfail(strict=True)), ], ids=[ 'empty-bytes', 'empty-string', 'all-bytes', 'unicode', 'whitespace', 'slash-r', ]) def test_assert_match_edge_cases(testdir, basic_case_dir, tested_value): """ This test tests many possible values to snapshot test. This test will fail if we change the snapshot file format in any way. This test also checks that assert_match will pass after a snapshot update. """ testdir.makepyfile(r""" def test_sth(snapshot): tested_value = {tested_value!r} snapshot.snapshot_dir = 'case_dir' snapshot.assert_match(tested_value, 'tested_value_snapshot') """.format(tested_value=tested_value)) result = testdir.runpytest('-v', '--snapshot-update') result.stdout.fnmatch_lines([ '*::test_sth PASSED*', '*::test_sth ERROR*', ]) assert result.ret == 1 if isinstance(tested_value, str): expected_encoded_snapshot = tested_value.replace('\n', os.linesep).encode() else: expected_encoded_snapshot = tested_value encoded_snapshot = Path(str(basic_case_dir)).joinpath('tested_value_snapshot').read_bytes() assert encoded_snapshot == expected_encoded_snapshot assert_pytest_passes(testdir) # assert that snapshot update worked def test_assert_match_unsupported_value_existing_snapshot(request, testdir, basic_case_dir): """ Test that when running tests without --snapshot-update, we don't tell the user that the value is unsupported. We instead tell the user that the value does not equal the snapshot. This behaviour is more helpful. """ basic_case_dir.join('newline.txt').write_binary(_file_encode('\n')) testdir.makepyfile(r""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match('\r', 'newline.txt') """) result = runpytest_with_assert_mode(testdir, request, '-v', '--assert=rewrite') result.stdout.fnmatch_lines([ '*::test_sth FAILED*', 'E* AssertionError: value does not match the expected value in snapshot case_dir?newline.txt', "E* - '\\n'", "E* + '\\r'", ]) assert result.ret == 1 def test_assert_match_unsupported_value_update_existing_snapshot(testdir, basic_case_dir): basic_case_dir.join('newline.txt').write_binary(_file_encode('\n')) testdir.makepyfile(r""" import os from unittest import mock def _file_encode(string: str) -> bytes: return string.replace('\n', os.linesep).encode() def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' with mock.patch('pytest_snapshot.plugin._file_encode', _file_encode): snapshot.assert_match('\r', 'newline.txt') """) result = testdir.runpytest('-v', '--snapshot-update') result.stdout.fnmatch_lines([ '*::test_sth FAILED*', "E* ValueError: value is not supported by pytest-snapshot's serializer.", ]) assert result.ret == 1 def test_assert_match_unsupported_value_create_snapshot(testdir, basic_case_dir): testdir.makepyfile(r""" import os from unittest import mock def _file_encode(string: str) -> bytes: return string.replace('\n', os.linesep).encode() def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' with mock.patch('pytest_snapshot.plugin._file_encode', _file_encode): snapshot.assert_match('\r', 'newline.txt') """) result = testdir.runpytest('-v', '--snapshot-update') result.stdout.fnmatch_lines([ '*::test_sth FAILED*', "E* ValueError: value is not supported by pytest-snapshot's serializer.", ]) assert result.ret == 1 def test_assert_match_unsupported_value_slash_r(testdir, basic_case_dir): testdir.makepyfile(r""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match('\r', 'newline.txt') """) result = testdir.runpytest('-v', '--snapshot-update') result.stdout.fnmatch_lines([ '*::test_sth FAILED*', 'E* ValueError: Snapshot testing strings containing "\\r" is not supported.', ]) assert result.ret == 1 pytest-snapshot-0.9.0/tests/test_assert_match_dir.py000066400000000000000000000272651423103161300230010ustar00rootroot00000000000000import sys import pytest from tests.utils import assert_pytest_passes, runpytest_with_assert_mode @pytest.fixture def basic_case_dir(testdir): case_dir = testdir.mkdir('case_dir') dict_snapshot1 = case_dir.mkdir('dict_snapshot1') dict_snapshot1.join('obj1.txt').write_text('the value of obj1.txt', 'ascii') subdir1 = dict_snapshot1.mkdir('subdir1') subdir1.join('subobj1.txt').write_text('the value of subobj1.txt', 'ascii') return case_dir def test_assert_match_dir_success(testdir, basic_case_dir): testdir.makepyfile(""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match_dir({ 'obj1.txt': 'the value of obj1.txt', 'subdir1': { 'subobj1.txt': 'the value of subobj1.txt', }, }, 'dict_snapshot1') """) assert_pytest_passes(testdir) def test_assert_match_dir_failure(request, testdir, basic_case_dir): testdir.makepyfile(""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match_dir({ 'obj1.txt': 'the value of obj1.txt', 'subdir1': { 'subobj1.txt': 'the INCORRECT value of subobj1.txt', }, }, 'dict_snapshot1') """) result = runpytest_with_assert_mode(testdir, request, '-v', '--assert=rewrite') result.stdout.fnmatch_lines([ '*::test_sth FAILED*', "* snapshot.assert_match_dir({", "* 'obj1.txt': 'the value of obj1.txt',", "* 'subdir1': {", "* 'subobj1.txt': 'the INCORRECT value of subobj1.txt',", "* },", "* }, 'dict_snapshot1')", 'E* A*Error: value does not match the expected value in snapshot case_dir?dict_snapshot1?subdir1?subobj1.txt', 'E* (run pytest with --snapshot-update to update snapshots)', "E* assert * == *", "E* - the value of subobj1.txt", "E* + the INCORRECT value of subobj1.txt", "E* ? ++++++++++", ]) assert result.ret == 1 def test_assert_match_dir_invalid_type(testdir, basic_case_dir): testdir.makepyfile(""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match_dir('NOT A DICTIONARY', 'dict_snapshot1') """) result = testdir.runpytest('-v') result.stdout.fnmatch_lines([ '*::test_sth FAILED*', 'E* TypeError: dir_dict must be a dictionary', ]) assert result.ret == 1 def test_assert_match_dir_missing_snapshot(testdir, basic_case_dir): testdir.makepyfile(""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match_dir({ 'obj1.txt': 'the value of obj1.txt', 'subdir1': { 'subobj1.txt': 'the INCORRECT value of subobj1.txt', 'new_obj.txt': 'the value of new_obj.txt', }, }, 'dict_snapshot1') """) result = testdir.runpytest('-v') result.stdout.fnmatch_lines([ '*::test_sth FAILED*', "E* AssertionError: Values do not match snapshots in case_dir?dict_snapshot1", 'E* (run pytest with --snapshot-update to update the snapshot directory)', 'E* Values without snapshots:', 'E* subdir1?new_obj.txt', ]) assert result.ret == 1 def test_assert_match_dir_missing_value(testdir, basic_case_dir): testdir.makepyfile(""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match_dir({ 'obj1.txt': 'the value of obj1.txt', 'subdir1': {}, }, 'dict_snapshot1') """) result = testdir.runpytest('-v') result.stdout.fnmatch_lines([ '*::test_sth FAILED*', "E* AssertionError: Values do not match snapshots in case_dir?dict_snapshot1", 'E* (run pytest with --snapshot-update to update the snapshot directory)', 'E* Snapshots without values:', 'E* subdir1?subobj1.txt', ]) assert result.ret == 1 def test_assert_match_dir_update_existing_snapshot_no_change(testdir, basic_case_dir): testdir.makepyfile(""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match_dir({ 'obj1.txt': 'the value of obj1.txt', 'subdir1': { 'subobj1.txt': 'the value of subobj1.txt', }, }, 'dict_snapshot1') """) result = testdir.runpytest('-v', '--snapshot-update') result.stdout.fnmatch_lines([ '*::test_sth PASSED*', ]) assert result.ret == 0 @pytest.mark.parametrize('case_dir_repr', ["'case_dir'", "str(Path('case_dir').absolute())", "Path('case_dir')", "Path('case_dir').absolute()"], ids=['relative_string_case_dir', 'abs_string_case_dir', 'relative_path_case_dir', 'abs_path_case_dir']) @pytest.mark.parametrize('snapshot_name_repr', ["'dict_snapshot1'", "str(Path('case_dir/dict_snapshot1').absolute())", "Path('case_dir/dict_snapshot1')", # TODO: support this or "Path('dict_snapshot1')"? "Path('case_dir/dict_snapshot1').absolute()"], ids=['relative_string_snapshot_name', 'abs_string_snapshot_name', 'relative_path_snapshot_name', 'abs_path_snapshot_name']) def test_assert_match_dir_update_existing_snapshot(testdir, basic_case_dir, case_dir_repr, snapshot_name_repr): """ Tests that `Snapshot.assert_match_dir` works when updating an existing snapshot. Also tests that `Snapshot` supports absolute/relative str/Path snapshot directories and snapshot paths. """ testdir.makepyfile(""" from pathlib import Path def test_sth(snapshot): snapshot.snapshot_dir = {case_dir_repr} snapshot.assert_match_dir({{ 'obj1.txt': 'the value of obj1.txt', 'subdir1': {{ 'subobj1.txt': 'the NEW value of subobj1.txt', }}, }}, {snapshot_name_repr}) """.format(case_dir_repr=case_dir_repr, snapshot_name_repr=snapshot_name_repr)) result = testdir.runpytest('-v', '--snapshot-update') result.stdout.fnmatch_lines([ '*::test_sth PASSED*', '*::test_sth ERROR*', '* ERROR at teardown of test_sth *', "Snapshot directory was modified: case_dir", ' Updated snapshots:', ' dict_snapshot1?subdir1?subobj1.txt', ]) assert result.ret == 1 assert_pytest_passes(testdir) # assert that snapshot update worked def test_assert_match_dir_create_new_snapshot_file(testdir, basic_case_dir): testdir.makepyfile(""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match_dir({ 'obj1.txt': 'the value of obj1.txt', 'subdir1': { 'subobj1.txt': 'the value of subobj1.txt', 'new_obj.txt': 'the value of new_obj.txt', }, }, 'dict_snapshot1') """) result = testdir.runpytest('-v', '--snapshot-update') result.stdout.fnmatch_lines([ '*::test_sth PASSED*', '*::test_sth ERROR*', '* ERROR at teardown of test_sth *', "Snapshot directory was modified: case_dir", ' Created snapshots:', ' dict_snapshot1?subdir1?new_obj.txt', ]) assert result.ret == 1 assert_pytest_passes(testdir) # assert that snapshot update worked def test_assert_match_dir_delete_snapshot_file(testdir, basic_case_dir): testdir.makepyfile(""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match_dir({ 'obj1.txt': 'the value of obj1.txt', 'subdir1': {}, }, 'dict_snapshot1') """) result = testdir.runpytest('-v', '--snapshot-update') result.stdout.fnmatch_lines([ '*::test_sth PASSED*', '*::test_sth ERROR*', '* ERROR at teardown of test_sth *', "Snapshot directory was modified: case_dir", ' Snapshots that should be deleted: (run pytest with --allow-snapshot-deletion to delete them)', ' dict_snapshot1?subdir1?subobj1.txt', ]) assert result.ret == 1 result = testdir.runpytest('-v', '--snapshot-update', '--allow-snapshot-deletion') result.stdout.fnmatch_lines([ '*::test_sth PASSED*', '*::test_sth ERROR*', '* ERROR at teardown of test_sth *', 'Snapshot directory was modified: case_dir', ' Deleted snapshots:', ' dict_snapshot1?subdir1?subobj1.txt', ]) assert result.ret == 1 assert_pytest_passes(testdir) # assert that snapshot update worked @pytest.mark.skipif(sys.version_info < (3, 6), reason="Non-deterministic dicts in python <=3.5 make testing annoying.") def test_assert_match_dir_create_new_snapshot_dir(testdir, basic_case_dir): testdir.makepyfile(""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match_dir({ 'obj1.txt': 'the value of obj1.txt', 'subdir1': { 'subobj1.txt': 'the value of subobj1.txt', }, }, 'new_dict_snapshot') """) result = testdir.runpytest('-v', '--snapshot-update') result.stdout.fnmatch_lines([ '*::test_sth PASSED*', '*::test_sth ERROR*', '* ERROR at teardown of test_sth *', 'Snapshot directory was modified: case_dir', ' Created snapshots:', ' new_dict_snapshot?obj1.txt', ' new_dict_snapshot?subdir1?subobj1.txt', ]) assert result.ret == 1 assert_pytest_passes(testdir) # assert that snapshot update worked def test_assert_match_dir_existing_snapshot_is_not_dir(testdir, basic_case_dir): basic_case_dir.join('file1').write_text('', 'ascii') testdir.makepyfile(""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match_dir({}, 'file1') """) result = testdir.runpytest('-v', '--snapshot-update') result.stdout.fnmatch_lines([ '*::test_sth FAILED*', "E* AssertionError: snapshot exists but is not a directory: case_dir?file1", ]) assert result.ret == 1 def test_assert_match_dir_empty_snapshot(testdir, basic_case_dir): """ When testing a empty dict, if the directory doesn't exist, the correct behaviour is to pass. This behaviour is important since git ignores empty directories. """ testdir.makepyfile(""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match_dir({}, 'nonexisting_directory') """) assert_pytest_passes(testdir) def test_assert_match_dir_path_traversal_name(testdir, basic_case_dir): testdir.makepyfile(""" def test_sth(snapshot): snapshot.snapshot_dir = 'case_dir' snapshot.assert_match_dir({ 'obj1.txt': 'the value of obj1.txt', 'subdir1/subobj1.txt': 'the value of subobj1.txt', }, 'dict_snapshot1') """) result = testdir.runpytest('-v', '--snapshot-update') result.stdout.fnmatch_lines([ '*::test_sth FAILED*', "E* ValueError: Key 'subdir1/subobj1.txt' in d must be a valid file name.", ]) assert result.ret == 1 pytest-snapshot-0.9.0/tests/test_misc.py000066400000000000000000000113001423103161300204000ustar00rootroot00000000000000import sys from unittest import mock from unittest.mock import Mock import pytest from pytest_snapshot._utils import shorten_path, might_be_valid_filename, simple_version_parse, \ _pytest_expected_on_right, flatten_dict, flatten_filesystem_dict from tests.utils import assert_pytest_passes, runpytest_with_assert_mode from pathlib import Path def test_help_message(testdir): result = testdir.runpytest('--help') result.stdout.fnmatch_lines([ 'snapshot:', '*--snapshot-update*Update snapshot files instead of testing against them.', ]) def test_default_snapshot_dir_without_parametrize(testdir): testdir.makepyfile(""" from pathlib import Path def test_sth(snapshot): assert snapshot.snapshot_dir == \ Path('snapshots/test_default_snapshot_dir_without_parametrize/test_sth').absolute() """) assert_pytest_passes(testdir) def test_default_snapshot_dir_with_parametrize(testdir): testdir.makepyfile(""" import pytest from pathlib import Path @pytest.mark.parametrize('param', ['a', 'b']) def test_sth(snapshot, param): assert snapshot.snapshot_dir == \ Path('snapshots/test_default_snapshot_dir_with_parametrize/test_sth/{}'.format(param)).absolute() """) result = testdir.runpytest('-v') result.stdout.fnmatch_lines([ '*::test_sth?a? PASSED*', '*::test_sth?b? PASSED*', ]) assert result.ret == 0 def test_shorten_path_in_cwd(): assert shorten_path(Path('a/b').absolute()) == Path('a/b') def test_shorten_path_outside_cwd(): path_outside_cwd = Path().absolute().parent.joinpath('a/b') assert shorten_path(path_outside_cwd) == path_outside_cwd @pytest.mark.parametrize('s, expected', [ ('snapshot.txt', True), ('snapshot', True), ('.snapshot', True), ('snapshot.', True), ('', False), ('.', False), ('..', False), ('/', False), ('\\', False), ('a/b', False), ('a\\b', False), ('a:b', False), ('a"b', False), ]) def test_might_be_valid_filename(s, expected): assert might_be_valid_filename(s) == expected @pytest.mark.parametrize('version_str, version', [ ('0.0.0', (0, 0, 0)), ('55.2312.123132', (55, 2312, 123132)), ('1.2.3rc', (1, 2, 3)), ]) def test_simple_version_parse_success(version_str, version): assert simple_version_parse(version_str) == version @pytest.mark.parametrize('version_str', [ '', 'rc1.2.3', '1!2.3.4', 'a.b.c', '1.2', '1.2.', ]) def test_simple_version_parse_error(version_str): with pytest.raises(ValueError): simple_version_parse(version_str) @pytest.mark.parametrize('version_str, expected_on_right', [ ('4.9.9', False), ('5.3.9', False), ('5.4.0', True), ('5.4.1', True), ('5.5.0', True), ('6.0.0', True), ('badversion', True), ]) def test_pytest_expected_on_right(version_str, expected_on_right): with mock.patch('pytest.__version__', version_str): assert _pytest_expected_on_right() == expected_on_right def test_flatten_dict(): result = flatten_dict({ 'a': 1, 'b': { 'ba': 2, 'bb': { 'bba': 3, 'bbb': 4, }, }, 'empty': {}, }) result = sorted(result) # Needed to support python 3.5 assert result == [(['a'], 1), (['b', 'ba'], 2), (['b', 'bb', 'bba'], 3), (['b', 'bb', 'bbb'], 4)] def test_flatten_filesystem_dict_success(): result = flatten_filesystem_dict({ 'file1': 'file1_contents', 'dir1': { 'file2': 'file2_contents', 'dir2': { 'file3': 'file3_contents', }, }, 'empty_dir': {}, }) assert result == { 'file1': 'file1_contents', 'dir1/file2': 'file2_contents', 'dir1/dir2/file3': 'file3_contents', } @pytest.mark.parametrize('illegal_filename', [ 'a/b', '.', '', ]) def test_flatten_filesystem_dict_illegal_filename(illegal_filename): with pytest.raises(ValueError): flatten_filesystem_dict({ illegal_filename: 'contents' }) @pytest.mark.skipif(sys.version_info < (3, 6), reason="assert_called_once doesn't exist in Python <3.6") def test_runpytest_with_assert_mode(request): testdir = Mock() runpytest_with_assert_mode(testdir, request, '--assert=plain') runpytest_with_assert_mode(testdir, request, '--assert=rewrite') testdir.runpytest.assert_called_once() testdir.runpytest_subprocess.assert_called_once() def test_runpytest_with_assert_mode_without_assert_mode(request): testdir = Mock() with pytest.raises(ValueError): runpytest_with_assert_mode(testdir, request) pytest-snapshot-0.9.0/tests/utils.py000066400000000000000000000020471423103161300175560ustar00rootroot00000000000000def runpytest_with_assert_mode(testdir, request, *args): """ Calls `runpytest` if possible, otherwise calls `runpytest_subprocess`. Calling `runpytest` when the caller is run with --assert=rewrite and the callee is run with --assert=plain or vice versa does not work correctly, so this wrapper calls `runpytest_subprocess` in these cases. Note: If you are trying to debug a test that reaches `runpytest_subprocess`, consider running the test with another --assert mode. """ if '--assert=plain' in args: assert_mode = 'plain' elif '--assert=rewrite' in args: assert_mode = 'rewrite' else: raise ValueError('Use this function only if you require --assert=rewrite or --assert=plain') if assert_mode == request.config.option.assertmode: return testdir.runpytest(*args) else: return testdir.runpytest_subprocess(*args) def assert_pytest_passes(testdir): result = testdir.runpytest('-v') result.stdout.fnmatch_lines(['*::test_sth PASSED*']) assert result.ret == 0 pytest-snapshot-0.9.0/tox.ini000066400000000000000000000017731423103161300162220ustar00rootroot00000000000000# For more information about tox, see https://tox.readthedocs.io/en/latest/ [tox] envlist = # Pytest <6.2.5 not supported on Python >=3.10 py{36,37,38,39}-pytest{3,4,5}-coverage py{35,36,37,38,39,310,3}-pytest{6,}-coverage # Coverage is slow in pypy pypy3-pytest{3,4,5,6,} flake8 [testenv] deps = pytest3: pytest >=3, <4 pytest4: pytest >=4, <5 pytest5: pytest >=5, <6 pytest6: pytest >=6, <7 pytest: pytest coverage: coverage setenv = coverage: TEST_RUNNER=coverage run -m pytest usedevelop = True commands = coverage: coverage erase {env:TEST_RUNNER:pytest} {posargs:tests} coverage: coverage report coverage: coverage xml -o coverage-reports/{env:TOX_ENV_NAME}.xml [testenv:flake8] skip_install = true deps = flake8 commands = flake8 pytest_snapshot setup.py tests [flake8] max-line-length = 120 [gh-actions] python = 3.5: py35 3.6: py36 3.7: py37 3.8: py38 3.9: py39 3.10: py310, flake8 3: py3 pypy-3: pypy3