From 08ad89f8891016d3de57894ce01de9d01b891cfd Mon Sep 17 00:00:00 2001 From: David Keijser Date: Thu, 11 Mar 2021 17:43:33 +0100 Subject: [PATCH] Add matcher for exceptions in asyncio future 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 --- doc/tutorial.rst | 33 ++++ src/hamcrest/core/core/future.py | 137 ++++++++++++++ tests/hamcrest_unit_test/core/future_test.py | 184 +++++++++++++++++++ 3 files changed, 354 insertions(+) create mode 100644 src/hamcrest/core/core/future.py create mode 100644 tests/hamcrest_unit_test/core/future_test.py diff --git a/doc/tutorial.rst b/doc/tutorial.rst index 13ded0cd..6bb18b3d 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -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 ------------------- diff --git a/src/hamcrest/core/core/future.py b/src/hamcrest/core/core/future.py new file mode 100644 index 00000000..b58bc2a8 --- /dev/null +++ b/src/hamcrest/core/core/future.py @@ -0,0 +1,137 @@ +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 in typeshed for asyncio.ensure_future + FutureT = asyncio.Future[T] + FutureLike = Union[asyncio.Future[T], Awaitable[T]] +else: + # Future is not a parametrised type in earlier version of python + FutureT = asyncio.Future + FutureLike = 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: FutureLike) -> FutureT: + """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 diff --git a/tests/hamcrest_unit_test/core/future_test.py b/tests/hamcrest_unit_test/core/future_test.py new file mode 100644 index 00000000..a419f1c1 --- /dev/null +++ b/tests/hamcrest_unit_test/core/future_test.py @@ -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 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 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())