Skip to content

Commit

Permalink
Merge pull request #10226 from Zac-HD/use-exceptiongroup-for-teardown
Browse files Browse the repository at this point in the history
Use exceptiongroup for multiple errors during teardown
  • Loading branch information
Zac-HD authored Oct 24, 2022
2 parents a6d2443 + 3a68c08 commit 10f55f7
Show file tree
Hide file tree
Showing 3 changed files with 48 additions and 11 deletions.
1 change: 1 addition & 0 deletions changelog/10226.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
If multiple errors are raised in teardown, we now re-raise an ``ExceptionGroup`` of them instead of discarding all but the last.
24 changes: 17 additions & 7 deletions src/_pytest/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
from _pytest.outcomes import Skipped
from _pytest.outcomes import TEST_OUTCOME

if sys.version_info[:2] < (3, 11):
from exceptiongroup import BaseExceptionGroup

if TYPE_CHECKING:
from typing_extensions import Literal

Expand Down Expand Up @@ -512,22 +515,29 @@ def teardown_exact(self, nextitem: Optional[Item]) -> None:
stack is torn down.
"""
needed_collectors = nextitem and nextitem.listchain() or []
exc = None
exceptions: List[BaseException] = []
while self.stack:
if list(self.stack.keys()) == needed_collectors[: len(self.stack)]:
break
node, (finalizers, _) = self.stack.popitem()
these_exceptions = []
while finalizers:
fin = finalizers.pop()
try:
fin()
except TEST_OUTCOME as e:
# XXX Only first exception will be seen by user,
# ideally all should be reported.
if exc is None:
exc = e
if exc:
raise exc
these_exceptions.append(e)

if len(these_exceptions) == 1:
exceptions.extend(these_exceptions)
elif these_exceptions:
msg = f"errors while tearing down {node!r}"
exceptions.append(BaseExceptionGroup(msg, these_exceptions[::-1]))

if len(exceptions) == 1:
raise exceptions[0]
elif exceptions:
raise BaseExceptionGroup("errors during test teardown", exceptions[::-1])
if nextitem is None:
assert not self.stack

Expand Down
34 changes: 30 additions & 4 deletions testing/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os
import sys
import types
from functools import partial
from pathlib import Path
from typing import Dict
from typing import List
Expand All @@ -19,6 +20,9 @@
from _pytest.outcomes import OutcomeException
from _pytest.pytester import Pytester

if sys.version_info[:2] < (3, 11):
from exceptiongroup import ExceptionGroup


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

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

Expand All @@ -90,9 +92,14 @@ def fin2():
ss.setup(item)
ss.addfinalizer(fin1, item)
ss.addfinalizer(fin2, item)
with pytest.raises(Exception) as err:
with pytest.raises(ExceptionGroup) as err:
ss.teardown_exact(None)
assert err.value.args == ("oops2",)

# Note that finalizers are run LIFO, but because FIFO is more intuitive for
# users we reverse the order of messages, and see the error from fin1 first.
err1, err2 = err.value.exceptions
assert err1.args == ("oops1",)
assert err2.args == ("oops2",)

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

def test_teardown_multiple_scopes_several_fail(self, pytester) -> None:
def raiser(exc):
raise exc

item = pytester.getitem("def test_func(): pass")
mod = item.listchain()[-2]
ss = item.session._setupstate
ss.setup(item)
ss.addfinalizer(partial(raiser, KeyError("from module scope")), mod)
ss.addfinalizer(partial(raiser, TypeError("from function scope 1")), item)
ss.addfinalizer(partial(raiser, ValueError("from function scope 2")), item)

with pytest.raises(ExceptionGroup, match="errors during test teardown") as e:
ss.teardown_exact(None)
mod, func = e.value.exceptions
assert isinstance(mod, KeyError)
assert isinstance(func.exceptions[0], TypeError) # type: ignore
assert isinstance(func.exceptions[1], ValueError) # type: ignore


class BaseFunctionalTests:
def test_passfunction(self, pytester: Pytester) -> None:
Expand Down

0 comments on commit 10f55f7

Please sign in to comment.