Skip to content

add RaisesGroup & Matcher #13192

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 26 commits into from
Mar 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c93130c
add RaisesGroup & Matcher
jakkdl Feb 4, 2025
4737c8c
add AbstractMatcher support to xfail
jakkdl Feb 4, 2025
e1e1874
rename AbstractMatcher -> AbstractRaises, Matcher->RaisesExc. Add doc…
jakkdl Feb 6, 2025
e090517
Merge branch 'main' into raisesgroup
jakkdl Feb 6, 2025
e73c411
fix test on py<311
jakkdl Feb 6, 2025
c011e9b
fix test, fix references in docstrings
jakkdl Feb 6, 2025
426fe19
Apply suggestions from code review
jakkdl Feb 18, 2025
7f9966b
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 18, 2025
0cdc5da
Update src/_pytest/_raises_group.py
jakkdl Feb 18, 2025
cb30674
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 18, 2025
9714dc0
doc improvements after review
jakkdl Feb 18, 2025
ad6542e
Merge remote-tracking branch 'origin/main' into raisesgroup
jakkdl Feb 18, 2025
4d2c709
fix imports after file rename
jakkdl Feb 18, 2025
9e38a9e
fix another import
jakkdl Feb 18, 2025
ff9dd38
sed s/RaisesGroups/RaisesGroup
jakkdl Feb 18, 2025
2c8cd64
make pytest.raises use RaisesExc... which made me notice about a mill…
jakkdl Feb 20, 2025
09d06fe
fix tests
jakkdl Feb 20, 2025
753df94
harmonize stringify_exception, various comments
jakkdl Feb 21, 2025
4f682c1
fix rtd
jakkdl Feb 24, 2025
edfcc86
Merge branch 'main' into raisesgroup
jakkdl Feb 24, 2025
309030c
fix import loop
jakkdl Feb 24, 2025
4e97652
remove raises_group alias, doc fixes after review
jakkdl Mar 3, 2025
5186f36
Merge remote-tracking branch 'origin/main' into raisesgroup
jakkdl Mar 3, 2025
9163167
Merge remote-tracking branch 'origin/main' into raisesgroup
jakkdl Mar 5, 2025
d37e6d6
Update 11538.feature.rst
jakkdl Mar 5, 2025
4c6ded7
docs fixes after removing the raises_group alias.
jakkdl Mar 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/11538.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added :class:`pytest.RaisesGroup` as an equivalent to :func:`pytest.raises` for expecting :exc:`ExceptionGroup`. Also adds :class:`pytest.RaisesExc` which is now the logic behind :func:`pytest.raises` and used as parameter to :class:`pytest.RaisesGroup`. ``RaisesGroup`` includes the ability to specify multiple different expected exceptions, the structure of nested exception groups, and flags for emulating :ref:`except* <except_star>`. See :ref:`assert-matching-exception-groups` and docstrings for more information.
1 change: 1 addition & 0 deletions changelog/12504.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:func:`pytest.mark.xfail` now accepts :class:`pytest.RaisesGroup` for the ``raises`` parameter when you expect an exception group. You can also pass a :class:`pytest.RaisesExc` if you e.g. want to make use of the ``check`` parameter.
2 changes: 2 additions & 0 deletions doc/en/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@
("py:obj", "_pytest.fixtures.FixtureValue"),
("py:obj", "_pytest.stash.T"),
("py:class", "_ScopeName"),
("py:class", "BaseExcT_1"),
("py:class", "ExcT_1"),
]

add_module_names = False
Expand Down
26 changes: 2 additions & 24 deletions doc/en/getting-started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -97,30 +97,6 @@ Use the :ref:`raises <assertraises>` helper to assert that some code raises an e
with pytest.raises(SystemExit):
f()

You can also use the context provided by :ref:`raises <assertraises>` to
assert that an expected exception is part of a raised :class:`ExceptionGroup`:

.. code-block:: python

# content of test_exceptiongroup.py
import pytest


def f():
raise ExceptionGroup(
"Group message",
[
RuntimeError(),
],
)


def test_exception_in_group():
with pytest.raises(ExceptionGroup) as excinfo:
f()
assert excinfo.group_contains(RuntimeError)
assert not excinfo.group_contains(TypeError)

Execute the test function with “quiet” reporting mode:

.. code-block:: pytest
Expand All @@ -133,6 +109,8 @@ Execute the test function with “quiet” reporting mode:

The ``-q/--quiet`` flag keeps the output brief in this and following examples.

See :ref:`assertraises` for specifying more details about the expected exception.

Group multiple tests in a class
--------------------------------------------------------------

Expand Down
111 changes: 104 additions & 7 deletions doc/en/how-to/assert.rst
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,93 @@ Notes:

.. _`assert-matching-exception-groups`:

Matching exception groups
~~~~~~~~~~~~~~~~~~~~~~~~~
Assertions about expected exception groups
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

