Added `warns` to assert warnings are thrown

Works in a similar manner to `raises`, but for warnings instead
of exceptions. Also refactored `recwarn.py` so that all the
warning recording and checking use the same core code.
This commit is contained in:
Eric Hunsberger 2015-07-28 19:01:11 -04:00
parent aac371cf07
commit 52b4eb6c46
7 changed files with 394 additions and 139 deletions

View File

@ -1,6 +1,9 @@
2.8.0.dev (compared to 2.7.X) 2.8.0.dev (compared to 2.7.X)
----------------------------- -----------------------------
- Add 'warns' to assert that warnings are thrown (like 'raises').
Thanks to Eric Hunsberger for the PR.
- Fix #683: Do not apply an already applied mark. Thanks ojake for the PR. - Fix #683: Do not apply an already applied mark. Thanks ojake for the PR.
- Deal with capturing failures better so fewer exceptions get lost to - Deal with capturing failures better so fewer exceptions get lost to

View File

@ -1052,8 +1052,8 @@ def getlocation(function, curdir):
# builtin pytest.raises helper # builtin pytest.raises helper
def raises(ExpectedException, *args, **kwargs): def raises(expected_exception, *args, **kwargs):
""" assert that a code block/function call raises @ExpectedException """ assert that a code block/function call raises @expected_exception
and raise a failure exception otherwise. and raise a failure exception otherwise.
This helper produces a ``py.code.ExceptionInfo()`` object. This helper produces a ``py.code.ExceptionInfo()`` object.
@ -1101,23 +1101,23 @@ def raises(ExpectedException, *args, **kwargs):
""" """
__tracebackhide__ = True __tracebackhide__ = True
if ExpectedException is AssertionError: if expected_exception is AssertionError:
# we want to catch a AssertionError # we want to catch a AssertionError
# replace our subclass with the builtin one # replace our subclass with the builtin one
# see https://github.com/pytest-dev/pytest/issues/176 # see https://github.com/pytest-dev/pytest/issues/176
from _pytest.assertion.util import BuiltinAssertionError \ from _pytest.assertion.util import BuiltinAssertionError \
as ExpectedException as expected_exception
msg = ("exceptions must be old-style classes or" msg = ("exceptions must be old-style classes or"
" derived from BaseException, not %s") " derived from BaseException, not %s")
if isinstance(ExpectedException, tuple): if isinstance(expected_exception, tuple):
for exc in ExpectedException: for exc in expected_exception:
if not inspect.isclass(exc): if not inspect.isclass(exc):
raise TypeError(msg % type(exc)) raise TypeError(msg % type(exc))
elif not inspect.isclass(ExpectedException): elif not inspect.isclass(expected_exception):
raise TypeError(msg % type(ExpectedException)) raise TypeError(msg % type(expected_exception))
if not args: if not args:
return RaisesContext(ExpectedException) return RaisesContext(expected_exception)
elif isinstance(args[0], str): elif isinstance(args[0], str):
code, = args code, = args
assert isinstance(code, str) assert isinstance(code, str)
@ -1130,19 +1130,19 @@ def raises(ExpectedException, *args, **kwargs):
py.builtin.exec_(code, frame.f_globals, loc) py.builtin.exec_(code, frame.f_globals, loc)
# XXX didn'T mean f_globals == f_locals something special? # XXX didn'T mean f_globals == f_locals something special?
# this is destroyed here ... # this is destroyed here ...
except ExpectedException: except expected_exception:
return py.code.ExceptionInfo() return py.code.ExceptionInfo()
else: else:
func = args[0] func = args[0]
try: try:
func(*args[1:], **kwargs) func(*args[1:], **kwargs)
except ExpectedException: except expected_exception:
return py.code.ExceptionInfo() return py.code.ExceptionInfo()
pytest.fail("DID NOT RAISE") pytest.fail("DID NOT RAISE")
class RaisesContext(object): class RaisesContext(object):
def __init__(self, ExpectedException): def __init__(self, expected_exception):
self.ExpectedException = ExpectedException self.expected_exception = expected_exception
self.excinfo = None self.excinfo = None
def __enter__(self): def __enter__(self):
@ -1161,7 +1161,7 @@ class RaisesContext(object):
exc_type, value, traceback = tp exc_type, value, traceback = tp
tp = exc_type, exc_type(value), traceback tp = exc_type, exc_type(value), traceback
self.excinfo.__init__(tp) self.excinfo.__init__(tp)
return issubclass(self.excinfo.type, self.ExpectedException) return issubclass(self.excinfo.type, self.expected_exception)
# #
# the basic pytest Function item # the basic pytest Function item
@ -2123,4 +2123,3 @@ def get_scope_node(node, scope):
return node.session return node.session
raise ValueError("unknown scope") raise ValueError("unknown scope")
return node.getparent(cls) return node.getparent(cls)

