From 56be38e6a2766455766829b8baa47d6ffdccd075 Mon Sep 17 00:00:00 2001 From: Andrew Barnes <169967362+Bortlesboat@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:21:26 -0400 Subject: [PATCH] Improve pytester outcome parsing error --- changelog/13369.improvement.rst | 1 + doc/en/how-to/writing_plugins.rst | 8 ++++++++ src/_pytest/pytester.py | 14 +++++++++++++- testing/test_pytester.py | 6 +++++- 4 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 changelog/13369.improvement.rst diff --git a/changelog/13369.improvement.rst b/changelog/13369.improvement.rst new file mode 100644 index 00000000000..b8cb8d244cb --- /dev/null +++ b/changelog/13369.improvement.rst @@ -0,0 +1 @@ +Improved the error from :meth:`pytest.RunResult.assert_outcomes` when a plugin changes or removes pytest's standard terminal summary, and documented how to isolate output-changing plugins. diff --git a/doc/en/how-to/writing_plugins.rst b/doc/en/how-to/writing_plugins.rst index 618c322bf25..079bde6274f 100644 --- a/doc/en/how-to/writing_plugins.rst +++ b/doc/en/how-to/writing_plugins.rst @@ -425,6 +425,14 @@ return a result object, with which we can assert the tests' outcomes. # check that all 4 tests passed result.assert_outcomes(passed=4) +.. note:: + + :meth:`pytest.RunResult.assert_outcomes` parses pytest's standard terminal + summary. A plugin that changes or removes that summary can make outcome + parsing fail. Disable the output-changing plugin for the nested run with + ``-p no:``, or set ``PYTEST_DISABLE_PLUGIN_AUTOLOAD=1`` when the + nested run should not discover third-party plugins. + Additionally it is possible to copy examples to the ``pytester``'s isolated environment before running pytest on it. This way we can abstract the tested logic to separate files, diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index b69b58732ef..80c417843bf 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -563,6 +563,9 @@ def parseoutcomes(self) -> dict[str, int]: ======= 1 failed, 1 passed, 1 warning, 1 error in 0.13s ==== Will return ``{"failed": 1, "passed": 1, "warnings": 1, "errors": 1}``. + + This method parses pytest's standard terminal summary. Plugins that + modify or remove that summary can make the outcomes unavailable. """ return self.parse_summary_nouns(self.outlines) @@ -582,7 +585,13 @@ def parse_summary_nouns(cls, lines) -> dict[str, int]: ret = {noun: int(count) for (count, noun) in outcomes} break else: - raise ValueError("Pytest terminal summary report not found") + raise ValueError( + "Pytest terminal summary report not found. " + "Plugins that modify pytest's terminal output can break outcome " + "parsing. Disable the plugin for the test run, for example with " + "`-p no:`, or disable plugin autoloading with " + "`PYTEST_DISABLE_PLUGIN_AUTOLOAD=1`." + ) to_plural = { "warning": "warnings", @@ -606,6 +615,9 @@ def assert_outcomes( numbers (0 means it didn't occur) in the text output from a test run. ``warnings`` and ``deselected`` are only checked if not None. + + This method requires pytest's standard terminal summary; see + :meth:`parseoutcomes`. """ __tracebackhide__ = True from _pytest.pytester_assertions import assert_outcomes diff --git a/testing/test_pytester.py b/testing/test_pytester.py index f641e9ee8bb..e9a20f1063c 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -299,9 +299,13 @@ def test_assert_outcomes_after_pytest_error(pytester: Pytester) -> None: pytester.makepyfile("def test_foo(): assert True") result = pytester.runpytest("--unexpected-argument") - with pytest.raises(ValueError, match="Pytest terminal summary report not found"): + with pytest.raises(ValueError) as exc_info: result.assert_outcomes(passed=0) + message = str(exc_info.value) + assert "Plugins that modify pytest's terminal output" in message + assert "PYTEST_DISABLE_PLUGIN_AUTOLOAD=1" in message + class TestSysModulesSnapshot: key = "my-test-module"