From 4701f8e74e127b48e729b915d77f24f299eece82 Mon Sep 17 00:00:00 2001 From: Hugo Heyman Date: Sat, 26 Oct 2019 23:44:49 +0200 Subject: [PATCH] Feature - assert_match with ignore_keys Enable setting ignore_keys argument on snapshot.assert_match() ignore_keys is a list or tuple of keys whose values should be ignored when running assert_match. The values of the ignore_keys are set to None, this way we still assert that the keys are present but ignore its value. Dicts, lists and tuples are traversed recursively to ignore any occurrence of the key for all dicts on any level. --- README.md | 9 +++ README.rst | 21 ++++++- examples/pytest/snapshots/snap_test_demo.py | 9 +++ examples/pytest/test_demo.py | 16 +++++ snapshottest/module.py | 24 +++++++- snapshottest/unittest.py | 4 +- tests/test_snapshot_test.py | 65 +++++++++++++++++++++ 7 files changed, 143 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ccf22df..5487b43 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,15 @@ class APITestCase(TestCase): If you want to update the snapshots automatically you can use the `python manage.py test --snapshot-update`. Check the [Django example](https://github.com/syrusakbary/snapshottest/tree/master/examples/django_project). +### Ignoring dict keys +A common usecase for snapshot testing is to freeze an API by ensuring that it doesn't get changed unexpectedly. +Some data such as timestamps, UUIDs or similar random data will make your snapshots fail every time unless you mock these fields. + +While mocking is a perfectly fine solution it might still not be the most time efficient and practical one. +Therefore `assert_match()` may take a keyword argument `ignore_keys`. +The values of any ignored key (on any nesting level) will not be compared with the snapshots value (but the key must still be present). + + # Contributing After cloning this repo and configuring a virtualenv for snapshottest (optional, but highly recommended), ensure dependencies are installed by running: diff --git a/README.rst b/README.rst index 82baad6..d05c474 100644 --- a/README.rst +++ b/README.rst @@ -84,6 +84,20 @@ If you want to update the snapshots automatically you can use the ``python manage.py test --snapshot-update``. Check the `Django example `__. +Ignoring dict keys +~~~~~~~~~~~~~~~~~~ + +A common usecase for snapshot testing is to freeze an API by ensuring +that it doesn't get changed unexpectedly. Some data such as timestamps, +UUIDs or similar random data will make your snapshots fail every time +unless you mock these fields. + +While mocking is a perfectly fine solution it might still not be the +most time efficient and practical one. Therefore ``assert_match()`` may +take a keyword argument ``ignore_keys``. The values of any ignored key +(on any nesting level) will not be compared with the snapshots value +(but the key must still be present). + Contributing ============ @@ -103,13 +117,16 @@ After developing, the full test suite can be evaluated by running: # and make test -If you change this ``README.md``, you'll need to have pandoc installed to update its ``README.rst`` counterpart (used by PyPI), -which can be done by running: +If you change this ``README.md``, remember to update its ``README.rst`` +counterpart (used by PyPI), which can be done by running: :: make README.rst +For this last step you'll need to have ``pandoc`` installed in your +machine. + Notes ===== diff --git a/examples/pytest/snapshots/snap_test_demo.py b/examples/pytest/snapshots/snap_test_demo.py index e01be52..06fa414 100644 --- a/examples/pytest/snapshots/snap_test_demo.py +++ b/examples/pytest/snapshots/snap_test_demo.py @@ -47,3 +47,12 @@ snapshots['test_nested_objects frozenset'] = frozenset([ GenericRepr('#') ]) + +snapshots['test_snapshot_can_ignore_keys 1'] = { + 'id': GenericRepr("UUID('fac2b49e-0ec1-407b-a840-3fbb0a522eb9')"), + 'nested': { + 'id': GenericRepr("UUID('1649c442-1fad-4b6d-9b14-5cf4ee9c929c')"), + 'some_nested_key': 'some_nested_value' + }, + 'some_key': 'some_value' +} diff --git a/examples/pytest/test_demo.py b/examples/pytest/test_demo.py index d1e4c27..b032dc8 100644 --- a/examples/pytest/test_demo.py +++ b/examples/pytest/test_demo.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import uuid from collections import defaultdict from snapshottest.file import FileSnapshot @@ -83,3 +84,18 @@ def test_nested_objects(snapshot): snapshot.assert_match(tuple_, 'tuple') snapshot.assert_match(set_, 'set') snapshot.assert_match(frozenset_, 'frozenset') + + +def test_snapshot_can_ignore_keys(snapshot): + snapshot.assert_match( + { + "id": uuid.uuid4(), + "some_key": "some_value", + "nested": + { + "id": uuid.uuid4(), + "some_nested_key": "some_nested_value" + } + }, + ignore_keys=("id",) + ) diff --git a/snapshottest/module.py b/snapshottest/module.py index 243ec4e..ec37462 100644 --- a/snapshottest/module.py +++ b/snapshottest/module.py @@ -192,6 +192,7 @@ class SnapshotTest(object): def __init__(self): self.curr_snapshot = '' self.snapshot_counter = 1 + self.ignore_keys = None @property def module(self): @@ -225,14 +226,18 @@ def store(self, data): self.module[self.test_name] = data def assert_value_matches_snapshot(self, test_value, snapshot_value): + if self.ignore_keys is not None: + self.clear_ignore_keys(test_value) + self.clear_ignore_keys(snapshot_value) formatter = Formatter.get_formatter(test_value) formatter.assert_value_matches_snapshot(self, test_value, snapshot_value, Formatter()) def assert_equals(self, value, snapshot): assert value == snapshot - def assert_match(self, value, name=''): + def assert_match(self, value, name='', ignore_keys=None): self.curr_snapshot = name or self.snapshot_counter + self.ignore_keys = ignore_keys self.visit() if self.update: self.store(value) @@ -254,6 +259,23 @@ def assert_match(self, value, name=''): def save_changes(self): self.module.save() + def clear_ignore_keys(self, data): + if isinstance(data, dict): + for key, value in data.items(): + if key in self.ignore_keys: + data[key] = None + else: + data[key] = self.clear_ignore_keys(value) + return data + elif isinstance(data, list): + for index, value in enumerate(data): + data[index] = self.clear_ignore_keys(value) + return data + elif isinstance(data, tuple): + return tuple(self.clear_ignore_keys(value) for value in data) + + return data + def assert_match_snapshot(value, name=''): if not SnapshotTest._current_tester: diff --git a/snapshottest/unittest.py b/snapshottest/unittest.py index 7209041..b7ef786 100644 --- a/snapshottest/unittest.py +++ b/snapshottest/unittest.py @@ -101,7 +101,7 @@ def tearDown(self): SnapshotTest._current_tester = None self._snapshot = None - def assert_match_snapshot(self, value, name=''): - self._snapshot.assert_match(value, name=name) + def assert_match_snapshot(self, value, name='', ignore_keys=None): + self._snapshot.assert_match(value, name=name, ignore_keys=ignore_keys) assertMatchSnapshot = assert_match_snapshot diff --git a/tests/test_snapshot_test.py b/tests/test_snapshot_test.py index d179886..fc1810b 100644 --- a/tests/test_snapshot_test.py +++ b/tests/test_snapshot_test.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +import time + import pytest from snapshottest.module import SnapshotModule, SnapshotTest @@ -111,3 +113,66 @@ def test_snapshot_does_not_match_other_values(snapshot_test, value, other_value) with pytest.raises(AssertionError): snapshot_test.assert_match(other_value) assert_snapshot_test_failed(snapshot_test) + + +SNAPSHOTABLE_DATA_FACTORIES = { + "dict": lambda: {"time": time.time(), "this key": "must match"}, + "nested dict": lambda: {"nested": {"time": time.time(), "this key": "must match"}}, + "dict in list": lambda: [{"time": time.time(), "this key": "must match"}], + "dict in tuple": lambda: ({"time": time.time(), "this key": "must match"},), + "dict in list in dict": lambda: {"list": [{"time": time.time(), "this key": "must match"}]}, + "dict in tuple in dict": lambda: {"tuple": ({"time": time.time(), "this key": "must match"},)} +} + + +@pytest.mark.parametrize( + "data_factory", + [ + pytest.param(data_factory) + for data_factory in SNAPSHOTABLE_DATA_FACTORIES.values() + ], ids=list(SNAPSHOTABLE_DATA_FACTORIES.keys()) +) +def test_snapshot_assert_match__matches_with_diffing_ignore_keys( + snapshot_test, data_factory +): + data = data_factory() + # first run stores the value as the snapshot + snapshot_test.assert_match(data) + + # Assert with ignored keys should succeed + data = data_factory() + snapshot_test.reinitialize() + snapshot_test.assert_match(data, ignore_keys=("time",)) + assert_snapshot_test_succeeded(snapshot_test) + + # Assert without ignored key should raise + data = data_factory() + snapshot_test.reinitialize() + with pytest.raises(AssertionError): + snapshot_test.assert_match(data) + + +@pytest.mark.parametrize( + "existing_snapshot, new_snapshot", + [ + pytest.param( + {"time": time.time(), "some_key": "some_value"}, + {"some_key": "some_value"}, + id="new_snapshot_missing_key", + ), + pytest.param( + {"some_key": "some_value"}, + {"time": time.time(), "some_key": "some_value"}, + id="new_snapshot_extra_key", + ), + ], +) +def test_snapshot_assert_match_does_not_match_if_ignore_keys_not_present( + snapshot_test, existing_snapshot, new_snapshot +): + # first run stores the value as the snapshot + snapshot_test.assert_match(existing_snapshot) + + snapshot_test.reinitialize() + with pytest.raises(AssertionError): + snapshot_test.assert_match(new_snapshot, ignore_keys=("time",))