diff --git a/changelog/6820.improvement.rst b/changelog/6820.improvement.rst new file mode 100644 index 00000000000..75f709a88ea --- /dev/null +++ b/changelog/6820.improvement.rst @@ -0,0 +1 @@ +Add support for asserting complete output with :class:`~pytest.LineMatcher`'s :func:`~pytest.LineMatcher.fnmatch_lines` and :func:`~pytest.LineMatcher.re_match_lines` via a new ``complete`` option. diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index bbc740e9582..07798c01752 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -1653,7 +1653,11 @@ def _log_text(self) -> str: return "\n".join(self._log_output) def fnmatch_lines( - self, lines2: Sequence[str], *, consecutive: bool = False + self, + lines2: Sequence[str], + *, + consecutive: bool = False, + complete: bool = False, ) -> None: """Check lines exist in the output (using :func:`python:fnmatch.fnmatch`). @@ -1663,12 +1667,19 @@ def fnmatch_lines( :param lines2: String patterns to match. :param consecutive: Match lines consecutively? + :param complete: Fail if the output contains lines which were not asserted? """ __tracebackhide__ = True - self._match_lines(lines2, fnmatch, "fnmatch", consecutive=consecutive) + self._match_lines( + lines2, fnmatch, "fnmatch", consecutive=consecutive, complete=complete + ) def re_match_lines( - self, lines2: Sequence[str], *, consecutive: bool = False + self, + lines2: Sequence[str], + *, + consecutive: bool = False, + complete: bool = False, ) -> None: """Check lines exist in the output (using :func:`python:re.match`). @@ -1679,6 +1690,7 @@ def re_match_lines( :param lines2: string patterns to match. :param consecutive: match lines consecutively? + :param complete: Fail if the output contains lines which were not asserted? """ __tracebackhide__ = True self._match_lines( @@ -1686,6 +1698,7 @@ def re_match_lines( lambda name, pat: bool(re.match(pat, name)), "re.match", consecutive=consecutive, + complete=complete, ) def _match_lines( @@ -1695,6 +1708,7 @@ def _match_lines( match_nickname: str, *, consecutive: bool = False, + complete: bool = False, ) -> None: """Underlying implementation of ``fnmatch_lines`` and ``re_match_lines``. @@ -1710,6 +1724,8 @@ def _match_lines( when a match occurs. :param consecutive: Match lines consecutively? + :param complete: + Fail if the output contains lines which were not asserted? """ if not isinstance(lines2, collections.abc.Sequence): raise TypeError(f"invalid type for lines2: {type(lines2).__name__}") @@ -1753,6 +1769,12 @@ def _match_lines( msg = f"remains unmatched: {line!r}" self._log(msg) self._fail(msg) + if complete: + unasserted = extralines + lines1 + if unasserted: + msg = f"output has unasserted lines: {unasserted!r}" + self._log(msg) + self._fail(msg) self._log_output = [] def no_fnmatch_line(self, pat: str) -> None: diff --git a/testing/test_pytester.py b/testing/test_pytester.py index e9a20f1063c..fb133f2ca38 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -549,6 +549,61 @@ def test_linematcher_consecutive() -> None: ] +def test_linematcher_complete() -> None: + lm = LineMatcher(["1", "2", "3"]) + lm.fnmatch_lines(["1", "2", "3"], complete=True) + lm.re_match_lines(["1", "2", "3"], complete=True) + + lm = LineMatcher(["unexpected", "1", "2", "3"]) + with pytest.raises(pytest.fail.Exception) as excinfo: + lm.fnmatch_lines(["1", "2", "3"], complete=True) + assert str(excinfo.value).splitlines() == [ + "nomatch: '1'", + " and: 'unexpected'", + "exact match: '1'", + "exact match: '2'", + "exact match: '3'", + "output has unasserted lines: ['unexpected']", + ] + + lm = LineMatcher(["1", "2", "3", "trailing"]) + with pytest.raises(pytest.fail.Exception) as excinfo: + lm.re_match_lines(["1", "2", "3"], complete=True) + assert str(excinfo.value).splitlines() == [ + "exact match: '1'", + "exact match: '2'", + "exact match: '3'", + "output has unasserted lines: ['trailing']", + ] + + lm = LineMatcher(["0", "1", "2", "3"]) + with pytest.raises(pytest.fail.Exception) as excinfo: + lm.fnmatch_lines(["1", "2", "3"], consecutive=True, complete=True) + assert str(excinfo.value).splitlines() == [ + "nomatch: '1'", + " and: '0'", + "exact match: '1'", + "exact match: '2'", + "exact match: '3'", + "output has unasserted lines: ['0']", + ] + + lm = LineMatcher(["x"]) + with pytest.raises(pytest.fail.Exception) as excinfo: + lm.fnmatch_lines([], complete=True) + assert str(excinfo.value).splitlines() == [ + "output has unasserted lines: ['x']", + ] + + lm = LineMatcher(["1", "1"]) + with pytest.raises(pytest.fail.Exception) as excinfo: + lm.fnmatch_lines(["1"], complete=True) + assert str(excinfo.value).splitlines() == [ + "exact match: '1'", + "output has unasserted lines: ['1']", + ] + + @pytest.mark.parametrize("function", ["no_fnmatch_line", "no_re_match_line"]) def test_linematcher_no_matching(function: str) -> None: if function == "no_fnmatch_line":