Enhance errors for exception/warnings matching (#8508)

Co-authored-by: Florian Bruhin <me@the-compiler.org>
This commit is contained in:
Ronny Pfannschmidt 2022-03-21 03:32:39 +01:00 committed by GitHub
parent 3297bb24a9
commit e9dd3dffab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 53 additions and 50 deletions

View File

@ -0,0 +1,2 @@
Introduce multiline display for warning matching via :py:func:`pytest.warns` and
enhance match comparison for :py:func:`_pytest._code.ExceptionInfo.match` as returned by :py:func:`pytest.raises`.

View File

@ -672,10 +672,11 @@ class ExceptionInfo(Generic[E]):
If it matches `True` is returned, otherwise an `AssertionError` is raised. If it matches `True` is returned, otherwise an `AssertionError` is raised.
""" """
__tracebackhide__ = True __tracebackhide__ = True
msg = "Regex pattern {!r} does not match {!r}." value = str(self.value)
if regexp == str(self.value): msg = f"Regex pattern did not match.\n Regex: {regexp!r}\n Input: {value!r}"
msg += " Did you mean to `re.escape()` the regex?" if regexp == value:
assert re.search(regexp, str(self.value)), msg.format(regexp, str(self.value)) msg += "\n Did you mean to `re.escape()` the regex?"
assert re.search(regexp, value), msg
# Return True to allow for "assert excinfo.match()". # Return True to allow for "assert excinfo.match()".
return True return True

View File

@ -1,6 +1,7 @@
"""Record warnings during test function execution.""" """Record warnings during test function execution."""
import re import re
import warnings import warnings
from pprint import pformat
from types import TracebackType from types import TracebackType
from typing import Any from typing import Any
from typing import Callable from typing import Callable
@ -142,10 +143,11 @@ def warns(
__tracebackhide__ = True __tracebackhide__ = True
if not args: if not args:
if kwargs: if kwargs:
msg = "Unexpected keyword arguments passed to pytest.warns: " argnames = ", ".join(sorted(kwargs))
msg += ", ".join(sorted(kwargs)) raise TypeError(
msg += "\nUse context-manager form instead?" f"Unexpected keyword arguments passed to pytest.warns: {argnames}"
raise TypeError(msg) "\nUse context-manager form instead?"
)
return WarningsChecker(expected_warning, match_expr=match, _ispytest=True) return WarningsChecker(expected_warning, match_expr=match, _ispytest=True)
else: else:
func = args[0] func = args[0]
@ -191,7 +193,7 @@ class WarningsRecorder(warnings.catch_warnings):
if issubclass(w.category, cls): if issubclass(w.category, cls):
return self._list.pop(i) return self._list.pop(i)
__tracebackhide__ = True __tracebackhide__ = True
raise AssertionError("%r not found in warning list" % cls) raise AssertionError(f"{cls!r} not found in warning list")
def clear(self) -> None: def clear(self) -> None:
"""Clear the list of recorded warnings.""" """Clear the list of recorded warnings."""
@ -202,7 +204,7 @@ class WarningsRecorder(warnings.catch_warnings):
def __enter__(self) -> "WarningsRecorder": # type: ignore def __enter__(self) -> "WarningsRecorder": # type: ignore
if self._entered: if self._entered:
__tracebackhide__ = True __tracebackhide__ = True
raise RuntimeError("Cannot enter %r twice" % self) raise RuntimeError(f"Cannot enter {self!r} twice")
_list = super().__enter__() _list = super().__enter__()
# record=True means it's None. # record=True means it's None.
assert _list is not None assert _list is not None
@ -218,7 +220,7 @@ class WarningsRecorder(warnings.catch_warnings):
) -> None: ) -> None:
if not self._entered: if not self._entered:
__tracebackhide__ = True __tracebackhide__ = True
raise RuntimeError("Cannot exit %r without entering first" % self) raise RuntimeError(f"Cannot exit {self!r} without entering first")
super().__exit__(exc_type, exc_val, exc_tb) super().__exit__(exc_type, exc_val, exc_tb)
@ -268,16 +270,17 @@ class WarningsChecker(WarningsRecorder):
__tracebackhide__ = True __tracebackhide__ = True
def found_str():
return pformat([record.message for record in self], indent=2)
# only check if we're not currently handling an exception # only check if we're not currently handling an exception
if exc_type is None and exc_val is None and exc_tb is None: if exc_type is None and exc_val is None and exc_tb is None:
if self.expected_warning is not None: if self.expected_warning is not None:
if not any(issubclass(r.category, self.expected_warning) for r in self): if not any(issubclass(r.category, self.expected_warning) for r in self):
__tracebackhide__ = True __tracebackhide__ = True
fail( fail(
"DID NOT WARN. No warnings of type {} were emitted. " f"DID NOT WARN. No warnings of type {self.expected_warning} were emitted.\n"
"The list of emitted warnings is: {}.".format( f"The list of emitted warnings is: {found_str()}."
self.expected_warning, [each.message for each in self]
)
) )
elif self.match_expr is not None: elif self.match_expr is not None:
for r in self: for r in self:
@ -286,11 +289,8 @@ class WarningsChecker(WarningsRecorder):
break break
else: else:
fail( fail(
"DID NOT WARN. No warnings of type {} matching" f"""\
" ('{}') were emitted. The list of emitted warnings" DID NOT WARN. No warnings of type {self.expected_warning} matching the regex were emitted.
" is: {}.".format( Regex: {self.match_expr}
self.expected_warning, Emitted warnings: {found_str()}"""
self.match_expr,
[each.message for each in self],
)
) )

