From 2a06e766c9e750662086769365e44d185f51b3a8 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Sat, 19 Oct 2019 03:18:06 +0200 Subject: [PATCH] summary/OutcomeException: add support for short_msg This is meant to improve the short msg with `-r`, where you want to see the non-matched line, and not the first line (that might have matched), from pytester's `_match_lines`. This will also come in handy for a better `__repr__`. --- changelog/6003.feature.rst | 1 + src/_pytest/_code/code.py | 8 ++++++++ src/_pytest/outcomes.py | 30 ++++++++++++++++++++++++------ src/_pytest/pytester.py | 4 +++- testing/test_outcomes.py | 11 +++++++++++ testing/test_pytester.py | 15 +++++++++++++++ testing/test_runner.py | 9 +++++++-- testing/test_terminal.py | 14 +++++++++++++- 8 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 changelog/6003.feature.rst create mode 100644 testing/test_outcomes.py diff --git a/changelog/6003.feature.rst b/changelog/6003.feature.rst new file mode 100644 index 00000000000..dcbdef96492 --- /dev/null +++ b/changelog/6003.feature.rst @@ -0,0 +1 @@ +Improve short excinfo with LineMatcher failures, via new ``OutcomeException.short_msg``. diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 1d26d94ab58..8409bdaad09 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -26,6 +26,7 @@ import _pytest from _pytest._io.saferepr import safeformat from _pytest._io.saferepr import saferepr +from _pytest.outcomes import OutcomeException if False: # TYPE_CHECKING from typing import Type @@ -519,6 +520,13 @@ def exconly(self, tryshort: bool = False) -> str: the exception representation is returned (so 'AssertionError: ' is removed from the beginning) """ + if ( + tryshort + and isinstance(self.value, OutcomeException) + and self.value.short_msg + ): + return self.value.short_msg + lines = format_exception_only(self.type, self.value) text = "".join(lines) text = text.rstrip() diff --git a/src/_pytest/outcomes.py b/src/_pytest/outcomes.py index 94713662571..457f7a4271a 100644 --- a/src/_pytest/outcomes.py +++ b/src/_pytest/outcomes.py @@ -17,7 +17,12 @@ class OutcomeException(BaseException): contain info about test and collection outcomes. """ - def __init__(self, msg: Optional[str] = None, pytrace: bool = True) -> None: + def __init__( + self, + msg: Optional[str] = None, + pytrace: bool = True, + short_msg: Optional[str] = None, + ) -> None: if msg is not None and not isinstance(msg, str): error_msg = ( "{} expected string as 'msg' parameter, got '{}' instead.\n" @@ -27,13 +32,24 @@ def __init__(self, msg: Optional[str] = None, pytrace: bool = True) -> None: BaseException.__init__(self, msg) self.msg = msg self.pytrace = pytrace + self.short_msg = short_msg def __repr__(self) -> str: + if self.short_msg: + return "<{} short_msg={!r}>".format(self.__class__.__name__, self.short_msg) + msg = self.msg + if msg: + lines = msg.split("\n", maxsplit=1) + if len(lines) > 1: + msg = lines[0] + "..." + else: + msg = lines[0] + return "<{} msg={!r}>".format(self.__class__.__name__, msg) + + def __str__(self) -> str: if self.msg: return self.msg - return "<{} instance>".format(self.__class__.__name__) - - __str__ = __repr__ + return self.__repr__() TEST_OUTCOME = (OutcomeException, Exception) @@ -116,7 +132,9 @@ def skip(msg: str = "", *, allow_module_level: bool = False) -> "NoReturn": skip.Exception = Skipped # type: ignore -def fail(msg: str = "", pytrace: bool = True) -> "NoReturn": +def fail( + msg: str = "", pytrace: bool = True, short_msg: Optional[str] = None +) -> "NoReturn": """ Explicitly fail an executing test with the given message. @@ -125,7 +143,7 @@ def fail(msg: str = "", pytrace: bool = True) -> "NoReturn": python traceback will be reported. """ __tracebackhide__ = True - raise Failed(msg=msg, pytrace=pytrace) + raise Failed(msg=msg, pytrace=pytrace, short_msg=short_msg) # Ignore type because of https://github.com/python/mypy/issues/2087. diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index a050dad09e5..a87ea8fbc13 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -1371,7 +1371,9 @@ def _match_lines(self, lines2, match_func, match_nickname): extralines.append(nextline) else: self._log("remains unmatched: {!r}".format(line)) - pytest.fail(self._log_text) + pytest.fail( + self._log_text, short_msg="remains unmatched: {!r}".format(line) + ) def no_fnmatch_line(self, pat): """Ensure captured lines do not match the given pattern, using ``fnmatch.fnmatch``. diff --git a/testing/test_outcomes.py b/testing/test_outcomes.py new file mode 100644 index 00000000000..2e72c9e7bc8 --- /dev/null +++ b/testing/test_outcomes.py @@ -0,0 +1,11 @@ +from _pytest.outcomes import OutcomeException + + +def test_OutcomeException(): + assert repr(OutcomeException()) == "" + assert repr(OutcomeException(msg="msg")) == "" + assert repr(OutcomeException(msg="msg\nline2")) == "" + assert ( + repr(OutcomeException(short_msg="short")) + == "" + ) diff --git a/testing/test_pytester.py b/testing/test_pytester.py index f8b0896c5fd..4039559755f 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -457,6 +457,21 @@ def test_linematcher_with_nonlist(): assert lm._getlines(set()) == set() +def test_linematcher_fnmatch_lines(): + lm = LineMatcher(["1", "2", "3"]) + with pytest.raises(pytest.fail.Exception) as excinfo: + lm.fnmatch_lines(["2", "last_unmatched"]) + assert excinfo.value.short_msg == "remains unmatched: 'last_unmatched'" + assert str(excinfo.value).splitlines() == [ + "nomatch: '2'", + " and: '1'", + "exact match: '2'", + "nomatch: 'last_unmatched'", + " and: '3'", + "remains unmatched: 'last_unmatched'", + ] + + @pytest.mark.parametrize("function", ["no_fnmatch_line", "no_re_match_line"]) def test_no_matching(function): """""" diff --git a/testing/test_runner.py b/testing/test_runner.py index 2d6b8476c10..8b6d3ac6ab8 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -550,8 +550,13 @@ def test_pytest_exit(): def test_pytest_fail(): with pytest.raises(pytest.fail.Exception) as excinfo: pytest.fail("hello") - s = excinfo.exconly(tryshort=True) - assert s.startswith("Failed") + assert excinfo.exconly(tryshort=True) == "Failed: hello" + assert excinfo.exconly(tryshort=False) == "Failed: hello" + + with pytest.raises(pytest.fail.Exception) as excinfo: + pytest.fail("hello", short_msg="short message") + assert excinfo.exconly(tryshort=True) == "short message" + assert excinfo.exconly(tryshort=False) == "Failed: hello" def test_pytest_exit_msg(testdir): diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 3bdabc5dee8..905ab3e8df6 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -757,7 +757,17 @@ def test(i): def test_fail_extra_reporting(testdir, monkeypatch): monkeypatch.setenv("COLUMNS", "80") - testdir.makepyfile("def test_this(): assert 0, 'this_failed' * 100") + testdir.makepyfile( + """ + def test_this(): + assert 0, 'this_failed' * 100 + + def test_linematcher(): + from _pytest.pytester import LineMatcher + + LineMatcher(["1", "2", "3"]).fnmatch_lines(["2", "last_unmatched"]) + """ + ) result = testdir.runpytest() result.stdout.no_fnmatch_line("*short test summary*") result = testdir.runpytest("-rf") @@ -765,6 +775,8 @@ def test_fail_extra_reporting(testdir, monkeypatch): [ "*test summary*", "FAILED test_fail_extra_reporting.py::test_this - AssertionError: this_failedt...", + "FAILED test_fail_extra_reporting.py::test_linematcher - remains unmatched: 'l...", + "*= 2 failed in *", ] )