Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Explicit interfaces for matchers and mismatches #211

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ Improvements
This had the side effect of not clearing up fixtures nor gathering details
properly. This is now fixed. (Julian Edwards, #1469759)

* Export explicit ``IMatcher`` and ``IMismatch`` interfaces, and update API
documentation to use those terms. (Jonathan Lange)

2.0.0
~~~~~

Expand Down
12 changes: 6 additions & 6 deletions testtools/assertions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
def assert_that(matchee, matcher, message='', verbose=False):
"""Assert that matchee is matched by matcher.

This should only be used when you need to use a function based
matcher, assertThat in Testtools.Testcase is prefered and has more
features
This should only be used when you need to use a function-based matcher,
``assertThat`` in :py:class:`testtools.TestCase` is prefered and has more
features.

:param matchee: An object to match with matcher.
:param matcher: An object meeting the testtools.Matcher protocol.
:raises MismatchError: When matcher does not match thing.
:param matchee: An object to match with ``matcher``.
:param IMatcher matcher: An object meeting the testtools.Matcher protocol.
:raises MismatchError: When ``matcher`` does not match ``matchee``.
"""
matcher = Annotate.if_message(message, matcher)
mismatch = matcher.match(matchee)
Expand Down
94 changes: 62 additions & 32 deletions testtools/matchers/_impl.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (c) 2009-2012 testtools developers. See LICENSE for details.
# Copyright (c) 2009-2016 testtools developers. See LICENSE for details.

"""Matchers, a way to express complex assertions outside the testcase.

Expand All @@ -11,6 +11,7 @@
"""

__all__ = [
'IMatcher',
'Matcher',
'Mismatch',
'MismatchDecorator',
Expand All @@ -25,25 +26,33 @@
)


class Matcher(object):
class IMatcher(object):
"""A pattern matcher.

A Matcher must implement match and __str__ to be used by
testtools.TestCase.assertThat. Matcher.match(thing) returns None when
thing is completely matched, and a Mismatch object otherwise.
This describes the IMatcher protocol that any matcher must implement.

A Matcher must implement ``match`` and ``__str__`` to be used by
:py:method:`testtools.TestCase.assertThat`.

Matchers can be useful outside of test cases, as they are simply a
pattern matching language expressed as objects.
pattern-matching language expressed as objects.

testtools.matchers is inspired by hamcrest, but is pythonic rather than
a Java transcription.
:py:module:`testtools.matchers` is inspired by hamcrest, but is Pythonic
rather than a Java transcription.
"""

def match(self, something):
"""Return None if this matcher matches something, a Mismatch otherwise.
"""Match against ``something``.

:param something: The thing to be matched against. Type will vary based
on matcher implementation.
:return: ``None`` if ``something`` matched, an :py:class:`IMismatch`
if it did not.
:rtype: Optional[IMismatch]
"""
raise NotImplementedError(self.match)

# XXX: We have a bug saying that this ought to be __repr__. jml agrees.
def __str__(self):
"""Get a sensible human representation of the matcher.

Expand All @@ -53,7 +62,49 @@ def __str__(self):
raise NotImplementedError(self.__str__)


class Mismatch(object):
Matcher = IMatcher


class IMismatch(object):
"""A mismatch detected by an IMatcher.

Describes the protocol that mismatches are required to implement.
"""

# XXX: jml would like to extend this interface to include the thing that
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, I'm +1 on this - I frequently find myself writing a test that fails, then adding the matchee as the third parameter to the assertion so it gets printed out, then fixing the test and removing that third parameter. Annoying. I'm not sure if that should go in the matchers themselves, or in (assert|expect)That.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, interesting. I mostly want it for composition, e.g. to help with Not. ISTR wanting it for https://github.com/ClusterHQ/flocker/blob/master/flocker/testtools/matchers.py but looking at it, I can't see what was essential.

Whether we or not we extend the interface this way, I want second opinions on whether it should be done in this patch / this release.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rbtcollins Any thoughts on this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jml Just for extra clarity, does "include the thing that" mean "the matchee argument passed to match()"?

From a very quick look at the code it seems that implementations of IMatcher typically pass or could pass the matchee object to the constructor of an IMismatch, in order to have describe() take it into account.

Why do we need to pass it to describe()?

An example of the "composition" use case you allude to would be appreciated.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does "include the thing that" mean "the matchee argument passed to match()"

Yes.

Why do we need to pass it to describe()?

Did I propose this?

An example of the "composition" use case you allude to would be appreciated.

Sorry, the intervening 13 months have somehow scrubbed it from my memory. However, looking at the file linked to in my comment provides just such an example. You'll have to figure out what it does, I'm afraid.

# failed to match.

def describe(self):
"""Describe the mismatch.

:return: Either a human-readable string or something that can be cast
to a string. On Python 2, this should be either ``unicode`` or a
plain ASCII ``str``, and care should be taken to escape control
characters.
"""
raise NotImplementedError(self.describe)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure, but I think we want this to actually be a string, always. I realise its just a move.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean by "string"?


def get_details(self):
"""Get extra details about the mismatch.

This allows the mismatch to provide extra information beyond the basic
description, including large text or binary files, or debugging internals
without having to force it to fit in the output of :py:method:`describe`.

The testtools assertion :py:method:`~testtools.TestCase.assertThat`
will query :py:method:`get_details` and attach all its values to the
test, permitting them to be reported in whatever manner the test
environment chooses.

:return: a dict mapping names to Content objects. name is a string to
name the detail, and the Content object is the detail to add
to the result. For more information see the API to which items from
this dict are passed testtools.TestCase.addDetail.
"""
raise NotImplementedError(self.get_details)


class Mismatch(IMismatch):
"""An object describing a mismatch detected by a Matcher."""

def __init__(self, description=None, details=None):
Expand All @@ -71,33 +122,12 @@ def __init__(self, description=None, details=None):
self._details = details

def describe(self):
"""Describe the mismatch.

This should be either a human-readable string or castable to a string.
In particular, is should either be plain ascii or unicode on Python 2,
and care should be taken to escape control characters.
"""
try:
return self._description
except AttributeError:
raise NotImplementedError(self.describe)

def get_details(self):
"""Get extra details about the mismatch.

This allows the mismatch to provide extra information beyond the basic
description, including large text or binary files, or debugging internals
without having to force it to fit in the output of 'describe'.

The testtools assertion assertThat will query get_details and attach
all its values to the test, permitting them to be reported in whatever
manner the test environment chooses.

:return: a dict mapping names to Content objects. name is a string to
name the detail, and the Content object is the detail to add
to the result. For more information see the API to which items from
this dict are passed testtools.TestCase.addDetail.
"""
return getattr(self, '_details', {})

def __repr__(self):
Expand Down Expand Up @@ -143,7 +173,7 @@ def __str__(self):
return self.__unicode__().encode("ascii", "backslashreplace")


class MismatchDecorator(object):
class MismatchDecorator(IMismatch):
"""Decorate a ``Mismatch``.

Forwards all messages to the original mismatch object. Probably the best
Expand Down
14 changes: 7 additions & 7 deletions testtools/testcase.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,11 +482,11 @@ def match(self, matchee):
failUnlessRaises = assertRaises

def assertThat(self, matchee, matcher, message='', verbose=False):
"""Assert that matchee is matched by matcher.
"""Assert that ``matchee`` is matched by ``matcher``.

:param matchee: An object to match with matcher.
:param matcher: An object meeting the testtools.Matcher protocol.
:raises MismatchError: When matcher does not match thing.
:param matchee: An object to match with ``matcher``.
:param IMatcher matcher: A matcher that ``matchee`` is matched against.
:raises MismatchError: When ``matcher`` does not match thing.
"""
mismatch_error = self._matchHelper(matchee, matcher, message, verbose)
if mismatch_error is not None:
Expand Down Expand Up @@ -514,15 +514,15 @@ def addDetailUniqueName(self, name, content_object):
self.addDetail(full_name, content_object)

def expectThat(self, matchee, matcher, message='', verbose=False):
"""Check that matchee is matched by matcher, but delay the assertion failure.
"""Check that ``matchee`` is matched by ``matcher``, but delay the failure.

This method behaves similarly to ``assertThat``, except that a failed
match does not exit the test immediately. The rest of the test code
will continue to run, and the test will be marked as failing after the
test has finished.

:param matchee: An object to match with matcher.
:param matcher: An object meeting the testtools.Matcher protocol.
:param matchee: An object to match with ``matcher``.
:param IMatcher matcher: A matcher that ``matchee`` is matched against.
:param message: If specified, show this message with any failed match.

"""
Expand Down