Skip to content

Commit

Permalink
Add matcher for exceptions in asyncio future
Browse files Browse the repository at this point in the history
Example of use

```
assert_that(
    await resolved(raise_exception()),
    future_raising(AssertionError))
)
```

The resolved helper is used to create resolved future objects in async
code. It takes a "future like" object and waits for it to complete.

Ref #155
  • Loading branch information
keis committed Feb 14, 2023
1 parent 40e15f9 commit 9492643
Show file tree
Hide file tree
Showing 3 changed files with 352 additions and 0 deletions.
33 changes: 33 additions & 0 deletions doc/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,39 @@ assure that the right issue was found::
# assert_that(23, raises(IOError))


Asserting exceptions from async methods
---------------------------------------

An async method does not directly return the result or raise an exception but
instead returns a Future-object that represent the async operation that can
later be resolved with the `await` keyword. The
:py:func:`~hamcrest.core.core.future.resolved` utility function can be used to
wait for a future to be done but without retrieving the value or raising the
exception. The :py:func:`~hamcrest.core.core.future.future_raising` matcher can
be used with any future object but combined lets you assert that calling some
async method, and waiting for the result, causes an exception to be raised.

This is best used together with an async test runner like IsolatedAsyncioTestCase or pytest-asyncio::

async def parse(input: str):
...

class Test(unittest.IsolatedAsyncioTestCase):

async def testParse(self):
future = parse("some bad data")
assert_that(await resolved(future), future_raising(ValueError))

But it's possible to use with an async unware runner by explicitly running the event loop in the test::

class Test(unittest.TestCase):
def test_parse(self):
async def test():
future = parse("some bad data")
assert_that(await resolved(future), future_raising(ValueError))

asyncio.get_event_loop().run_until_complete(test())


Predefined matchers
-------------------
Expand Down
135 changes: 135 additions & 0 deletions src/hamcrest/core/core/future.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import sys
import re
import asyncio
from typing import (
Optional,
Type,
TypeVar,
Union,
Awaitable,
)

from hamcrest.core.base_matcher import BaseMatcher
from hamcrest.core.description import Description
from hamcrest.core.matcher import Matcher

__author__ = "David Keijser"
__copyright__ = "Copyright 2021 hamcrest.org"
__license__ = "BSD, see License.txt"

T = TypeVar("T")

if sys.version_info > (3, 9):
# Same as used for asyncio.ensure_future
FutureT = Union[asyncio.Future[T], Awaitable[T]]
else:
# Future is not a parametrised type in earlier version of python
FutureT = Union[asyncio.Future, Awaitable]


class FutureRaising(BaseMatcher[asyncio.Future]):
def __init__(
self,
expected: Type[Exception],
pattern: Optional[str] = None,
matching: Optional[Matcher] = None,
) -> None:
self.pattern = pattern
self.matcher = matching
self.expected = expected

def _matches(self, future: asyncio.Future) -> bool:
if not asyncio.isfuture(future):
return False

if not future.done():
return False

if future.cancelled():
return False

exc = future.exception()
if exc is None:
return False

if isinstance(exc, self.expected):
if self.pattern is not None:
if re.search(self.pattern, str(exc)) is None:
return False
if self.matcher is not None:
if not self.matcher.matches(exc):
return False
return True

return False

def describe_to(self, description: Description) -> None:
description.append_text("Expected a completed future with exception %s" % self.expected)

def describe_mismatch(self, future: asyncio.Future, description: Description) -> None:
if not asyncio.isfuture(future):
description.append_text("%s is not a future" % future)
return

if not future.done():
description.append_text("%s is not completed yet" % future)
return

if future.cancelled():
description.append_text("%s is cancelled" % future)
return

