Skip to content
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

add RaisesGroup & Matcher #13192

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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` (also export as ``pytest.raises_group``) and :class:`pytest.RaisesExc`, as an equivalent to :func:`pytest.raises` for expecting :exc:`ExceptionGroup`. It 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
89 changes: 87 additions & 2 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`, also available as :class:`pytest.raises_group <pytest.RaisesGroup>`:

.. code-block:: python

def test_exception_in_group():
with pytest.raises_group(ValueError):
raise ExceptionGroup("group msg", [ValueError("value msg")])
with pytest.raises_group(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.raises_group(BaseException, match="my group msg"):
raise BaseExceptionGroup("my group msg", [KeyboardInterrupt()])
with pytest.raises_group(
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.raises_group(pytest.raises_group(ValueError)):
raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),))
with pytest.raises_group(ValueError, flatten_subgroups=True):
raise ExceptionGroup("1st group", [ExceptionGroup("2nd group", [ValueError()])])
with pytest.raises_group(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.raises_group(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.raises_group <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
17 changes: 17 additions & 0 deletions doc/en/reference/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,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
7 changes: 7 additions & 0 deletions src/_pytest/_code/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,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.raises_group <pytest.RaisesGroup>`
"""
msg = "Captured exception is not an instance of `BaseExceptionGroup`"
assert isinstance(self.value, BaseExceptionGroup), msg
Expand Down
Loading