Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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` (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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps it would be best to not export RaisesGroup at all, keeping only pytest.raises_group as the official interface?

pytest.raises_group harmonizes well with the existing pytest.raises, plus I don't see any benefit of having two ways of doing the same thing. Another argument for that is that we do not expose the context manager used in pytest.raises either, so I don't think we should do the same here.

I would make the pytest.raises_group the first-class citizen in the API, we can keep RaisesGroup in the docs if we like, but I would make all examples use pytest.raises_group only.

@The-Compiler might want to comment on that given he does a lot of pytest training.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah only having a single name makes sense, but IMO it behaves so much like a class, and will get used a lot like a class, that the snake_case looks very weird. And surely RaisesGroup and RaisesExc should be the same? but raises_exc is then close-to-identically-named to raises which means it should be renamed, or they should be harmonized.

I think these examples look weird, but maybe that's just me?

with raises_group(raises_group(ValueError), raises_exc(match="foo")):
    ...

if raises_group(ValueError).matches(my_exc): # this one is especially bad imo
    ...

rg = raises_group(ValueError)
assert rg.matches(my_exc), rg.fail_reason

@pytest.mark.xfail(raises=raises_group(ValueError))
def foo():
    ...

I guess one might frame it as raises_group is a factory for creating RaisesGroup.. but then the user will assume there's a distinction and a reason for that distinction.

What I'm afraid might happen is people thinking that they need to do

with raises_group(RaisesGroup(ValueError)):
   ...

especially if the type hints specify RaisesGroup... which means that maybe the class itself should be named in snake_case??? Ew. Or people just not considering the possibility that it does a lot more than pytest.raises has historically supported.

In my very personal opinion the ideal would be having pytest.RaisesGroup & pytest.RaisesExc, and pytest.raises being a thin wrapper around pytest.RaisesExc to add support for the legacy calling mode. But if I'm in the minority here then /shrug

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another argument for that is that we do not expose the context manager used in pytest.raises either, so I don't think we should do the same here.

Wait sorry I only now properly parsed this. I don't think you should pattern-match this beast with pytest.raises, it's perhaps an overly engineered beast - but RaisesGroup is way more than just "the context manager" of a function call. It has public attributes in fail_reason & matches that are meant to be used - and it even supports modifying any of the parameters passed to __init__ after creation (though that bypasses the verification in __init__ so it is a bit dangerous).

Copy link
Member

@nicoddemus nicoddemus Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess one might frame it as raises_group is a factory for creating RaisesGroup.. but then the user will assume there's a distinction and a reason for that distinction.

Well at least this is consistent with pytest.raises, which is also just a factory. I personally find it fine to have pytest.raises_group a factory, and a separate RaisesExc as a class, given their usage is different anyway.

As an additional data point, we have pytest.param which is similar in purpose to RaisesExc, so perhaps this is another argument for pytest.raises_exc.

But I get your points, but I'm sure at least some users will wonder why there is pytest.raises and pytest.RaisesGroup.

But if we move forward with having pytest.RaisesGroup I would remove the pytest.raises_group alias, I don't see much benefit of having the alias in place.

But I'm not deadset on this either, just find the inconsistency a bit unnerving and might something to come up for new users. 👍

As I mentioned before, this is excellent work overall, I'm bringing this up because user facing API is important and is something we have overlooked in the past (regarding consistency I mean).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think RaisesGroup and RaisesExc has significant differences in their use, the only difference is that RaisesExc does not support the context-manager use - but I kind of think it should and that RaisesContext should be removed. I've refrained from bundling that as yet another part of this PR, but I'm starting to think I should.

pytest.param is a good data point. And maybe the harmonization ends up with not needing a distinction between raises_exc and raises, in which case we get

with pytest.raises_group(pytest.raises(ValueError), pytest.raises_group(TypeError)):
    ...

I'll give it a go, and if the details on that becomes worthy of extensive review&discussion I'll split it off into another PR.

I'm very happy to get feedback! I've spent an ungodly amount of time on this feature, so I do have very strong personal opinions at this point, but hopefully I can soon let it go into the collective consciousness~

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but I kind of think it should and that RaisesContext should be removed.

Do you mean:

class RaisesContext(AbstractContextManager[_pytest._code.ExceptionInfo[E]]):

Or is that a typo?

But before moving on, I would recommend to wait a few days for other opinions -- it is possible people will say "I agree with @jakkdl and @nicoddemus is being too cautious, this will not be a problem in practice" and we can just move forward, hehehe.

Copy link
Member Author

@jakkdl jakkdl Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, not a typo. The logic in RaisesContext is a ~strict subset of the logic in RaisesGroup, so if I move __enter__ and __exit__ to AbstractMatcher I can make pytest.raises return RaisesExc (and also offload some input validation from pytest.raises to RaisesExc).

That also adds support for e.g. pytest.raises(check=lambda e: isinstance(e.__cause__, ValueError)). #12763

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