exc = future.exception()
if exc is None:
description.append_text("No exception raised.")
elif isinstance(exc, self.expected):
if self.pattern is not None or self.matcher is not None:
description.append_text("Correct assertion type raised, but ")
if self.pattern is not None:
description.append_text('the expected pattern ("%s") ' % self.pattern)
if self.pattern is not None and self.matcher is not None:
description.append_text("and ")
if self.matcher is not None:
description.append_description_of(self.matcher)
description.append_text(" ")
description.append_text('not found. Exception message was: "%s"' % str(exc))
else:
description.append_text("%r of type %s was raised instead" % (exc, type(exc)))

def describe_match(self, future: asyncio.Future, match_description: Description) -> None:
exc = future.exception()
match_description.append_text("%r of type %s was raised." % (exc, type(exc)))


def future_raising(
exception: Type[Exception], pattern=None, matching=None
) -> Matcher[asyncio.Future]:
"""Matches a future with the expected exception.
:param exception: The class of the expected exception
:param pattern: Optional regular expression to match exception message.
:param matching: Optional Hamcrest matchers to apply to the exception.
Expects the actual to be an already resolved future. The :py:func:`~hamcrest:core.core.future.resolved` helper can be used to wait for a future to resolve.
Optional argument pattern should be a string containing a regular expression. If provided,
the string representation of the actual exception - e.g. `str(actual)` - must match pattern.
Examples::
assert_that(somefuture, future_exception(ValueError))
assert_that(
await resolved(async_http_get()),
future_exception(HTTPError, matching=has_properties(status_code=500)
)
"""
return FutureRaising(exception, pattern, matching)


async def resolved(obj: FutureT) -> asyncio.Future:
"""Wait for an async operation to finish and return a resolved future object with the result.
:param obj: A future like object or an awaitable object.
"""
fut = asyncio.ensure_future(obj)
await asyncio.wait([fut])
return fut
184 changes: 184 additions & 0 deletions tests/hamcrest_unit_test/core/future_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import sys

import pytest
import asyncio
from hamcrest import has_properties
from hamcrest.core.core.future import resolved, future_raising
from hamcrest_unit_test.matcher_test import MatcherTest

if __name__ == "__main__":
sys.path.insert(0, "..")
sys.path.insert(0, "../..")


__author__ = "David Keijser"
__copyright__ = "Copyright 2023 hamcrest.org"
__license__ = "BSD, see License.txt"


async def no_exception(*args, **kwargs):
return


async def raise_exception(*args, **kwargs):
raise AssertionError(str(args) + str(kwargs))


async def raise_exception_with_properties(**kwargs):
err = AssertionError("boom")
for k, v in kwargs.items():
setattr(err, k, v)
raise err


# From python 3.8 this could be simplified by using unittest.IsolatedAsyncioTestCase
class FutureExceptionTest(MatcherTest):
def testMatchesIfFutureHasTheExactExceptionExpected(self):
async def test():
self.assert_matches(
"Right exception",
future_raising(AssertionError),
await resolved(raise_exception()),
)

asyncio.get_event_loop().run_until_complete(test())

def testDoesNotMatchIfActualIsNotAFuture(self):
async def test():
self.assert_does_not_match("Not a future", future_raising(TypeError), 23)

asyncio.get_event_loop().run_until_complete(test())

def testDoesNotMatchIfFutureIsNotDone(self):
future = asyncio.Future()
self.assert_does_not_match("Unresolved future", future_raising(TypeError), future)

def testDoesNotMatchIfFutureIsCancelled(self):
future = asyncio.Future()
future.cancel()
self.assert_does_not_match("Cancelled future", future_raising(TypeError), future)

@pytest.mark.skipif(
not (3, 0) <= sys.version_info < (3, 7), reason="Message differs between Python versions"
)
def testDoesNotMatchIfFutureHasTheWrongExceptionTypePy3(self):
async def test():
self.assert_does_not_match(
"Wrong exception", future_raising(IOError), await resolved(raise_exception())
)
expected_message = (
"AssertionError('(){}',) of type <class 'AssertionError'> was raised instead"
)
self.assert_mismatch_description(
expected_message, future_raising(TypeError), await resolved(raise_exception())
)