View File

@ -420,18 +420,20 @@ def test_match_raises_error(pytester: Pytester) -> None:
excinfo.match(r'[123]+') excinfo.match(r'[123]+')
""" """
) )
result = pytester.runpytest() result = pytester.runpytest("--tb=short")
assert result.ret != 0 assert result.ret != 0
exc_msg = "Regex pattern '[[]123[]]+' does not match 'division by zero'." match = [
result.stdout.fnmatch_lines([f"E * AssertionError: {exc_msg}"]) r"E .* AssertionError: Regex pattern did not match.",
r"E .* Regex: '\[123\]\+'",
r"E .* Input: 'division by zero'",
]
result.stdout.re_match_lines(match)
result.stdout.no_fnmatch_line("*__tracebackhide__ = True*") result.stdout.no_fnmatch_line("*__tracebackhide__ = True*")
result = pytester.runpytest("--fulltrace") result = pytester.runpytest("--fulltrace")
assert result.ret != 0 assert result.ret != 0
result.stdout.fnmatch_lines( result.stdout.re_match_lines([r".*__tracebackhide__ = True.*", *match])
["*__tracebackhide__ = True*", f"E * AssertionError: {exc_msg}"]
)
class TestFormattedExcinfo: class TestFormattedExcinfo:

View File

