diff --git a/README.rst b/README.rst index 62d435c..a20697a 100644 --- a/README.rst +++ b/README.rst @@ -117,6 +117,18 @@ For instance, ``has_attrs(name="bob")`` is equivalent to ``has_attrs(name=equal_ "b": equal_to(4), })) +* ``mapping_includes(matchers)``: matches a mapping, such as a ``dict``, if it has the same keys with matching values. + An error will be raised if the mapping is missing any keys, but allows extra keys. + For instance: + + .. code:: python + + result = {"a": 1, "b": 4, "c": 5} + assert_that(result, mapping_includes({ + "a": equal_to(1), + "b": equal_to(4), + })) + * ``anything``: matches all values. * ``is_instance(type)``: matches any value where ``isinstance(value, type)``. diff --git a/precisely/__init__.py b/precisely/__init__.py index 4477613..7e2722d 100644 --- a/precisely/__init__.py +++ b/precisely/__init__.py @@ -5,7 +5,7 @@ from .iterable_matchers import all_elements, contains_exactly, includes, is_sequence from .feature_matchers import has_feature from .function_matchers import raises -from .mapping_matchers import is_mapping +from .mapping_matchers import is_mapping, mapping_includes from .results import indent as _indent @@ -35,6 +35,7 @@ "is_sequence", "has_feature", "is_mapping", + "mapping_includes", "raises", ] diff --git a/precisely/mapping_matchers.py b/precisely/mapping_matchers.py index ddb68a0..980b9fb 100644 --- a/precisely/mapping_matchers.py +++ b/precisely/mapping_matchers.py @@ -11,6 +11,9 @@ def is_mapping(matchers): class IsMappingMatcher(Matcher): + _describe_message = "mapping with items:{0}" + _allow_extra = False + def __init__(self, matchers): self._matchers = matchers @@ -25,14 +28,27 @@ def match(self, actual): if not value_result.is_match: return unmatched("value for key {0!r} mismatched:{1}".format(key, indented_list([value_result.explanation]))) - extra_keys = set(actual.keys()) - set(self._matchers.keys()) - if extra_keys: - return unmatched("had extra keys:{0}".format(indented_list(sorted(map(repr, extra_keys))))) + if not self._allow_extra: + extra_keys = set(actual.keys()) - set(self._matchers.keys()) + if extra_keys: + return unmatched("had extra keys:{0}".format(indented_list(sorted(map(repr, extra_keys))))) return matched() def describe(self): - return "mapping with items:{0}".format(indented_list(sorted( + return self._describe_message.format(indented_list(sorted( "{0!r}: {1}".format(key, matcher.describe()) for key, matcher in self._matchers.items() ))) + + +def mapping_includes(matchers): + return MappingIncludesMatcher(dict( + (key, to_matcher(matcher)) + for key, matcher in matchers.items() + )) + + +class MappingIncludesMatcher(IsMappingMatcher): + _describe_message = "mapping including items:{0}" + _allow_extra = True diff --git a/tests/mapping_includes_tests.py b/tests/mapping_includes_tests.py new file mode 100644 index 0000000..af052d5 --- /dev/null +++ b/tests/mapping_includes_tests.py @@ -0,0 +1,43 @@ +from nose.tools import istest, assert_equal + +from precisely import equal_to, mapping_includes +from precisely.results import matched, unmatched + + +@istest +def matches_when_keys_and_values_match(): + matcher = mapping_includes({"a": equal_to(1), "b": equal_to(2)}) + assert_equal(matched(), matcher.match({"a": 1, "b": 2})) + + +@istest +def values_are_coerced_to_matchers(): + matcher = mapping_includes({"a": 1, "b": 2}) + assert_equal(matched(), matcher.match({"a": 1, "b": 2})) + + +@istest +def does_not_match_when_value_does_not_match(): + matcher = mapping_includes({"a": equal_to(1), "b": equal_to(2)}) + assert_equal( + unmatched("value for key 'b' mismatched:\n * was 3"), + matcher.match({"a": 1, "b": 3, "c": 4}), + ) + + +@istest +def does_not_match_when_keys_are_missing(): + matcher = mapping_includes({"a": equal_to(1), "b": equal_to(2)}) + assert_equal(unmatched("was missing key: 'b'"), matcher.match({"a": 1})) + + +@istest +def matches_when_there_are_extra_keys(): + matcher = mapping_includes({"a": equal_to(1)}) + assert_equal(matched(), matcher.match({"a": 1, "b": 1, "c": 1})) + + +@istest +def description_describes_keys_and_value_matchers(): + matcher = mapping_includes({"a": equal_to(1), "b": equal_to(2)}) + assert_equal("mapping including items:\n * 'a': 1\n * 'b': 2", matcher.describe())