asyncio.get_event_loop().run_until_complete(test())

@pytest.mark.skipif(sys.version_info < (3, 7), reason="Message differs between Python versions")
def testDoesNotMatchIfFutureHasTheWrongExceptionTypePy37(self):
async def test():
self.assert_does_not_match(
"Wrong exception", future_raising(IOError), await resolved(raise_exception())
)
expected_message = (
"AssertionError('(){}') of type <class 'AssertionError'> was raised instead"
)
self.assert_mismatch_description(
expected_message, future_raising(TypeError), await resolved(raise_exception())
)

asyncio.get_event_loop().run_until_complete(test())

def testMatchesIfFutureHasASubclassOfTheExpectedException(self):
async def test():
self.assert_matches(
"Subclassed Exception",
future_raising(Exception),
await resolved(raise_exception()),
)

asyncio.get_event_loop().run_until_complete(test())

def testDoesNotMatchIfFutureDoesNotHaveException(self):
async def test():
self.assert_does_not_match(
"No exception", future_raising(ValueError), await resolved(no_exception())
)

asyncio.get_event_loop().run_until_complete(test())

def testDoesNotMatchExceptionIfRegularExpressionDoesNotMatch(self):
async def test():
self.assert_does_not_match(
"Bad regex",
future_raising(AssertionError, "Phrase not found"),
await resolved(raise_exception()),
)
self.assert_mismatch_description(
'''Correct assertion type raised, but the expected pattern ("Phrase not found") not found. Exception message was: "(){}"''',
future_raising(AssertionError, "Phrase not found"),
await resolved(raise_exception()),
)

asyncio.get_event_loop().run_until_complete(test())

def testMatchesRegularExpressionToStringifiedException(self):
async def test():
self.assert_matches(
"Regex",
future_raising(AssertionError, "(3, 1, 4)"),
await resolved(raise_exception(3, 1, 4)),
)

self.assert_matches(
"Regex",
future_raising(AssertionError, r"([\d, ]+)"),
await resolved(raise_exception(3, 1, 4)),
)

asyncio.get_event_loop().run_until_complete(test())

def testMachesIfExceptionMatchesAdditionalMatchers(self):
async def test():
self.assert_matches(
"Properties",
future_raising(AssertionError, matching=has_properties(prip="prop")),
await resolved(raise_exception_with_properties(prip="prop")),
)

asyncio.get_event_loop().run_until_complete(test())

def testDoesNotMatchIfAdditionalMatchersDoesNotMatch(self):
async def test():
self.assert_does_not_match(
"Bad properties",
future_raising(AssertionError, matching=has_properties(prop="prip")),
await resolved(raise_exception_with_properties(prip="prop")),
)
self.assert_mismatch_description(
'''Correct assertion type raised, but an object with a property 'prop' matching 'prip' not found. Exception message was: "boom"''',
future_raising(AssertionError, matching=has_properties(prop="prip")),
await resolved(raise_exception_with_properties(prip="prop")),
)

asyncio.get_event_loop().run_until_complete(test())

def testDoesNotMatchIfNeitherPatternOrMatcherMatch(self):
async def test():
self.assert_does_not_match(
"Bad pattern and properties",
future_raising(
AssertionError, pattern="asdf", matching=has_properties(prop="prip")
),
await resolved(raise_exception_with_properties(prip="prop")),
)
self.assert_mismatch_description(
'''Correct assertion type raised, but the expected pattern ("asdf") and an object with a property 'prop' matching 'prip' not found. Exception message was: "boom"''',
future_raising(
AssertionError, pattern="asdf", matching=has_properties(prop="prip")
),
await resolved(raise_exception_with_properties(prip="prop")),
)

asyncio.get_event_loop().run_until_complete(test())

0 comments on commit 9492643

Please sign in to comment.