diff --git a/changelog/1140.bugfix.rst b/changelog/1140.bugfix.rst new file mode 100644 index 00000000..35a8b9aa --- /dev/null +++ b/changelog/1140.bugfix.rst @@ -0,0 +1 @@ +Deduplicate identical collection errors from multiple workers while preserving different errors and errors from different modules. diff --git a/src/xdist/dsession.py b/src/xdist/dsession.py index 41e62d46..a658c49e 100644 --- a/src/xdist/dsession.py +++ b/src/xdist/dsession.py @@ -55,7 +55,7 @@ def __init__(self, config: pytest.Config) -> None: self.maxfail: int = config.getvalue("maxfail") self.queue: Queue[tuple[str, dict[str, Any]]] = Queue() self._session: pytest.Session | None = None - self._failed_collection_errors: dict[object, bool] = {} + self._failed_collection_errors: set[tuple[str, str]] = set() self._active_nodes: set[WorkerController] = set() self._failed_nodes_count = 0 self._max_worker_restart = get_default_max_worker_restart(self.config) @@ -410,8 +410,10 @@ def _failed_worker_collectreport( ) -> None: # Check we haven't already seen this report (from # another worker). - if rep.longrepr not in self._failed_collection_errors: - self._failed_collection_errors[rep.longrepr] = True + # Exception representations from different workers need not compare equal. + report_key = (rep.nodeid, str(rep.longrepr)) + if report_key not in self._failed_collection_errors: + self._failed_collection_errors.add(report_key) self.config.hook.pytest_collectreport(report=rep) self._handlefailures(rep) diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 4bd7039e..c6295213 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -4,6 +4,7 @@ import re import shutil from typing import cast +import xml.etree.ElementTree as ET import pytest @@ -1722,6 +1723,39 @@ def test_get_xdist_worker_id(self, fake_request: pytest.FixtureRequest) -> None: assert xdist.get_xdist_worker_id(fake_request) == "master" +@pytest.mark.parametrize("error_source", ["same", "worker", "module"]) +def test_collection_error_deduplication( + pytester: pytest.Pytester, error_source: str +) -> None: + source = "raise RuntimeError('collection failure')" + if error_source == "worker": + source = ( + "import os\n" + "raise RuntimeError('failure from ' + os.environ['PYTEST_XDIST_WORKER'])" + ) + pytester.makepyfile(test_bad=source) + if error_source == "module": + pytester.makepyfile(test_other=source) + + result = pytester.runpytest("-n2", "--junitxml=report.xml") + errors = 1 if error_source == "same" else 2 + result.assert_outcomes(errors=errors) + assert result.ret == pytest.ExitCode.TESTS_FAILED + suite = ET.parse(pytester.path / "report.xml").getroot().find("testsuite") + assert suite is not None + assert suite.get("errors") == str(errors) + reports = suite.findall(".//error") + assert len(reports) == errors + if error_source == "worker": + assert any("failure from gw0" in (report.text or "") for report in reports) + assert any("failure from gw1" in (report.text or "") for report in reports) + elif error_source == "module": + assert {case.get("name") for case in suite.findall("testcase")} == { + "test_bad", + "test_other", + } + + def test_collection_crash(pytester: pytest.Pytester) -> None: p1 = pytester.makepyfile( """