View File

@ -1,10 +1,14 @@
""" recording warnings during test function execution. """ """ recording warnings during test function execution. """
import inspect
import py
import sys import sys
import warnings import warnings
import pytest
def pytest_funcarg__recwarn(request): @pytest.yield_fixture
def recwarn(request):
"""Return a WarningsRecorder instance that provides these methods: """Return a WarningsRecorder instance that provides these methods:
* ``pop(category=None)``: return last warning matching the category. * ``pop(category=None)``: return last warning matching the category.
@ -13,83 +17,169 @@ def pytest_funcarg__recwarn(request):
See http://docs.python.org/library/warnings.html for information See http://docs.python.org/library/warnings.html for information
on warning categories. on warning categories.
""" """
if sys.version_info >= (2,7):
oldfilters = warnings.filters[:]
warnings.simplefilter('default')
def reset_filters():
warnings.filters[:] = oldfilters
request.addfinalizer(reset_filters)
wrec = WarningsRecorder() wrec = WarningsRecorder()
request.addfinalizer(wrec.finalize) with wrec:
return wrec warnings.simplefilter('default')
yield wrec
def pytest_namespace(): def pytest_namespace():
return {'deprecated_call': deprecated_call} return {'deprecated_call': deprecated_call,
'warns': warns}
def deprecated_call(func, *args, **kwargs): def deprecated_call(func, *args, **kwargs):
""" assert that calling ``func(*args, **kwargs)`` """Assert that ``func(*args, **kwargs)`` triggers a DeprecationWarning.
triggers a DeprecationWarning.
""" """
l = [] wrec = WarningsRecorder()
oldwarn_explicit = getattr(warnings, 'warn_explicit') with wrec:
def warn_explicit(*args, **kwargs): warnings.simplefilter('always') # ensure all warnings are triggered
l.append(args)
oldwarn_explicit(*args, **kwargs)
oldwarn = getattr(warnings, 'warn')
def warn(*args, **kwargs):
l.append(args)
oldwarn(*args, **kwargs)
warnings.warn_explicit = warn_explicit
warnings.warn = warn
try:
ret = func(*args, **kwargs) ret = func(*args, **kwargs)
finally:
warnings.warn_explicit = oldwarn_explicit if not any(r.category is DeprecationWarning for r in wrec):
warnings.warn = oldwarn
if not l:
__tracebackhide__ = True __tracebackhide__ = True
raise AssertionError("%r did not produce DeprecationWarning" %(func,)) raise AssertionError("%r did not produce DeprecationWarning" % (func,))
return ret return ret
class RecordedWarning: def warns(expected_warning, *args, **kwargs):
def __init__(self, message, category, filename, lineno, line): """Assert that code raises a particular class of warning.
Specifically, the input @expected_warning can be a warning class or
tuple of warning classes, and the code must return that warning
(if a single class) or one of those warnings (if a tuple).
This helper produces a list of ``warnings.WarningMessage`` objects,
one for each warning raised.
This function can be used as a context manager, or any of the other ways
``pytest.raises`` can be used::
>>> with warns(RuntimeWarning):
... warnings.warn("my warning", RuntimeWarning)
"""
wcheck = WarningsChecker(expected_warning)
if not args:
return wcheck
elif isinstance(args[0], str):
code, = args
assert isinstance(code, str)
frame = sys._getframe(1)
loc = frame.f_locals.copy()
loc.update(kwargs)
with wcheck:
code = py.code.Source(code).compile()
py.builtin.exec_(code, frame.f_globals, loc)
else:
func = args[0]
with wcheck:
return func(*args[1:], **kwargs)
class RecordedWarning(object):
def __init__(self, message, category, filename, lineno, file, line):
self.message = message self.message = message
self.category = category self.category = category
self.filename = filename self.filename = filename
self.lineno = lineno self.lineno = lineno
self.file = file
self.line = line self.line = line
class WarningsRecorder:
def __init__(self): class WarningsRecorder(object):
self.list = [] """A context manager to record raised warnings.
def showwarning(message, category, filename, lineno, line=0):
self.list.append(RecordedWarning( Adapted from `warnings.catch_warnings`.
message, category, filename, lineno, line)) """
try:
self.old_showwarning(message, category, def __init__(self, module=None):
filename, lineno, line=line) self._module = sys.modules['warnings'] if module is None else module
except TypeError: self._entered = False
# < python2.6 self._list = []
self.old_showwarning(message, category, filename, lineno)
self.old_showwarning = warnings.showwarning @property
warnings.showwarning = showwarning def list(self):
"""The list of recorded warnings."""
return self._list
def __getitem__(self, i):
"""Get a recorded warning by index."""
return self._list[i]
def __iter__(self):
"""Iterate through the recorded warnings."""
return iter(self._list)
def __len__(self):
"""The number of recorded warnings."""
return len(self._list)
def pop(self, cls=Warning): def pop(self, cls=Warning):
""" pop the first recorded warning, raise exception if not exists.""" """Pop the first recorded warning, raise exception if not exists."""
for i, w in enumerate(self.list): for i, w in enumerate(self._list):
if issubclass(w.category, cls): if issubclass(w.category, cls):
return self.list.pop(i) return self._list.pop(i)
__tracebackhide__ = True __tracebackhide__ = True
assert 0, "%r not found in %r" %(cls, self.list) raise AssertionError("%r not found in warning list" % cls)
#def resetregistry(self):
# warnings.onceregistry.clear()
# warnings.__warningregistry__.clear()
def clear(self): def clear(self):
self.list[:] = [] """Clear the list of recorded warnings."""
self._list[:] = []
def finalize(self): def __enter__(self):
warnings.showwarning = self.old_showwarning if self._entered:
__tracebackhide__ = True
raise RuntimeError("Cannot enter %r twice" % self)
self._entered = True
self._filters = self._module.filters
self._module.filters = self._filters[:]
self._showwarning = self._module.showwarning
def showwarning(message, category, filename, lineno,
file=None, line=None):
self._list.append(RecordedWarning(
message, category, filename, lineno, file, line))
# still perform old showwarning functionality
self._showwarning(message, category, filename, lineno,
file=file, line=line)
self._module.showwarning = showwarning
return self
def __exit__(self, *exc_info):
if not self._entered:
__tracebackhide__ = True
raise RuntimeError("Cannot exit %r without entering first" % self)
self._module.filters = self._filters
self._module.showwarning = self._showwarning
class WarningsChecker(WarningsRecorder):
def __init__(self, expected_warning=None, module=None):
super(WarningsChecker, self).__init__(module=module)
msg = ("exceptions must be old-style classes or "
"derived from Warning, not %s")
if isinstance(expected_warning, tuple):
for exc in expected_warning:
if not inspect.isclass(exc):
raise TypeError(msg % type(exc))
elif inspect.isclass(expected_warning):
expected_warning = (expected_warning,)
elif expected_warning is not None:
raise TypeError(msg % type(expected_warning))
self.expected_warning = expected_warning
def __exit__(self, *exc_info):
super(WarningsChecker, self).__exit__(*exc_info)
# only check if we're not currently handling an exception
if all(a is None for a in exc_info):
if self.expected_warning is not None:
if not any(r.category in self.expected_warning for r in self):
__tracebackhide__ = True
pytest.fail("DID NOT WARN")

View File

@ -114,6 +114,16 @@ like documenting unfixed bugs (where the test describes what "should" happen)
or bugs in dependencies. or bugs in dependencies.
.. _`assertwarns`:
Assertions about expected warnings
-----------------------------------------
.. versionadded:: 2.8
You can check that code raises a particular warning using
:ref:`pytest.warns <warns>`.
.. _newreport: .. _newreport:

View File

@ -1,35 +1,91 @@
Asserting deprecation and other warnings Asserting Warnings
===================================================== =====================================================
.. _function_argument: .. _warns:
The recwarn function argument Asserting warnings with the warns function
------------------------------------ -----------------------------------------------
You can use the ``recwarn`` funcarg to assert that code triggers .. versionadded:: 2.8
warnings through the Python warnings system. Here is a simple
self-contained test:: You can check that code raises a particular warning using ``pytest.warns``,
which works in a similar manner to :ref:`raises <assertraises>`::
import warnings
import pytest
def test_warning():
with pytest.warns(UserWarning):
warnings.warn("my warning", UserWarning)
The test will fail if the warning in question is not raised.
You can also call ``pytest.warns`` on a function or code string::
pytest.warns(expected_warning, func, *args, **kwargs)
pytest.warns(expected_warning, "func(*args, **kwargs)")
The function also returns a list of all raised warnings (as
``warnings.WarningMessage`` objects), which you can query for
additional information::
with pytest.warns(RuntimeWarning) as record:
warnings.warn("another warning", RuntimeWarning)
# check that only one warning was raised
assert len(record) == 1
# check that the message matches
assert record[0].message.args[0] == "another warning"
Alternatively, you can examine raised warnings in detail using the
:ref:`recwarn <recwarn>` fixture (see below).
.. _recwarn:
Recording warnings
------------------------
You can record raised warnings either using ``pytest.warns`` or with
the ``recwarn`` fixture.
To record with ``pytest.warns`` without asserting anything about the warnings,
pass ``None`` as the expected warning type::
with pytest.warns(None) as record:
warnings.warn("user", UserWarning)
warnings.warn("runtime", RuntimeWarning)
assert len(record) == 2
assert str(record[0].message) == "user"
assert str(record[1].message) == "runtime"
The ``recwarn`` fixture will record warnings for the whole function::
import warnings
# content of test_recwarn.py
def test_hello(recwarn): def test_hello(recwarn):
from warnings import warn warnings.warn("hello", UserWarning)
warn("hello", DeprecationWarning) assert len(recwarn) == 1
w = recwarn.pop(DeprecationWarning) w = recwarn.pop(UserWarning)
assert issubclass(w.category, DeprecationWarning) assert issubclass(w.category, UserWarning)
assert 'hello' in str(w.message) assert str(w.message) == "hello"
assert w.filename assert w.filename
assert w.lineno assert w.lineno
The ``recwarn`` function argument provides these methods: Both ``recwarn`` and ``pytest.warns`` return the same interface for recorded
warnings: a WarningsRecorder instance. To view the recorded warnings, you can
iterate over this instance, call ``len`` on it to get the number of recorded
warnings, or index into it to get a particular recorded warning. It also
provides these methods:
.. method:: pop(category=None) .. autoclass:: _pytest.recwarn.WarningsRecorder()
:members:
Return last warning matching the category. Each recorded warning has the attributes ``message``, ``category``,
``filename``, ``lineno``, ``file``, and ``line``. The ``category`` is the
.. method:: clear() class of the warning. The ``message`` is the warning itself; calling
``str(message)`` will return the actual message of the warning.
Clear list of warnings
.. _ensuring_function_triggers: .. _ensuring_function_triggers:
@ -44,3 +100,17 @@ that a certain function call triggers a ``DeprecationWarning``::
def test_global(): def test_global():
pytest.deprecated_call(myfunction, 17) pytest.deprecated_call(myfunction, 17)
By default, deprecation warnings will not be caught when using ``pytest.warns``
or ``recwarn``, since the default Python warnings filters hide
DeprecationWarnings. If you wish to record them in your own code, use the
command ``warnings.simplefilter('always')``::
import warnings
import pytest
def test_deprecation(recwarn):
warnings.simplefilter('always')
warnings.warn("deprecated", DeprecationWarning)
assert len(recwarn) == 1
assert recwarn.pop(DeprecationWarning)

View File

@ -34,7 +34,6 @@ class TestRaises:
raise BuiltinAssertionError raise BuiltinAssertionError
""") """)
@pytest.mark.skipif('sys.version < "2.5"')
def test_raises_as_contextmanager(self, testdir): def test_raises_as_contextmanager(self, testdir):
testdir.makepyfile(""" testdir.makepyfile("""
from __future__ import with_statement from __future__ import with_statement

View File

@ -1,24 +1,8 @@
import py, pytest import warnings
from _pytest.recwarn import WarningsRecorder import py
import pytest
from _pytest.recwarn import WarningsChecker, WarningsRecorder
def test_WarningRecorder(recwarn):
showwarning = py.std.warnings.showwarning
rec = WarningsRecorder()
assert py.std.warnings.showwarning != showwarning
assert not rec.list
py.std.warnings.warn_explicit("hello", UserWarning, "xyz", 13)
assert len(rec.list) == 1
py.std.warnings.warn(DeprecationWarning("hello"))
assert len(rec.list) == 2
warn = rec.pop()
assert str(warn.message) == "hello"
l = rec.list
rec.clear()
assert len(rec.list) == 0
assert l is rec.list
pytest.raises(AssertionError, "rec.pop()")
rec.finalize()
assert showwarning == py.std.warnings.showwarning
def test_recwarn_functional(testdir): def test_recwarn_functional(testdir):
reprec = testdir.inline_runsource(""" reprec = testdir.inline_runsource("""
@ -35,6 +19,49 @@ def test_recwarn_functional(testdir):
res = reprec.countoutcomes() res = reprec.countoutcomes()
assert tuple(res) == (2, 0, 0), res assert tuple(res) == (2, 0, 0), res
class TestWarningsRecorderChecker(object):
def test_recording(self, recwarn):
showwarning = py.std.warnings.showwarning
rec = WarningsRecorder()
with rec:
assert py.std.warnings.showwarning != showwarning
assert not rec.list
py.std.warnings.warn_explicit("hello", UserWarning, "xyz", 13)
assert len(rec.list) == 1
py.std.warnings.warn(DeprecationWarning("hello"))
assert len(rec.list) == 2
warn = rec.pop()
assert str(warn.message) == "hello"
l = rec.list
rec.clear()
assert len(rec.list) == 0
assert l is rec.list
pytest.raises(AssertionError, "rec.pop()")
assert showwarning == py.std.warnings.showwarning
def test_typechecking(self):
with pytest.raises(TypeError):
WarningsChecker(5)
with pytest.raises(TypeError):
WarningsChecker(('hi', RuntimeWarning))
with pytest.raises(TypeError):
WarningsChecker([DeprecationWarning, RuntimeWarning])
def test_invalid_enter_exit(self):
# wrap this test in WarningsRecorder to ensure warning state gets reset
with WarningsRecorder():
with pytest.raises(RuntimeError):
rec = WarningsRecorder()
rec.__exit__(None, None, None) # can't exit before entering
with pytest.raises(RuntimeError):
rec = WarningsRecorder()
with rec:
with rec:
pass # can't enter twice
# #
# ============ test pytest.deprecated_call() ============== # ============ test pytest.deprecated_call() ==============
# #
@ -50,35 +77,92 @@ def dep_explicit(i):
py.std.warnings.warn_explicit("dep_explicit", category=DeprecationWarning, py.std.warnings.warn_explicit("dep_explicit", category=DeprecationWarning,
filename="hello", lineno=3) filename="hello", lineno=3)
def test_deprecated_call_raises(): class TestDeprecatedCall(object):
excinfo = pytest.raises(AssertionError, def test_deprecated_call_raises(self):
"pytest.deprecated_call(dep, 3)") excinfo = pytest.raises(AssertionError,
assert str(excinfo).find("did not produce") != -1 "pytest.deprecated_call(dep, 3)")
assert str(excinfo).find("did not produce") != -1
def test_deprecated_call(): def test_deprecated_call(self):
pytest.deprecated_call(dep, 0) pytest.deprecated_call(dep, 0)
def test_deprecated_call_ret(): def test_deprecated_call_ret(self):
ret = pytest.deprecated_call(dep, 0) ret = pytest.deprecated_call(dep, 0)
assert ret == 42 assert ret == 42
def test_deprecated_call_preserves(): def test_deprecated_call_preserves(self):
onceregistry = py.std.warnings.onceregistry.copy() onceregistry = py.std.warnings.onceregistry.copy()
filters = py.std.warnings.filters[:] filters = py.std.warnings.filters[:]
warn = py.std.warnings.warn warn = py.std.warnings.warn
warn_explicit = py.std.warnings.warn_explicit warn_explicit = py.std.warnings.warn_explicit
test_deprecated_call_raises() self.test_deprecated_call_raises()
test_deprecated_call() self.test_deprecated_call()
assert onceregistry == py.std.warnings.onceregistry assert onceregistry == py.std.warnings.onceregistry
assert filters == py.std.warnings.filters assert filters == py.std.warnings.filters
assert warn is py.std.warnings.warn assert warn is py.std.warnings.warn
assert warn_explicit is py.std.warnings.warn_explicit assert warn_explicit is py.std.warnings.warn_explicit
def test_deprecated_explicit_call_raises(): def test_deprecated_explicit_call_raises(self):
pytest.raises(AssertionError, pytest.raises(AssertionError,
"pytest.deprecated_call(dep_explicit, 3)") "pytest.deprecated_call(dep_explicit, 3)")
def test_deprecated_explicit_call(): def test_deprecated_explicit_call(self):
pytest.deprecated_call(dep_explicit, 0) pytest.deprecated_call(dep_explicit, 0)
pytest.deprecated_call(dep_explicit, 0) pytest.deprecated_call(dep_explicit, 0)
class TestWarns(object):
def test_strings(self):
# different messages, b/c Python suppresses multiple identical warnings
source1 = "warnings.warn('w1', RuntimeWarning)"
source2 = "warnings.warn('w2', RuntimeWarning)"
source3 = "warnings.warn('w3', RuntimeWarning)"
pytest.warns(RuntimeWarning, source1)
pytest.raises(pytest.fail.Exception,
lambda: pytest.warns(UserWarning, source2))
pytest.warns(RuntimeWarning, source3)
def test_function(self):
pytest.warns(SyntaxWarning,
lambda msg: warnings.warn(msg, SyntaxWarning), "syntax")
def test_warning_tuple(self):
pytest.warns((RuntimeWarning, SyntaxWarning),
lambda: warnings.warn('w1', RuntimeWarning))
pytest.warns((RuntimeWarning, SyntaxWarning),
lambda: warnings.warn('w2', SyntaxWarning))
pytest.raises(pytest.fail.Exception,
lambda: pytest.warns(
(RuntimeWarning, SyntaxWarning),
lambda: warnings.warn('w3', UserWarning)))
def test_as_contextmanager(self):
with pytest.warns(RuntimeWarning):
warnings.warn("runtime", RuntimeWarning)
with pytest.raises(pytest.fail.Exception):
with pytest.warns(RuntimeWarning):
warnings.warn("user", UserWarning)
with pytest.raises(pytest.fail.Exception):
with pytest.warns(UserWarning):
warnings.warn("runtime", RuntimeWarning)
with pytest.warns(UserWarning):
warnings.warn("user", UserWarning)
def test_record(self):
with pytest.warns(UserWarning) as record:
warnings.warn("user", UserWarning)
assert len(record) == 1
assert str(record[0].message) == "user"
def test_record_only(self):
with pytest.warns(None) as record:
warnings.warn("user", UserWarning)
warnings.warn("runtime", RuntimeWarning)
assert len(record) == 2
assert str(record[0].message) == "user"
assert str(record[1].message) == "runtime"