Skip to content

Commit feaae2f

Browse files
authored
Merge pull request #12264 from bluetech/reraise-with-original-tb
fixtures,runner: fix tracebacks getting longer and longer
2 parents cf90008 + 3e81cb2 commit feaae2f

File tree

5 files changed

+82
-9
lines changed

5 files changed

+82
-9
lines changed

changelog/12204.bugfix.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
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.
2+
3+
Also fix a similar regression in pytest 5.4 for collectors which raise during setup.
4+
5+
The fix necessitated internal changes which may affect some plugins:
6+
- ``FixtureDef.cached_result[2]`` is now a tuple ``(exc, tb)`` instead of ``exc``.
7+
- ``SetupState.stack`` failures are now a tuple ``(exc, tb)`` instead of ``exc``.

src/_pytest/fixtures.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import os
99
from pathlib import Path
1010
import sys
11+
import types
1112
from typing import AbstractSet
1213
from typing import Any
1314
from typing import Callable
@@ -104,8 +105,8 @@
104105
None,
105106
# Cache key.
106107
object,
107-
# Exception if raised.
108-
BaseException,
108+
# The exception and the original traceback.
109+
Tuple[BaseException, Optional[types.TracebackType]],
109110
],
110111
]
111112

@@ -1049,8 +1050,8 @@ def execute(self, request: SubRequest) -> FixtureValue:
10491050
# numpy arrays (#6497).
10501051
if my_cache_key is cache_key:
10511052
if self.cached_result[2] is not None:
1052-
exc = self.cached_result[2]
1053-
raise exc
1053+
exc, exc_tb = self.cached_result[2]
1054+
raise exc.with_traceback(exc_tb)
10541055
else:
10551056
result = self.cached_result[0]
10561057
return result
@@ -1126,7 +1127,7 @@ def pytest_fixture_setup(
11261127
# Don't show the fixture as the skip location, as then the user
11271128
# wouldn't know which test skipped.
11281129
e._use_item_location = True
1129-
fixturedef.cached_result = (None, my_cache_key, e)
1130+
fixturedef.cached_result = (None, my_cache_key, (e, e.__traceback__))
11301131
raise
11311132
fixturedef.cached_result = (result, my_cache_key, None)
11321133
return result

src/_pytest/runner.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import dataclasses
66
import os
77
import sys
8+
import types
89
from typing import Callable
910
from typing import cast
1011
from typing import Dict
@@ -488,8 +489,13 @@ def __init__(self) -> None:
488489
Tuple[
489490
# Node's finalizers.
490491
List[Callable[[], object]],
491-
# Node's exception, if its setup raised.
492-
Optional[Union[OutcomeException, Exception]],
492+
# Node's exception and original traceback, if its setup raised.
493+
Optional[
494+
Tuple[
495+
Union[OutcomeException, Exception],
496+
Optional[types.TracebackType],
497+
]
498+
],
493499
],
494500
] = {}
495501

@@ -502,7 +508,7 @@ def setup(self, item: Item) -> None:
502508
for col, (finalizers, exc) in self.stack.items():
503509
assert col in needed_collectors, "previous item was not torn down properly"
504510
if exc:
505-
raise exc
511+
raise exc[0].with_traceback(exc[1])
506512

507513
for col in needed_collectors[len(self.stack) :]:
508514
assert col not in self.stack
@@ -511,7 +517,7 @@ def setup(self, item: Item) -> None:
511517
try:
512518
col.setup()
513519
except TEST_OUTCOME as exc:
514-
self.stack[col] = (self.stack[col][0], exc)
520+
self.stack[col] = (self.stack[col][0], (exc, exc.__traceback__))
515521
raise
516522

517523
def addfinalizer(self, finalizer: Callable[[], object], node: Node) -> None:

testing/python/fixtures.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3397,6 +3397,28 @@ def test_something():
33973397
["*def gen(qwe123):*", "*fixture*qwe123*not found*", "*1 error*"]
33983398
)
33993399

3400+
def test_cached_exception_doesnt_get_longer(self, pytester: Pytester) -> None:
3401+
"""Regression test for #12204."""
3402+
pytester.makepyfile(
3403+
"""
3404+
import pytest
3405+
@pytest.fixture(scope="session")
3406+
def bad(): 1 / 0
3407+
3408+
def test_1(bad): pass
3409+
def test_2(bad): pass
3410+
def test_3(bad): pass
3411+
"""
3412+
)
3413+
3414+
result = pytester.runpytest_inprocess("--tb=native")
3415+
assert result.ret == ExitCode.TESTS_FAILED
3416+
failures = result.reprec.getfailures() # type: ignore[attr-defined]
3417+
assert len(failures) == 3
3418+
lines1 = failures[1].longrepr.reprtraceback.reprentries[0].lines
3419+
lines2 = failures[2].longrepr.reprtraceback.reprentries[0].lines
3420+
assert len(lines1) == len(lines2)
3421+
34003422

34013423
class TestShowFixtures:
34023424
def test_funcarg_compat(self, pytester: Pytester) -> None:

testing/test_runner.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,43 @@ def raiser(exc):
142142
assert isinstance(func.exceptions[0], TypeError) # type: ignore
143143
assert isinstance(func.exceptions[1], ValueError) # type: ignore
144144

145+
def test_cached_exception_doesnt_get_longer(self, pytester: Pytester) -> None:
146+
"""Regression test for #12204 (the "BTW" case)."""
147+
pytester.makepyfile(test="")
148+
# If the collector.setup() raises, all collected items error with this
149+
# exception.
150+
pytester.makeconftest(
151+
"""
152+
import pytest
153+
154+
class MyItem(pytest.Item):
155+
def runtest(self) -> None: pass
156+
157+
class MyBadCollector(pytest.Collector):
158+
def collect(self):
159+
return [
160+
MyItem.from_parent(self, name="one"),
161+
MyItem.from_parent(self, name="two"),
162+
MyItem.from_parent(self, name="three"),
163+
]
164+
165+
def setup(self):
166+
1 / 0
167+
168+
def pytest_collect_file(file_path, parent):
169+
if file_path.name == "test.py":
170+
return MyBadCollector.from_parent(parent, name='bad')
171+
"""
172+
)
173+
174+
result = pytester.runpytest_inprocess("--tb=native")
175+
assert result.ret == ExitCode.TESTS_FAILED
176+
failures = result.reprec.getfailures() # type: ignore[attr-defined]
177+
assert len(failures) == 3
178+
lines1 = failures[1].longrepr.reprtraceback.reprentries[0].lines
179+
lines2 = failures[2].longrepr.reprtraceback.reprentries[0].lines
180+
assert len(lines1) == len(lines2)
181+
145182

146183
class BaseFunctionalTests:
147184
def test_passfunction(self, pytester: Pytester) -> None:

0 commit comments

Comments
 (0)