@ -191,10 +191,12 @@ class TestRaises:
int("asdf") int("asdf")
msg = "with base 16" msg = "with base 16"
expr = "Regex pattern {!r} does not match \"invalid literal for int() with base 10: 'asdf'\".".format( expr = (
msg "Regex pattern did not match.\n"
f" Regex: {msg!r}\n"
" Input: \"invalid literal for int() with base 10: 'asdf'\""
) )
with pytest.raises(AssertionError, match=re.escape(expr)): with pytest.raises(AssertionError, match="(?m)" + re.escape(expr)):
with pytest.raises(ValueError, match=msg): with pytest.raises(ValueError, match=msg):
int("asdf", base=10) int("asdf", base=10)
@ -217,7 +219,7 @@ class TestRaises:
with pytest.raises(AssertionError, match="'foo"): with pytest.raises(AssertionError, match="'foo"):
raise AssertionError("'bar") raise AssertionError("'bar")
(msg,) = excinfo.value.args (msg,) = excinfo.value.args
assert msg == 'Regex pattern "\'foo" does not match "\'bar".' assert msg == '''Regex pattern did not match.\n Regex: "'foo"\n Input: "'bar"'''
def test_match_failure_exact_string_message(self): def test_match_failure_exact_string_message(self):
message = "Oh here is a message with (42) numbers in parameters" message = "Oh here is a message with (42) numbers in parameters"
@ -226,9 +228,10 @@ class TestRaises:
raise AssertionError(message) raise AssertionError(message)
(msg,) = excinfo.value.args (msg,) = excinfo.value.args
assert msg == ( assert msg == (
"Regex pattern 'Oh here is a message with (42) numbers in " "Regex pattern did not match.\n"
"parameters' does not match 'Oh here is a message with (42) " " Regex: 'Oh here is a message with (42) numbers in parameters'\n"
"numbers in parameters'. Did you mean to `re.escape()` the regex?" " Input: 'Oh here is a message with (42) numbers in parameters'\n"
" Did you mean to `re.escape()` the regex?"
) )
def test_raises_match_wrong_type(self): def test_raises_match_wrong_type(self):

View File

@ -1,4 +1,3 @@
import re
import warnings import warnings
from typing import Optional from typing import Optional
@ -263,7 +262,7 @@ class TestWarns:
with pytest.warns(RuntimeWarning): with pytest.warns(RuntimeWarning):
warnings.warn("user", UserWarning) warnings.warn("user", UserWarning)
excinfo.match( excinfo.match(
r"DID NOT WARN. No warnings of type \(.+RuntimeWarning.+,\) were emitted. " r"DID NOT WARN. No warnings of type \(.+RuntimeWarning.+,\) were emitted.\n"
r"The list of emitted warnings is: \[UserWarning\('user',?\)\]." r"The list of emitted warnings is: \[UserWarning\('user',?\)\]."
) )
@ -271,15 +270,15 @@ class TestWarns:
with pytest.warns(UserWarning): with pytest.warns(UserWarning):
warnings.warn("runtime", RuntimeWarning) warnings.warn("runtime", RuntimeWarning)
excinfo.match( excinfo.match(
r"DID NOT WARN. No warnings of type \(.+UserWarning.+,\) were emitted. " r"DID NOT WARN. No warnings of type \(.+UserWarning.+,\) were emitted.\n"
r"The list of emitted warnings is: \[RuntimeWarning\('runtime',?\)\]." r"The list of emitted warnings is: \[RuntimeWarning\('runtime',?\)]."
) )
with pytest.raises(pytest.fail.Exception) as excinfo: with pytest.raises(pytest.fail.Exception) as excinfo:
with pytest.warns(UserWarning): with pytest.warns(UserWarning):
pass pass
excinfo.match( excinfo.match(
r"DID NOT WARN. No warnings of type \(.+UserWarning.+,\) were emitted. " r"DID NOT WARN. No warnings of type \(.+UserWarning.+,\) were emitted.\n"
r"The list of emitted warnings is: \[\]." r"The list of emitted warnings is: \[\]."
) )
@ -289,18 +288,14 @@ class TestWarns:
warnings.warn("runtime", RuntimeWarning) warnings.warn("runtime", RuntimeWarning)
warnings.warn("import", ImportWarning) warnings.warn("import", ImportWarning)
message_template = ( messages = [each.message for each in warninfo]
"DID NOT WARN. No warnings of type {0} were emitted. " expected_str = (
"The list of emitted warnings is: {1}." f"DID NOT WARN. No warnings of type {warning_classes} were emitted.\n"
) f"The list of emitted warnings is: {messages}."
excinfo.match(
re.escape(
message_template.format(
warning_classes, [each.message for each in warninfo]
)
)
) )
assert str(excinfo.value) == expected_str
def test_record(self) -> None: def test_record(self) -> None:
with pytest.warns(UserWarning) as record: with pytest.warns(UserWarning) as record:
warnings.warn("user", UserWarning) warnings.warn("user", UserWarning)