From 06023d9de4c8262792506d3772a12b5042e5e4e8 Mon Sep 17 00:00:00 2001 From: Ilya Kulakov Date: Fri, 15 Oct 2021 16:46:14 -0700 Subject: [PATCH] bpo-17013: Add Mock.call_event to allow waiting for calls New methods allow tests to wait for calls executing in other threads. --- Doc/library/unittest.mock-examples.rst | 19 +++++ Doc/library/unittest.mock.rst | 14 ++++ Lib/unittest/mock.py | 74 +++++++++++++++++++ Lib/unittest/test/testmock/support.py | 16 ++++ Lib/unittest/test/testmock/testmock.py | 74 ++++++++++++++++++- .../2019-11-12-13-11-44.bpo-17013.C06aC9.rst | 2 + 6 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2019-11-12-13-11-44.bpo-17013.C06aC9.rst diff --git a/Doc/library/unittest.mock-examples.rst b/Doc/library/unittest.mock-examples.rst index 24a18c68484686..744c642d2f6334 100644 --- a/Doc/library/unittest.mock-examples.rst +++ b/Doc/library/unittest.mock-examples.rst @@ -70,6 +70,25 @@ the ``something`` method: >>> real.method() >>> real.something.assert_called_once_with(1, 2, 3) +When testing mutltithreaded code it may be important to ensure that certain +method is eventually called, e.g. as a result of scheduling asynchronous +operation. :attr:`~Mock.call_event` exposes methods that allow to assert that: + + >>> from threading import Timer + >>> + >>> class ProductionClass: + ... def method(self): + ... self.t1 = Timer(0.1, self.something, args=(1, 2, 3)) + ... self.t1.start() + ... self.t2 = Timer(0.1, self.something, args=(4, 5, 6)) + ... self.t2.start() + ... def something(self, a, b, c): + ... pass + ... + >>> real = ProductionClass() + >>> real.something = MagicMock() + >>> real.method() + >>> real.something.call_event.wait_for_call(call(4, 5, 6), timeout=1.0) Mock for Method Calls on an Object diff --git a/Doc/library/unittest.mock.rst b/Doc/library/unittest.mock.rst index 0856c3fbded08a..75984e263fc15a 100644 --- a/Doc/library/unittest.mock.rst +++ b/Doc/library/unittest.mock.rst @@ -515,6 +515,20 @@ the *new_callable* argument to :func:`patch`. >>> mock.call_count 2 + .. attribute:: call_event + + An object that can be used in multithreaded tests to assert that a call was made. + + - :meth:`wait(/, skip=0, timeout=None)` asserts that mock is called + *skip* + 1 times during the *timeout* + + - :meth:`wait_for(predicate, /, timeout=None)` asserts that + *predicate* was ``True`` at least once during the *timeout*; + *predicate* receives exactly one positional argument: the mock itself + + - :meth:`wait_for_call(call, /, skip=0, timeout=None)` asserts that + *call* has happened at least *skip* + 1 times during the *timeout* + .. attribute:: return_value Set this to configure the value returned by calling the mock: diff --git a/Lib/unittest/mock.py b/Lib/unittest/mock.py index 9f99a5aa5bcdcb..766236a4fe89f9 100644 --- a/Lib/unittest/mock.py +++ b/Lib/unittest/mock.py @@ -20,6 +20,7 @@ 'mock_open', 'PropertyMock', 'seal', + 'CallEvent' ) @@ -31,6 +32,7 @@ import sys import builtins import pkgutil +import threading from asyncio import iscoroutinefunction from types import CodeType, ModuleType, MethodType from unittest.util import safe_repr @@ -224,6 +226,7 @@ def reset_mock(): ret.reset_mock() funcopy.called = False + funcopy.call_event = CallEvent(mock) funcopy.call_count = 0 funcopy.call_args = None funcopy.call_args_list = _CallList() @@ -446,6 +449,7 @@ def __init__( __dict__['_mock_delegate'] = None __dict__['_mock_called'] = False + __dict__['_mock_call_event'] = CallEvent(self) __dict__['_mock_call_args'] = None __dict__['_mock_call_count'] = 0 __dict__['_mock_call_args_list'] = _CallList() @@ -547,6 +551,7 @@ def __class__(self): return self._spec_class called = _delegating_property('called') + call_event = _delegating_property('call_event') call_count = _delegating_property('call_count') call_args = _delegating_property('call_args') call_args_list = _delegating_property('call_args_list') @@ -584,6 +589,7 @@ def reset_mock(self, visited=None,*, return_value=False, side_effect=False): visited.append(id(self)) self.called = False + self.call_event = CallEvent(self) self.call_args = None self.call_count = 0 self.mock_calls = _CallList() @@ -1111,6 +1117,7 @@ def _mock_call(self, /, *args, **kwargs): def _increment_mock_call(self, /, *args, **kwargs): self.called = True self.call_count += 1 + self.call_event._notify() # handle call_args # needs to be set here so assertions on call arguments pass before @@ -2411,6 +2418,73 @@ def _format_call_signature(name, args, kwargs): return message % formatted_args +class CallEvent(object): + def __init__(self, mock): + self._mock = mock + self._condition = threading.Condition() + + def wait(self, /, skip=0, timeout=None): + """ + Wait for any call. + + :param skip: How many calls will be skipped. + As a result, the mock should be called at least + ``skip + 1`` times. + + :param timeout: See :meth:`threading.Condition.wait`. + """ + def predicate(mock): + return mock.call_count > skip + + self.wait_for(predicate, timeout=timeout) + + def wait_for_call(self, call, /, skip=0, timeout=None): + """ + Wait for a given call. + + :param skip: How many calls will be skipped. + As a result, the call should happen at least + ``skip + 1`` times. + + :param timeout: See :meth:`threading.Condition.wait`. + """ + def predicate(mock): + return mock.call_args_list.count(call) > skip + + self.wait_for(predicate, timeout=timeout) + + def wait_for(self, predicate, /, timeout=None): + """ + Wait for a given predicate to become True. + + :param predicate: A callable that receives mock which result + will be interpreted as a boolean value. + The final predicate value is the return value. + + :param timeout: See :meth:`threading.Condition.wait`. + """ + try: + self._condition.acquire() + + def _predicate(): + return predicate(self._mock) + + b = self._condition.wait_for(_predicate, timeout) + + if not b: + msg = (f"{self._mock._mock_name or 'mock'} was not called before" + f" timeout({timeout}).") + raise AssertionError(msg) + finally: + self._condition.release() + + def _notify(self): + try: + self._condition.acquire() + self._condition.notify_all() + finally: + self._condition.release() + class _Call(tuple): """ diff --git a/Lib/unittest/test/testmock/support.py b/Lib/unittest/test/testmock/support.py index 49986d65dc47af..424e4757b97e11 100644 --- a/Lib/unittest/test/testmock/support.py +++ b/Lib/unittest/test/testmock/support.py @@ -1,3 +1,7 @@ +import concurrent.futures +import time + + target = {'foo': 'FOO'} @@ -14,3 +18,15 @@ def wibble(self): pass class X(object): pass + + +def call_after_delay(func, /, *args, **kwargs): + time.sleep(kwargs.pop('delay')) + func(*args, **kwargs) + + +def run_async(func, /, *args, executor=None, delay=0, **kwargs): + if executor is None: + executor = concurrent.futures.ThreadPoolExecutor(max_workers=5) + + executor.submit(call_after_delay, func, *args, **kwargs, delay=delay) diff --git a/Lib/unittest/test/testmock/testmock.py b/Lib/unittest/test/testmock/testmock.py index fdba543b53511d..29e4ad1f98fb34 100644 --- a/Lib/unittest/test/testmock/testmock.py +++ b/Lib/unittest/test/testmock/testmock.py @@ -5,7 +5,7 @@ from test.support import ALWAYS_EQ import unittest -from unittest.test.testmock.support import is_instance +from unittest.test.testmock.support import is_instance, run_async from unittest import mock from unittest.mock import ( call, DEFAULT, patch, sentinel, @@ -2249,6 +2249,78 @@ class Foo(): f'{__name__}.Typos', autospect=True, set_spec=True, auto_spec=True): pass + def test_wait_until_called_before(self): + mock = Mock(spec=Something)() + mock.method_1() + mock.method_1.call_event.wait() + mock.method_1.assert_called_once() + + def test_wait_until_called(self): + mock = Mock(spec=Something)() + run_async(mock.method_1, delay=0.01) + mock.method_1.call_event.wait() + mock.method_1.assert_called_once() + + def test_wait_until_called_magic_method(self): + mock = MagicMock(spec=Something)() + run_async(mock.method_1.__str__, delay=0.01) + mock.method_1.__str__.call_event.wait() + mock.method_1.__str__.assert_called_once() + + def test_wait_until_called_timeout(self): + mock = Mock(spec=Something)() + run_async(mock.method_1, delay=0.2) + + with self.assertRaises(AssertionError): + mock.method_1.call_event.wait(timeout=0.1) + + mock.method_1.assert_not_called() + mock.method_1.call_event.wait() + mock.method_1.assert_called_once() + + def test_wait_until_any_call_positional(self): + mock = Mock(spec=Something)() + run_async(mock.method_1, 1, delay=0.1) + run_async(mock.method_1, 2, delay=0.2) + run_async(mock.method_1, 3, delay=0.3) + + for arg in (1, 2, 3): + self.assertNotIn(call(arg), mock.method_1.mock_calls) + mock.method_1.call_event.wait_for(lambda m: call(arg) in m.call_args_list) + mock.method_1.assert_called_with(arg) + + def test_wait_until_any_call_keywords(self): + mock = Mock(spec=Something)() + run_async(mock.method_1, a=1, delay=0.1) + run_async(mock.method_1, a=2, delay=0.2) + run_async(mock.method_1, a=3, delay=0.3) + + for arg in (1, 2, 3): + self.assertNotIn(call(arg), mock.method_1.mock_calls) + mock.method_1.call_event.wait_for(lambda m: call(a=arg) in m.call_args_list) + mock.method_1.assert_called_with(a=arg) + + def test_wait_until_any_call_no_argument(self): + mock = Mock(spec=Something)() + mock.method_1(1) + mock.method_1.assert_called_once_with(1) + + with self.assertRaises(AssertionError): + mock.method_1.call_event.wait_for(lambda m: call() in m.call_args_list, timeout=0.01) + + mock.method_1() + mock.method_1.call_event.wait_for(lambda m: call() in m.call_args_list, timeout=0.01) + + def test_wait_until_call(self): + mock = Mock(spec=Something)() + mock.method_1() + run_async(mock.method_1, 1, a=1, delay=0.1) + + with self.assertRaises(AssertionError): + mock.method_1.call_event.wait_for_call(call(1, a=1), timeout=0.01) + + mock.method_1.call_event.wait_for_call(call(1, a=1)) + if __name__ == '__main__': unittest.main() diff --git a/Misc/NEWS.d/next/Library/2019-11-12-13-11-44.bpo-17013.C06aC9.rst b/Misc/NEWS.d/next/Library/2019-11-12-13-11-44.bpo-17013.C06aC9.rst new file mode 100644 index 00000000000000..39aaccacb2eb09 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2019-11-12-13-11-44.bpo-17013.C06aC9.rst @@ -0,0 +1,2 @@ +Add :attr:`call_event` to :class:`Mock` that allows to wait for the calls in +multithreaded tests. Patch by Ilya Kulakov. pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy