Skip to content

Commit 10f55f7

Browse files
authored
Merge pull request #10226 from Zac-HD/use-exceptiongroup-for-teardown
Use exceptiongroup for multiple errors during teardown
2 parents a6d2443 + 3a68c08 commit 10f55f7

File tree

3 files changed

+48
-11
lines changed

3 files changed

+48
-11
lines changed

changelog/10226.improvement.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
If multiple errors are raised in teardown, we now re-raise an ``ExceptionGroup`` of them instead of discarding all but the last.

src/_pytest/runner.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
from _pytest.outcomes import Skipped
3636
from _pytest.outcomes import TEST_OUTCOME
3737

38+
if sys.version_info[:2] < (3, 11):
39+
from exceptiongroup import BaseExceptionGroup
40+
3841
if TYPE_CHECKING:
3942
from typing_extensions import Literal
4043

@@ -512,22 +515,29 @@ def teardown_exact(self, nextitem: Optional[Item]) -> None:
512515
stack is torn down.
513516
"""
514517
needed_collectors = nextitem and nextitem.listchain() or []
515-
exc = None
518+
exceptions: List[BaseException] = []
516519
while self.stack:
517520
if list(self.stack.keys()) == needed_collectors[: len(self.stack)]:
518521
break
519522
node, (finalizers, _) = self.stack.popitem()
523+
these_exceptions = []
520524
while finalizers:
521525
fin = finalizers.pop()
522526
try:
523527
fin()
524528
except TEST_OUTCOME as e:
525-
# XXX Only first exception will be seen by user,
526-
# ideally all should be reported.
527-
if exc is None:
528-
exc = e
529-
if exc:
530-
raise exc
529+
these_exceptions.append(e)
530+
531+
if len(these_exceptions) == 1:
532+
exceptions.extend(these_exceptions)
533+
elif these_exceptions:
534+
msg = f"errors while tearing down {node!r}"
535+
exceptions.append(BaseExceptionGroup(msg, these_exceptions[::-1]))
536+
537+
if len(exceptions) == 1:
538+
raise exceptions[0]
539+
elif exceptions:
540+
raise BaseExceptionGroup("errors during test teardown", exceptions[::-1])
531541
if nextitem is None:
532542
assert not self.stack
533543

testing/test_runner.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33
import sys
44
import types
5+
from functools import partial
56
from pathlib import Path
67
from typing import Dict
78
from typing import List
@@ -19,6 +20,9 @@
1920
from _pytest.outcomes import OutcomeException
2021
from _pytest.pytester import Pytester
2122

23+
if sys.version_info[:2] < (3, 11):
24+
from exceptiongroup import ExceptionGroup
25+
2226

2327
class TestSetupState:
2428
def test_setup(self, pytester: Pytester) -> None:
@@ -77,8 +81,6 @@ def fin3():
7781
assert r == ["fin3", "fin1"]
7882

7983
def test_teardown_multiple_fail(self, pytester: Pytester) -> None:
80-
# Ensure the first exception is the one which is re-raised.
81-
# Ideally both would be reported however.
8284
def fin1():
8385
raise Exception("oops1")
8486

@@ -90,9 +92,14 @@ def fin2():
9092
ss.setup(item)
9193
ss.addfinalizer(fin1, item)
9294
ss.addfinalizer(fin2, item)
93-
with pytest.raises(Exception) as err:
95+
with pytest.raises(ExceptionGroup) as err:
9496
ss.teardown_exact(None)
95-
assert err.value.args == ("oops2",)
97+
98+
# Note that finalizers are run LIFO, but because FIFO is more intuitive for
99+
# users we reverse the order of messages, and see the error from fin1 first.
100+
err1, err2 = err.value.exceptions
101+
assert err1.args == ("oops1",)
102+
assert err2.args == ("oops2",)
96103

97104
def test_teardown_multiple_scopes_one_fails(self, pytester: Pytester) -> None:
98105
module_teardown = []
@@ -113,6 +120,25 @@ def fin_module():
113120
ss.teardown_exact(None)
114121
assert module_teardown == ["fin_module"]
115122

123+
def test_teardown_multiple_scopes_several_fail(self, pytester) -> None:
124+
def raiser(exc):
125+
raise exc
126+
127+
item = pytester.getitem("def test_func(): pass")
128+
mod = item.listchain()[-2]
129+
ss = item.session._setupstate
130+
ss.setup(item)
131+
ss.addfinalizer(partial(raiser, KeyError("from module scope")), mod)
132+
ss.addfinalizer(partial(raiser, TypeError("from function scope 1")), item)
133+
ss.addfinalizer(partial(raiser, ValueError("from function scope 2")), item)
134+
135+
with pytest.raises(ExceptionGroup, match="errors during test teardown") as e:
136+
ss.teardown_exact(None)
137+
mod, func = e.value.exceptions
138+
assert isinstance(mod, KeyError)
139+
assert isinstance(func.exceptions[0], TypeError) # type: ignore
140+
assert isinstance(func.exceptions[1], ValueError) # type: ignore
141+
116142

117143
class BaseFunctionalTests:
118144
def test_passfunction(self, pytester: Pytester) -> None:

0 commit comments

Comments
 (0)