Skip to content

Commit 4d8903f

Browse files
authored
Merge pull request #3780 from nicoddemus/mock-integration-fix
Fix issue where fixtures would lose the decorated functionality
2 parents 5d3c512 + 67106f0 commit 4d8903f

File tree

5 files changed

+79
-4
lines changed

5 files changed

+79
-4
lines changed

src/_pytest/compat.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,12 +228,31 @@ def ascii_escaped(val):
228228
return val.encode("unicode-escape")
229229

230230

231+
class _PytestWrapper(object):
232+
"""Dummy wrapper around a function object for internal use only.
233+
234+
Used to correctly unwrap the underlying function object
235+
when we are creating fixtures, because we wrap the function object ourselves with a decorator
236+
to issue warnings when the fixture function is called directly.
237+
"""
238+
239+
def __init__(self, obj):
240+
self.obj = obj
241+
242+
231243
def get_real_func(obj):
232244
""" gets the real function object of the (possibly) wrapped object by
233245
functools.wraps or functools.partial.
234246
"""
235247
start_obj = obj
236248
for i in range(100):
249+
# __pytest_wrapped__ is set by @pytest.fixture when wrapping the fixture function
250+
# to trigger a warning if it gets called directly instead of by pytest: we don't
251+
# want to unwrap further than this otherwise we lose useful wrappings like @mock.patch (#3774)
252+
new_obj = getattr(obj, "__pytest_wrapped__", None)
253+
if isinstance(new_obj, _PytestWrapper):
254+
obj = new_obj.obj
255+
break
237256
new_obj = getattr(obj, "__wrapped__", None)
238257
if new_obj is None:
239258
break

src/_pytest/fixtures.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
safe_getattr,
3232
FuncargnamesCompatAttr,
3333
get_real_method,
34+
_PytestWrapper,
3435
)
3536
from _pytest.deprecated import FIXTURE_FUNCTION_CALL, RemovedInPytest4Warning
3637
from _pytest.outcomes import fail, TEST_OUTCOME
@@ -954,9 +955,6 @@ def _ensure_immutable_ids(ids):
954955
def wrap_function_to_warning_if_called_directly(function, fixture_marker):
955956
"""Wrap the given fixture function so we can issue warnings about it being called directly, instead of
956957
used as an argument in a test function.
957-
958-
The warning is emitted only in Python 3, because I didn't find a reliable way to make the wrapper function
959-
keep the original signature, and we probably will drop Python 2 in Pytest 4 anyway.
960958
"""
961959
is_yield_function = is_generator(function)
962960
msg = FIXTURE_FUNCTION_CALL.format(name=fixture_marker.name or function.__name__)
@@ -982,6 +980,10 @@ def result(*args, **kwargs):
982980
if six.PY2:
983981
result.__wrapped__ = function
984982

983+
# keep reference to the original function in our own custom attribute so we don't unwrap
984+
# further than this point and lose useful wrappings like @mock.patch (#3774)
985+
result.__pytest_wrapped__ = _PytestWrapper(function)
986+
985987
return result
986988

987989

testing/acceptance_test.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,3 +1044,10 @@ def test2():
10441044
)
10451045
result = testdir.runpytest_subprocess()
10461046
result.stdout.fnmatch_lines(["*1 failed, 1 passed in*"])
1047+
1048+
1049+
def test_fixture_mock_integration(testdir):
1050+
"""Test that decorators applied to fixture are left working (#3774)"""
1051+
p = testdir.copy_example("acceptance/fixture_mock_integration.py")
1052+
result = testdir.runpytest(p)
1053+
result.stdout.fnmatch_lines("*1 passed*")
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Reproduces issue #3774"""
2+
3+
import mock
4+
5+
import pytest
6+
7+
config = {"mykey": "ORIGINAL"}
8+
9+
10+
@pytest.fixture(scope="function")
11+
@mock.patch.dict(config, {"mykey": "MOCKED"})
12+
def my_fixture():
13+
return config["mykey"]
14+
15+
16+
def test_foobar(my_fixture):
17+
assert my_fixture == "MOCKED"

testing/test_compat.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
from __future__ import absolute_import, division, print_function
22
import sys
3+
from functools import wraps
4+
5+
import six
36

47
import pytest
5-
from _pytest.compat import is_generator, get_real_func, safe_getattr
8+
from _pytest.compat import is_generator, get_real_func, safe_getattr, _PytestWrapper
69
from _pytest.outcomes import OutcomeException
710

811

@@ -38,6 +41,33 @@ def __getattr__(self, attr):
3841
print(res)
3942

4043

44+
def test_get_real_func():
45+
"""Check that get_real_func correctly unwraps decorators until reaching the real function"""
46+
47+
def decorator(f):
48+
@wraps(f)
49+
def inner():
50+
pass
51+
52+
if six.PY2:
53+
inner.__wrapped__ = f
54+
return inner
55+
56+
def func():
57+
pass
58+
59+
wrapped_func = decorator(decorator(func))
60+
assert get_real_func(wrapped_func) is func
61+
62+
wrapped_func2 = decorator(decorator(wrapped_func))
63+
assert get_real_func(wrapped_func2) is func
64+
65+
# special case for __pytest_wrapped__ attribute: used to obtain the function up until the point
66+
# a function was wrapped by pytest itself
67+
wrapped_func2.__pytest_wrapped__ = _PytestWrapper(wrapped_func)
68+
assert get_real_func(wrapped_func2) is wrapped_func
69+
70+
4171
@pytest.mark.skipif(
4272
sys.version_info < (3, 4), reason="asyncio available in Python 3.4+"
4373
)

0 commit comments

Comments
 (0)