diff --git a/changelog/6003.improvement.rst b/changelog/6003.improvement.rst new file mode 100644 index 00000000000..181dbc24f13 --- /dev/null +++ b/changelog/6003.improvement.rst @@ -0,0 +1 @@ +Improve short excinfo with LineMatcher failures in short test summaries, via new ``OutcomeException.short_msg``. diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index c35125b749c..87a518c074a 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -537,6 +537,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) if isinstance(self.value, OutcomeException): # Remove module prefix. diff --git a/src/_pytest/outcomes.py b/src/_pytest/outcomes.py index d298ace6c4d..45c9086b210 100644 --- a/src/_pytest/outcomes.py +++ b/src/_pytest/outcomes.py @@ -17,7 +17,13 @@ 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 +33,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 repr(self) TEST_OUTCOME = (OutcomeException, Exception) @@ -110,7 +127,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. @@ -119,7 +138,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 8a0e3ac55bf..e02f5a676e2 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -1413,7 +1413,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.lstrip()) + 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 d5a283317f5..5003d353174 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -493,6 +493,21 @@ def test_linematcher_match_failure(): ] +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): if function == "no_fnmatch_line": diff --git a/testing/test_runner.py b/testing/test_runner.py index 86e9bddffdb..bc7eef2f0b0 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -559,8 +559,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 bc5ddfbe9a4..02ac5a09356 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -758,7 +758,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") @@ -766,6 +776,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 *", ] )