When expecting a :exc:`BaseExceptionGroup` or :exc:`ExceptionGroup` you can use :class:`pytest.RaisesGroup`:

.. code-block:: python

def test_exception_in_group():
with pytest.RaisesGroup(ValueError):
raise ExceptionGroup("group msg", [ValueError("value msg")])
with pytest.RaisesGroup(ValueError, TypeError):
raise ExceptionGroup("msg", [ValueError("foo"), TypeError("bar")])


It accepts a ``match`` parameter, that checks against the group message, and a ``check`` parameter that takes an arbitrary callable which it passes the group to, and only succeeds if the callable returns ``True``.

.. code-block:: python

def test_raisesgroup_match_and_check():
with pytest.RaisesGroup(BaseException, match="my group msg"):
raise BaseExceptionGroup("my group msg", [KeyboardInterrupt()])
with pytest.RaisesGroup(
Exception, check=lambda eg: isinstance(eg.__cause__, ValueError)
):
raise ExceptionGroup("", [TypeError()]) from ValueError()

It is strict about structure and unwrapped exceptions, unlike :ref:`except* <except_star>`, so you might want to set the ``flatten_subgroups`` and/or ``allow_unwrapped`` parameters.

.. code-block:: python

def test_structure():
with pytest.RaisesGroup(pytest.RaisesGroup(ValueError)):
raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),))
with pytest.RaisesGroup(ValueError, flatten_subgroups=True):
raise ExceptionGroup("1st group", [ExceptionGroup("2nd group", [ValueError()])])
with pytest.RaisesGroup(ValueError, allow_unwrapped=True):
raise ValueError

To specify more details about the contained exception you can use :class:`pytest.RaisesExc`

.. code-block:: python

def test_raises_exc():
with pytest.RaisesGroup(pytest.RaisesExc(ValueError, match="foo")):
raise ExceptionGroup("", (ValueError("foo")))

They both supply a method :meth:`pytest.RaisesGroup.matches` :meth:`pytest.RaisesExc.matches` if you want to do matching outside of using it as a contextmanager. This can be helpful when checking ``.__context__`` or ``.__cause__``.

.. code-block:: python

def test_matches():
exc = ValueError()
exc_group = ExceptionGroup("", [exc])
if RaisesGroup(ValueError).matches(exc_group):
...
# helpful error is available in `.fail_reason` if it fails to match
r = RaisesExc(ValueError)
assert r.matches(e), r.fail_reason

Check the documentation on :class:`pytest.RaisesGroup` and :class:`pytest.RaisesExc` for more details and examples.

``ExceptionInfo.group_contains()``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. warning::

This helper makes it easy to check for the presence of specific exceptions, but it is very bad for checking that the group does *not* contain *any other exceptions*. So this will pass:

.. code-block:: python

class EXTREMELYBADERROR(BaseException):
"""This is a very bad error to miss"""


def test_for_value_error():
with pytest.raises(ExceptionGroup) as excinfo:
excs = [ValueError()]
if very_unlucky():
excs.append(EXTREMELYBADERROR())
raise ExceptionGroup("", excs)
# This passes regardless of if there's other exceptions.
assert excinfo.group_contains(ValueError)
# You can't simply list all exceptions you *don't* want to get here.


There is no good way of using :func:`excinfo.group_contains() <pytest.ExceptionInfo.group_contains>` to ensure you're not getting *any* other exceptions than the one you expected.
You should instead use :class:`pytest.RaisesGroup`, see :ref:`assert-matching-exception-groups`.

You can also use the :func:`excinfo.group_contains() <pytest.ExceptionInfo.group_contains>`
method to test for exceptions returned as part of an :class:`ExceptionGroup`:
Expand Down Expand Up @@ -194,12 +279,12 @@ exception at a specific level; exceptions contained directly in the top
assert not excinfo.group_contains(RuntimeError, depth=2)
assert not excinfo.group_contains(TypeError, depth=1)

Alternate form (legacy)
~~~~~~~~~~~~~~~~~~~~~~~
Alternate `pytest.raises` form (legacy)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

There is an alternate form where you pass
a function that will be executed, along ``*args`` and ``**kwargs``, and :func:`pytest.raises`
will execute the function with the arguments and assert that the given exception is raised:
There is an alternate form of :func:`pytest.raises` where you pass
a function that will be executed, along with ``*args`` and ``**kwargs``. :func:`pytest.raises`
will then execute the function with those arguments and assert that the given exception is raised:

.. code-block:: python

Expand Down Expand Up @@ -244,6 +329,18 @@ This will only "xfail" if the test fails by raising ``IndexError`` or subclasses
* Using :func:`pytest.raises` is likely to be better for cases where you are
testing exceptions your own code is deliberately raising, which is the majority of cases.

You can also use :class:`pytest.RaisesGroup`:

.. code-block:: python

def f():
raise ExceptionGroup("", [IndexError()])


@pytest.mark.xfail(raises=RaisesGroup(IndexError))
def test_f():
f()


.. _`assertwarns`:

Expand Down
17 changes: 17 additions & 0 deletions doc/en/reference/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,23 @@ PytestPluginManager
:inherited-members:
:show-inheritance:

RaisesExc
~~~~~~~~~

.. autoclass:: pytest.RaisesExc()
:members:

.. autoattribute:: fail_reason

RaisesGroup
~~~~~~~~~~~
**Tutorial**: :ref:`assert-matching-exception-groups`

.. autoclass:: pytest.RaisesGroup()
:members:

.. autoattribute:: fail_reason

TerminalReporter
~~~~~~~~~~~~~~~~

Expand Down
56 changes: 35 additions & 21 deletions src/_pytest/_code/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,32 @@ def recursionindex(self) -> int | None:
return None


def stringify_exception(
exc: BaseException, include_subexception_msg: bool = True
) -> str:
try:
notes = getattr(exc, "__notes__", [])
except KeyError:
# Workaround for https://github.com/python/cpython/issues/98778 on
# Python <= 3.9, and some 3.10 and 3.11 patch versions.
HTTPError = getattr(sys.modules.get("urllib.error", None), "HTTPError", ())
if sys.version_info < (3, 12) and isinstance(exc, HTTPError):
notes = []
else:
raise
if not include_subexception_msg and isinstance(exc, BaseExceptionGroup):
message = exc.message
else:
message = str(exc)

return "\n".join(
[
message,
*notes,
]
)


E = TypeVar("E", bound=BaseException, covariant=True)


Expand Down Expand Up @@ -736,33 +762,14 @@ def getrepr(
)
return fmt.repr_excinfo(self)

def _stringify_exception(self, exc: BaseException) -> str:
try:
notes = getattr(exc, "__notes__", [])
except KeyError:
# Workaround for https://github.com/python/cpython/issues/98778 on
# Python <= 3.9, and some 3.10 and 3.11 patch versions.
HTTPError = getattr(sys.modules.get("urllib.error", None), "HTTPError", ())
if sys.version_info < (3, 12) and isinstance(exc, HTTPError):
notes = []
else:
raise

return "\n".join(
[
str(exc),
*notes,
]
)

def match(self, regexp: str | re.Pattern[str]) -> Literal[True]:
"""Check whether the regular expression `regexp` matches the string
representation of the exception using :func:`python:re.search`.

If it matches `True` is returned, otherwise an `AssertionError` is raised.
"""
__tracebackhide__ = True
value = self._stringify_exception(self.value)
value = stringify_exception(self.value)
msg = f"Regex pattern did not match.\n Regex: {regexp!r}\n Input: {value!r}"
if regexp == value:
msg += "\n Did you mean to `re.escape()` the regex?"
Expand Down Expand Up @@ -794,7 +801,7 @@ def _group_contains(
if not isinstance(exc, expected_exception):
continue
if match is not None:
value = self._stringify_exception(exc)
value = stringify_exception(exc)
if not re.search(match, value):
continue
return True
Expand Down Expand Up @@ -828,6 +835,13 @@ def group_contains(
the exceptions contained within the topmost exception group).

.. versionadded:: 8.0

.. warning::
This helper makes it easy to check for the presence of specific exceptions,
but it is very bad for checking that the group does *not* contain
*any other exceptions*.
You should instead consider using :class:`pytest.RaisesGroup`

"""
msg = "Captured exception is not an instance of `BaseExceptionGroup`"
assert isinstance(self.value, BaseExceptionGroup), msg
Expand Down
6 changes: 5 additions & 1 deletion src/_pytest/mark/structures.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from _pytest.deprecated import check_ispytest
from _pytest.deprecated import MARKED_FIXTURE
from _pytest.outcomes import fail
from _pytest.raises_group import AbstractRaises
from _pytest.scope import _ScopeName
from _pytest.warning_types import PytestUnknownMarkWarning

Expand Down Expand Up @@ -473,7 +474,10 @@ def __call__(
*conditions: str | bool,
reason: str = ...,
run: bool = ...,
raises: None | type[BaseException] | tuple[type[BaseException], ...] = ...,
raises: None
| type[BaseException]
| tuple[type[BaseException], ...]
| AbstractRaises[BaseException] = ...,
strict: bool = ...,
) -> MarkDecorator: ...

Expand Down
4 changes: 2 additions & 2 deletions src/_pytest/outcomes.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ def __init__(
super().__init__(msg)


# Elaborate hack to work around https://github.com/python/mypy/issues/2087.
# Ideally would just be `exit.Exception = Exit` etc.
# We need a callable protocol to add attributes, for discussion see
# https://github.com/python/mypy/issues/2087.

_F = TypeVar("_F", bound=Callable[..., object])
_ET = TypeVar("_ET", bound=type[BaseException])
Expand Down
Loading