Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Wrap assert methods of mock module to clean up traceback #27

Merged
merged 1 commit into from
Jan 27, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,14 @@ module and the plugin were required within a test.
The old fixture ``mock`` still works, but its use is discouraged and will be
removed in version ``1.0``.

Improved reporting of mock call assertion errors
------------------------------------------------

This plugin monkeypatches the mock library to improve pytest output for failures
of mock call assertions like ``Mock.assert_called_with()``. This can be disabled
by setting ``mock_traceback_monkeypatch = false`` in the ini.


Requirements
============

Expand Down
96 changes: 96 additions & 0 deletions pytest_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import sys

import pytest
from distutils.util import strtobool

if sys.version_info >= (3, 3): # pragma: no cover
import unittest.mock as mock_module
Expand Down Expand Up @@ -137,3 +138,98 @@ def mock(mocker):
warnings.warn('"mock" fixture has been deprecated, use "mocker" instead',
DeprecationWarning)
return mocker


_mock_module_patches = []
_mock_module_originals = {}


def assert_wrapper(method, *args, **kwargs):
__tracebackhide__ = True
try:
method(*args, **kwargs)
except AssertionError as e:
raise AssertionError(*e.args)


def wrap_assert_not_called(*args, **kwargs):
__tracebackhide__ = True
assert_wrapper(_mock_module_originals["assert_not_called"],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't you just call the wrapped method directly, and let the AssertionError propagate? Like this:

def wrap_assert_not_called(*args, **kwargs):
    __tracebackhide__ = True
    _mock_module_originals["assert_not_called"](*args, **kwargs)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would defeat the whole purpose :). Then the traceback would look exactly like it did before – the wrapper is hidden, but the assert_not_called frame is displayed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh you are right, I was under the impression that having a local variable named __tracebackhide__ would strip all frames below and including that frame, but it only hides the frame containing the variable so you have to raise a new exception at that point. Got it, thanks! 😄

*args, **kwargs)


def wrap_assert_called_with(*args, **kwargs):
__tracebackhide__ = True
assert_wrapper(_mock_module_originals["assert_called_with"],
*args, **kwargs)


def wrap_assert_called_once_with(*args, **kwargs):
__tracebackhide__ = True
assert_wrapper(_mock_module_originals["assert_called_once_with"],
*args, **kwargs)


def wrap_assert_has_calls(*args, **kwargs):
__tracebackhide__ = True
assert_wrapper(_mock_module_originals["assert_has_calls"],
*args, **kwargs)


def wrap_assert_any_call(*args, **kwargs):
__tracebackhide__ = True
assert_wrapper(_mock_module_originals["assert_any_call"],
*args, **kwargs)


def wrap_assert_methods(config):
"""
Wrap assert methods of mock module so we can hide their traceback
"""
# Make sure we only do this once
if _mock_module_originals:
return

wrappers = {
'assert_not_called': wrap_assert_not_called,
'assert_called_with': wrap_assert_called_with,
'assert_called_once_with': wrap_assert_called_once_with,
'assert_has_calls': wrap_assert_has_calls,
'assert_any_call': wrap_assert_any_call,
}
for method, wrapper in wrappers.items():
try:
original = getattr(mock_module.NonCallableMock, method)
except AttributeError:
continue
_mock_module_originals[method] = original
patcher = mock_module.patch.object(
mock_module.NonCallableMock, method, wrapper)
patcher.start()
_mock_module_patches.append(patcher)
config.add_cleanup(unwrap_assert_methods)


def unwrap_assert_methods():
for patcher in _mock_module_patches:
patcher.stop()
_mock_module_patches[:] = []
_mock_module_originals.clear()


def pytest_addoption(parser):
parser.addini('mock_traceback_monkeypatch',
'Monkeypatch the mock library to improve reporting of the '
'assert_called_... methods',
default=True)


def parse_ini_boolean(value):
if value in (True, False):
return value
return bool(strtobool(value))


def pytest_configure(config):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think for completeness we should also implement pytest_unconfigure to undo all the patches.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I had that – but then the tests break, because the tests actually run a second pytest in the same process. That pytest will also call unconfigure, and the patching will be gone for all following tests.

But see line 209 – that does exactly what you're asking for.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little uneasy of doing this wrapping automatically for everyone which uses the plugin... there are a tons of test suites, some of them doing already some deep dark magic on their on, who knows what this might break? I agree with you it is unlikely, but I think we should add an option to disable this. How about an ini option:

[pytest]
mock_traceback_monkeypatch = false

The default would be true, but users can disable it at will at least. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I agree – it's quite agressive and I felt the pain of this while writing it. But as I can't see any other way to make sure that the patching is there when somebody uses mock in a test, I think a config option would probably be a good way to handle problems. I'll add one.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! 😄

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But see line 209 – that does exactly what you're asking for.

Oops, missed that, thanks!

if parse_ini_boolean(config.getini('mock_traceback_monkeypatch')):
wrap_assert_methods(config)
91 changes: 91 additions & 0 deletions test_pytest_mock.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import os
import platform
import sys
from contextlib import contextmanager

import py.code
import pytest


Expand Down Expand Up @@ -246,3 +249,91 @@ def bar(arg):
assert Foo.bar(arg=10) == 20
Foo.bar.assert_called_once_with(arg=10)
spy.assert_called_once_with(arg=10)


@contextmanager
def assert_traceback():
"""
Assert that this file is at the top of the filtered traceback
"""
try:
yield
except AssertionError:
traceback = py.code.ExceptionInfo().traceback
crashentry = traceback.getcrashentry()
assert crashentry.path == __file__
else:
raise AssertionError("DID NOT RAISE")


@pytest.mark.skipif(sys.version_info[:2] == (3, 4),
reason="assert_not_called not available in python 3.4")
def test_assert_not_called_wrapper(mocker):
stub = mocker.stub()
stub.assert_not_called()
stub()
with assert_traceback():
stub.assert_not_called()


def test_assert_called_with_wrapper(mocker):
stub = mocker.stub()
stub("foo")
stub.assert_called_with("foo")
with assert_traceback():
stub.assert_called_with("bar")


def test_assert_called_once_with_wrapper(mocker):
stub = mocker.stub()
stub("foo")
stub.assert_called_once_with("foo")
stub("foo")
with assert_traceback():
stub.assert_called_once_with("foo")


def test_assert_any_call_wrapper(mocker):
stub = mocker.stub()
stub("foo")
stub("foo")
stub.assert_any_call("foo")
with assert_traceback():
stub.assert_any_call("bar")


def test_assert_has_calls(mocker):
from pytest_mock import mock_module
stub = mocker.stub()
stub("foo")
stub.assert_has_calls([mock_module.call("foo")])
with assert_traceback():
stub.assert_has_calls([mock_module.call("bar")])


def test_monkeypatch_ini(mocker, testdir):
# Make sure the following function actually tests something
stub = mocker.stub()
assert stub.assert_called_with.__module__ != stub.__module__

testdir.makepyfile("""
import py.code
def test_foo(mocker):
stub = mocker.stub()
assert stub.assert_called_with.__module__ == stub.__module__
""")
testdir.makeini("""
[pytest]
mock_traceback_monkeypatch = false
""")

result = testdir.runpytest_subprocess()
assert result.ret == 0


def test_parse_ini_boolean(testdir):
import pytest_mock
assert pytest_mock.parse_ini_boolean('True') is True
assert pytest_mock.parse_ini_boolean('false') is False
with pytest.raises(ValueError):
pytest_mock.parse_ini_boolean('foo')