Use exceptiongroup for teardown errors

This commit is contained in:
Zac Hatfield-Dodds 2022-10-23 15:06:29 -07:00
parent 9e1804a6ee
commit 3a68c08426
3 changed files with 48 additions and 11 deletions

View File

@ -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.

View File

@ -35,6 +35,9 @@ from _pytest.outcomes import OutcomeException
from _pytest.outcomes import Skipped from _pytest.outcomes import Skipped
from _pytest.outcomes import TEST_OUTCOME from _pytest.outcomes import TEST_OUTCOME
if sys.version_info[:2] < (3, 11):
from exceptiongroup import BaseExceptionGroup
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import Literal from typing_extensions import Literal
@ -512,22 +515,29 @@ class SetupState:
stack is torn down. stack is torn down.
""" """
needed_collectors = nextitem and nextitem.listchain() or [] needed_collectors = nextitem and nextitem.listchain() or []
exc = None exceptions: List[BaseException] = []
while self.stack: while self.stack:
if list(self.stack.keys()) == needed_collectors[: len(self.stack)]: if list(self.stack.keys()) == needed_collectors[: len(self.stack)]:
break break
node, (finalizers, _) = self.stack.popitem() node, (finalizers, _) = self.stack.popitem()
these_exceptions = []
while finalizers: while finalizers:
fin = finalizers.pop() fin = finalizers.pop()
try: try:
fin() fin()
except TEST_OUTCOME as e: except TEST_OUTCOME as e:
# XXX Only first exception will be seen by user, these_exceptions.append(e)
# ideally all should be reported.
if exc is None: if len(these_exceptions) == 1:
exc = e exceptions.extend(these_exceptions)
if exc: elif these_exceptions:
raise exc 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: if nextitem is None:
assert not self.stack assert not self.stack

View File

@ -2,6 +2,7 @@ import inspect
import os import os
import sys import sys
import types import types
from functools import partial
from pathlib import Path from pathlib import Path
from typing import Dict from typing import Dict
from typing import List from typing import List
@ -19,6 +20,9 @@ from _pytest.monkeypatch import MonkeyPatch
from _pytest.outcomes import OutcomeException from _pytest.outcomes import OutcomeException
from _pytest.pytester import Pytester from _pytest.pytester import Pytester
if sys.version_info[:2] < (3, 11):
from exceptiongroup import ExceptionGroup
class TestSetupState: class TestSetupState:
def test_setup(self, pytester: Pytester) -> None: def test_setup(self, pytester: Pytester) -> None:
@ -77,8 +81,6 @@ class TestSetupState:
assert r == ["fin3", "fin1"] assert r == ["fin3", "fin1"]
def test_teardown_multiple_fail(self, pytester: Pytester) -> None: 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(): def fin1():
raise Exception("oops1") raise Exception("oops1")
@ -90,9 +92,14 @@ class TestSetupState:
ss.setup(item) ss.setup(item)
ss.addfinalizer(fin1, item) ss.addfinalizer(fin1, item)
ss.addfinalizer(fin2, item) ss.addfinalizer(fin2, item)
with pytest.raises(Exception) as err: with pytest.raises(ExceptionGroup) as err:
ss.teardown_exact(None) 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: def test_teardown_multiple_scopes_one_fails(self, pytester: Pytester) -> None:
module_teardown = [] module_teardown = []
@ -113,6 +120,25 @@ class TestSetupState:
ss.teardown_exact(None) ss.teardown_exact(None)
assert module_teardown == ["fin_module"] 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: class BaseFunctionalTests:
def test_passfunction(self, pytester: Pytester) -> None: def test_passfunction(self, pytester: Pytester) -> None: