From c6ecc69b5284f580e633182aee445df666296da9 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 ignored keys Enable setting ignored_keys argument on snapshot.assert_match() ignored_keys is a list or tuple of keys whose values should be ignored when running assert_match. The values of the ignored 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 | 63 +++++++++++++++++++++ 7 files changed, 141 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ccf22df..4ce25e4 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 `ignored_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..753780f 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 ``ignored_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..58468ea 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" + } + }, + ignored_keys=("id",) + ) diff --git a/snapshottest/module.py b/snapshottest/module.py index 243ec4e..7c5ae9e 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.ignored_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.ignored_keys is not None: + self.clear_ignored_keys(test_value) + self.clear_ignored_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='', ignored_keys=None): self.curr_snapshot = name or self.snapshot_counter + self.ignored_keys = ignored_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_ignored_keys(self, data): + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(value, dict): + data[key] = self.clear_ignored_keys(value) + if key in self.ignored_keys: + data[key] = None + return data + elif isinstance(data, list): + for index, value in enumerate(data): + data[index] = self.clear_ignored_keys(value) + return data + elif isinstance(data, tuple): + return tuple(self.clear_ignored_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..cd9be44 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='', ignored_keys=None): + self._snapshot.assert_match(value, name=name, ignored_keys=ignored_keys) assertMatchSnapshot = assert_match_snapshot diff --git a/tests/test_snapshot_test.py b/tests/test_snapshot_test.py index d179886..58eb836 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,64 @@ 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 = [ + lambda: {"time": time.time(), "this key": "must match"}, + lambda: {"nested": {"time": time.time(), "this key": "must match"}}, + lambda: [{"time": time.time(), "this key": "must match"}], + lambda: ({"time": time.time(), "this key": "must match"},), +] + + +@pytest.mark.parametrize( + "data_factory", + [ + pytest.param(data_factory) + for data_factory in SNAPSHOTABLE_DATA_FACTORIES + ], +) +def test_snapshot_assert_match__matches_with_diffing_ignored_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, ignored_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_ignored_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, ignored_keys=("time",))