diff --git a/changelog/3456.bugfix.rst b/changelog/3456.bugfix.rst new file mode 100644 index 00000000000..db6d5411e8c --- /dev/null +++ b/changelog/3456.bugfix.rst @@ -0,0 +1 @@ +Extend Doctest-modules to ignore mock objects. diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index dbf7df82365..d34cb638cf4 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -3,17 +3,19 @@ from __future__ import division from __future__ import print_function +import inspect import platform import sys import traceback +from contextlib import contextmanager import pytest from _pytest._code.code import ExceptionInfo from _pytest._code.code import ReprFileLocation from _pytest._code.code import TerminalRepr +from _pytest.compat import safe_getattr from _pytest.fixtures import FixtureRequest - DOCTEST_REPORT_CHOICE_NONE = "none" DOCTEST_REPORT_CHOICE_CDIFF = "cdiff" DOCTEST_REPORT_CHOICE_NDIFF = "ndiff" @@ -346,10 +348,61 @@ def _check_all_skipped(test): pytest.skip("all tests skipped by +SKIP option") +def _is_mocked(obj): + """ + returns if a object is possibly a mock object by checking the existence of a highly improbable attribute + """ + return ( + safe_getattr(obj, "pytest_mock_example_attribute_that_shouldnt_exist", None) + is not None + ) + + +@contextmanager +def _patch_unwrap_mock_aware(): + """ + contextmanager which replaces ``inspect.unwrap`` with a version + that's aware of mock objects and doesn't recurse on them + """ + real_unwrap = getattr(inspect, "unwrap", None) + if real_unwrap is None: + yield + else: + + def _mock_aware_unwrap(obj, stop=None): + if stop is None: + return real_unwrap(obj, stop=_is_mocked) + else: + return real_unwrap(obj, stop=lambda obj: _is_mocked(obj) or stop(obj)) + + inspect.unwrap = _mock_aware_unwrap + try: + yield + finally: + inspect.unwrap = real_unwrap + + class DoctestModule(pytest.Module): def collect(self): import doctest + class MockAwareDocTestFinder(doctest.DocTestFinder): + """ + a hackish doctest finder that overrides stdlib internals to fix a stdlib bug + + https://github.com/pytest-dev/pytest/issues/3456 + https://bugs.python.org/issue25532 + """ + + def _find(self, tests, obj, name, module, source_lines, globs, seen): + if _is_mocked(obj): + return + with _patch_unwrap_mock_aware(): + + doctest.DocTestFinder._find( + self, tests, obj, name, module, source_lines, globs, seen + ) + if self.fspath.basename == "conftest.py": module = self.config.pluginmanager._importconftest(self.fspath) else: @@ -361,7 +414,7 @@ def collect(self): else: raise # uses internal doctest module parsing mechanism - finder = doctest.DocTestFinder() + finder = MockAwareDocTestFinder() optionflags = get_optionflags(self) runner = _get_runner( verbose=0, diff --git a/testing/test_doctest.py b/testing/test_doctest.py index cccfdabe6f3..e7b6b060fd0 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -1206,3 +1206,22 @@ def test_doctest_report_invalid(self, testdir): "*error: argument --doctest-report: invalid choice: 'obviously_invalid_format' (choose from*" ] ) + + +@pytest.mark.parametrize("mock_module", ["mock", "unittest.mock"]) +def test_doctest_mock_objects_dont_recurse_missbehaved(mock_module, testdir): + pytest.importorskip(mock_module) + testdir.makepyfile( + """ + from {mock_module} import call + class Example(object): + ''' + >>> 1 + 1 + 2 + ''' + """.format( + mock_module=mock_module + ) + ) + result = testdir.runpytest("--doctest-modules") + result.stdout.fnmatch_lines(["* 1 passed *"])