Merge pull request #10226 from Zac-HD/use-exceptiongroup-for-teardown

Use exceptiongroup for multiple errors during teardown
This commit is contained in:
Zac Hatfield-Dodds 2022-10-24 08:44:02 -07:00 committed by GitHub
commit 10f55f79af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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 TEST_OUTCOME
if sys.version_info[:2] < (3, 11):
from exceptiongroup import BaseExceptionGroup
if TYPE_CHECKING:
from typing_extensions import Literal
@ -512,22 +515,29 @@ class SetupState:
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

View File

@ -2,6 +2,7 @@ import inspect
import os
import sys
import types
from functools import partial
from pathlib import Path
from typing import Dict
from typing import List
@ -19,6 +20,9 @@ from _pytest.monkeypatch import MonkeyPatch
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:
@ -77,8 +81,6 @@ class TestSetupState:
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")
@ -90,9 +92,14 @@ class TestSetupState:
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 = []
@ -113,6 +120,25 @@ class TestSetupState:
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: