runner: fix tracebacks for failed collectors getting longer and longer

Refs https://github.com/pytest-dev/pytest/issues/12204#issuecomment-2081239376
This commit is contained in:
Ran Benita 2024-04-28 11:47:37 +03:00
parent 0b91d5e3e8
commit 3e81cb2f45
3 changed files with 50 additions and 4 deletions

View File

@ -1,4 +1,7 @@
Fix a regression in pytest 8.0 where tracebacks get longer and longer when multiple tests fail due to a shared higher-scope fixture which raised. Fix a regression in pytest 8.0 where tracebacks get longer and longer when multiple tests fail due to a shared higher-scope fixture which raised.
Also fix a similar regression in pytest 5.4 for collectors which raise during setup.
The fix necessitated internal changes which may affect some plugins: The fix necessitated internal changes which may affect some plugins:
- ``FixtureDef.cached_result[2]`` is now a tuple ``(exc, tb)`` instead of ``exc``. - ``FixtureDef.cached_result[2]`` is now a tuple ``(exc, tb)`` instead of ``exc``.
- ``SetupState.stack`` failures are now a tuple ``(exc, tb)`` instead of ``exc``.

View File

@ -5,6 +5,7 @@ import bdb
import dataclasses import dataclasses
import os import os
import sys import sys
import types
from typing import Callable from typing import Callable
from typing import cast from typing import cast
from typing import Dict from typing import Dict
@ -488,8 +489,13 @@ class SetupState:
Tuple[ Tuple[
# Node's finalizers. # Node's finalizers.
List[Callable[[], object]], List[Callable[[], object]],
# Node's exception, if its setup raised. # Node's exception and original traceback, if its setup raised.
Optional[Union[OutcomeException, Exception]], Optional[
Tuple[
Union[OutcomeException, Exception],
Optional[types.TracebackType],
]
],
], ],
] = {} ] = {}
@ -502,7 +508,7 @@ class SetupState:
for col, (finalizers, exc) in self.stack.items(): for col, (finalizers, exc) in self.stack.items():
assert col in needed_collectors, "previous item was not torn down properly" assert col in needed_collectors, "previous item was not torn down properly"
if exc: if exc:
raise exc raise exc[0].with_traceback(exc[1])
for col in needed_collectors[len(self.stack) :]: for col in needed_collectors[len(self.stack) :]:
assert col not in self.stack assert col not in self.stack
@ -511,7 +517,7 @@ class SetupState:
try: try:
col.setup() col.setup()
except TEST_OUTCOME as exc: except TEST_OUTCOME as exc:
self.stack[col] = (self.stack[col][0], exc) self.stack[col] = (self.stack[col][0], (exc, exc.__traceback__))
raise raise
def addfinalizer(self, finalizer: Callable[[], object], node: Node) -> None: def addfinalizer(self, finalizer: Callable[[], object], node: Node) -> None:

View File

@ -142,6 +142,43 @@ class TestSetupState:
assert isinstance(func.exceptions[0], TypeError) # type: ignore assert isinstance(func.exceptions[0], TypeError) # type: ignore
assert isinstance(func.exceptions[1], ValueError) # type: ignore assert isinstance(func.exceptions[1], ValueError) # type: ignore
def test_cached_exception_doesnt_get_longer(self, pytester: Pytester) -> None:
"""Regression test for #12204 (the "BTW" case)."""
pytester.makepyfile(test="")
# If the collector.setup() raises, all collected items error with this
# exception.
pytester.makeconftest(
"""
import pytest
class MyItem(pytest.Item):
def runtest(self) -> None: pass
class MyBadCollector(pytest.Collector):
def collect(self):
return [
MyItem.from_parent(self, name="one"),
MyItem.from_parent(self, name="two"),
MyItem.from_parent(self, name="three"),
]
def setup(self):
1 / 0
def pytest_collect_file(file_path, parent):
if file_path.name == "test.py":
return MyBadCollector.from_parent(parent, name='bad')
"""
)
result = pytester.runpytest_inprocess("--tb=native")
assert result.ret == ExitCode.TESTS_FAILED
failures = result.reprec.getfailures() # type: ignore[attr-defined]
assert len(failures) == 3
lines1 = failures[1].longrepr.reprtraceback.reprentries[0].lines
lines2 = failures[2].longrepr.reprtraceback.reprentries[0].lines
assert len(lines1) == len(lines2)
class BaseFunctionalTests: class BaseFunctionalTests:
def test_passfunction(self, pytester: Pytester) -> None: def test_passfunction(self, pytester: Pytester) -> None: