Skip to content

Commit deb12c6

Browse files
authored
feat(tests): Add helper to count mock calls (#103941)
The Python built-in `MagicMock` class has `assert_called_once_with`, `assert_any_call`, `assert_has_calls`, etc, but none of those is helpful if what you really want is `assert_called_twice_with` or `assert_called_exactly_three_times_with`. This therefore adds a new testing helper, `count_matching_calls`, which works exactly the same way as the built-in ones, except that you also have to pass it the mock. So, for example, the following sets of assertions are equivalent (yes, I know some of the mock methods don't actually exist - that's the point): some_mock.assert_called_once_with("dogs", "are", "great", adopt_dont_shop=True) assert count_matching_calls(some_mock, "dogs", "are", "great", adopt_dont_shop=True) == 1 some_mock.assert_called_twice_with("dogs", "are", "great", adopt_dont_shop=True) assert count_matching_calls(some_mock, "dogs", "are", "great", adopt_dont_shop=True) == 2 some_mock.assert_called_exactly_three_times_with("dogs", "are", "great", adopt_dont_shop=True) assert count_matching_calls(some_mock, "dogs", "are", "great", adopt_dont_shop=True) == 3 This will be helpful for testing an upcoming PR involving caching, where for example it will be nice to be able to test that `cache.get` is called twice while `cache.set` is only called once (or not at all, if certain criteria aren't met).
1 parent 8e631f2 commit deb12c6

File tree

2 files changed

+64
-2
lines changed

2 files changed

+64
-2
lines changed

src/sentry/testutils/pytest/mocking.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from __future__ import annotations
22

33
from collections.abc import Callable
4-
from typing import ParamSpec, TypeVar
4+
from typing import Any, ParamSpec, TypeVar
5+
from unittest.mock import MagicMock
6+
from unittest.mock import call as MockCall
57

68
# TODO: Once we're on python 3.12, we can get rid of these and change the first line of the
79
# signature of `capture_results` to
@@ -122,3 +124,14 @@ def wrapped_fn(*args: P.args, **kwargs: P.kwargs) -> T:
122124
return returned_value
123125

124126
return wrapped_fn
127+
128+
129+
def count_matching_calls(mock_fn: MagicMock, *args: Any, **kwargs: Any) -> int:
130+
"""
131+
Given a mock function, count the calls which match the given args and kwargs.
132+
133+
Note: As is the case with the built-in mock methods `assert_called_with` and friends, the given
134+
args and kwargs must match the full list of what was passed to the given mock.
135+
"""
136+
matching_calls = [call for call in mock_fn.call_args_list if call == MockCall(*args, **kwargs)]
137+
return len(matching_calls)

tests/sentry/testutils/pytest/mocking/test_mocking.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from typing import Any
22
from unittest import TestCase, mock
3+
from unittest.mock import MagicMock
34

4-
from sentry.testutils.pytest.mocking import capture_results
5+
from sentry.testutils.pytest.mocking import capture_results, count_matching_calls
56
from tests.sentry.testutils.pytest.mocking.animals import (
67
a_function_that_calls_erroring_get_dog,
78
a_function_that_calls_get_cat,
@@ -68,3 +69,51 @@ def test_records_thrown_exception(self) -> None:
6869

6970
error_message = result.args[0]
7071
assert error_message == "Expected dog, but got cat instead."
72+
73+
74+
class MockCallCountingTest(TestCase):
75+
def test_no_args_no_kwargs_matching(self) -> None:
76+
describe_dogs = MagicMock()
77+
# Call the function more than once to show it's not just the total number of calls being
78+
# counted, and call it with something else second, to show it's not just looking at the most
79+
# recent call
80+
describe_dogs()
81+
describe_dogs("maisey")
82+
83+
assert count_matching_calls(describe_dogs) == 1
84+
85+
def test_arg_matching(self) -> None:
86+
describe_dogs = MagicMock()
87+
describe_dogs("maisey")
88+
describe_dogs("charlie")
89+
describe_dogs("maisey")
90+
describe_dogs("maisey", "charlie")
91+
92+
assert count_matching_calls(describe_dogs, "maisey") == 2
93+
assert count_matching_calls(describe_dogs, "charlie") == 1
94+
assert count_matching_calls(describe_dogs, "maisey", "charlie") == 1
95+
96+
def test_kwarg_matching(self) -> None:
97+
describe_dogs = MagicMock()
98+
describe_dogs(number_1_dog="maisey")
99+
describe_dogs(number_1_dog="charlie")
100+
describe_dogs(number_1_dog="maisey")
101+
describe_dogs(numer_1_dog="maisey", co_number_1_dog="charlie")
102+
103+
assert count_matching_calls(describe_dogs, number_1_dog="maisey") == 2
104+
assert count_matching_calls(describe_dogs, number_1_dog="charlie") == 1
105+
assert (
106+
count_matching_calls(describe_dogs, numer_1_dog="maisey", co_number_1_dog="charlie")
107+
== 1
108+
)
109+
110+
def test_mixed_matching(self) -> None:
111+
describe_dogs = MagicMock()
112+
describe_dogs("maisey", is_number_1_dog=True)
113+
describe_dogs("charlie", is_number_1_dog=True)
114+
describe_dogs("maisey", is_number_1_dog=True)
115+
describe_dogs("maisey", "charlie", co_number_1_dogs=True)
116+
117+
assert count_matching_calls(describe_dogs, "maisey", is_number_1_dog=True) == 2
118+
assert count_matching_calls(describe_dogs, "charlie", is_number_1_dog=True) == 1
119+
assert count_matching_calls(describe_dogs, "maisey", "charlie", co_number_1_dogs=True) == 1

0 commit comments

Comments
 (0)