pytest-dev / pytest-dev/pytest

Support assertions in `RaisesExc` `check` functions in a `RaisesGroup`

Open
#14,578 6 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

type: proposal
Dominant language
Python
Stars
14.5k
Forks
3.4k
Avg merge
2d 9h
Merged PRs (30d)
35

Description

What's the problem this feature will solve?

When using a RaisesExc check function (either within a with RaisesGroup or (less commonly) directly via with RaisesExc), the error messages can be vague, complicating debugging[^1]. For example:

[^1]: Especially when the check in question only fails occasionally and/or only in remote CI jobs where you can't easily reach for PDB.

def test() -> None:
    def check_BarError(exc: BarError, /) -> bool:
        return (str(exc) == "a" and isinstance(exc.__cause__, FooError))

    with pytest.RaisesGroup(
        pytest.RaisesExc(BarError, check=check_BarError),
    ):
        do_something()

In the check function, either check, or both, can fail. But a failure of either check is reported the same way:

[ ... ]

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/tmp/tmp.DPffGtQk0J/eg.py", line 33, in test
    with pytest.RaisesGroup(
         ~~~~~~~~~~~~~~~~~~^
        pytest.RaisesExc(BarError, check=check_BarError),
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ):
    ^
  File "/tmp/tmp.DPffGtQk0J/.direnv/python-3.14/lib/python3.14/site-packages/_pytest/raises.py", line 1432, in __exit__
    fail(f"Raised exception {group_str} did not match: {self._fail_reason}")
    ~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/tmp.DPffGtQk0J/.direnv/python-3.14/lib/python3.14/site-packages/_pytest/outcomes.py", line 163, in __call__
    raise Failed(msg=reason, pytrace=pytrace)
Failed: Raised exception group did not match: RaisesExc(BarError, check=<function test.<locals>.check_BarError at 0x7f5aac5a8930>): check did not return True

During handling of the above exception, another exception occurred:

    def test() -> None:
        def check_BarError(exc: BarError, /) -> bool:
            return (str(exc) == "a" and isinstance(exc.__cause__, FooError))

>       with pytest.RaisesGroup(
            pytest.RaisesExc(BarError, check=check_BarError),
        ):
E       Failed: Raised exception group did not match: RaisesExc(BarError, check=<function test.<locals>.check_BarError at 0x7f5aac5a8930>): check did not return True

The reported failure reason is ambiguous and does not make debugging easier[^2].

[^2]: In this particular example, the full traceback of the failure does include the exception's message and the line where its __cause__ was raised, so it can be deduced which was the issue, but (in some cases that I hit) with more complicated checks and more complicated tracebacks from service tasks in task groups, it becomes harder to eke out that information from the long traceback (or the Failed and accompanying traceback simply doesn't contain the information to deduce that).

Using assertion rewriting in the example check function makes failures clearer:

def test() -> None:
    def check_BarError(exc: BarError, /) -> bool:
        assert str(exc) == "a"
        assert isinstance(exc.__cause__, FooError)
        return True

    with pytest.RaisesGroup(
        pytest.RaisesExc(BarError, check=check_BarError),
    ):
        do_something()

We get clearer failure reports, e.g.

[... truncated ...]

exc = BarError('not right')

    def check_BarError(exc: BarError, /) -> bool:
>       assert str(exc) == "a"
E       AssertionError: assert 'not right' == 'a'
E
E         - a
E         + not right

and

[... truncated ..]

exc = BarError('a')

    def check_BarError(exc: BarError, /) -> bool:
        assert str(exc) == "a"
>       assert isinstance(exc.__cause__, FooError)
E       AssertionError: assert False
E        +  where False = isinstance(ValueError(), FooError)
E        +    where ValueError() = BarError('a').__cause__

Using assertions in a RaisesExc's check function works well for a RaisesGroup containing only one RaisesExc (and for a bare with raises/RaisesExc), but putting multiple of them into a RaisesGroup causes RaisesGroup to misbehave. A small example:

@pytest.mark.parametrize("order", tuple(range(math.perm(2))))
def test(order: int) -> None:
    def check_foo(exc: ValueError, /) -> bool:
        assert exc.args[0] == "foo"
        return True

    def check_bar(exc: ValueError, /) -> bool:
        assert exc.args[0] == "bar"
        return True

    with pytest.RaisesGroup(
        pytest.RaisesExc(ValueError, check=check_foo),
        pytest.RaisesExc(ValueError, check=check_bar),
    ):
        raise ExceptionGroup(
            "",
            tuple(
                itertools.permutations(
                    (
                        ValueError("foo"),
                        ValueError("bar"),
                    )
                )
            )[order],
        )

One of the orders passes and the other fails. Ideally, both would pass. RaisesGroup is documented as order-agnostic (modulo potential issues related to greedy matching).

Describe the solution you'd like

It would be great if RaisesExc(..., check=i_use_assertions) worked in RaisesGroups that contain multiple RaisesExcs, not just one.

Alternative solutions

Alternative solutions I've tried:

  • Don't use assertions in check functions.

    This made debugging test failures tedous. Pytest's assertion rewriting is very convenient! I ended up usually putting breakpoint()s in check functions and checking properties manually in a REPL.

  • Don't use RaisesGroup; don't do reordering of group members during matching.

    This is a no-go because (with async runtimes) it would have caused overtesting for scheduling order (i.e. it would have caused scheduling-order-dependent test failures).

  • Don't use RaisesGroup; do reordering of group members manually during matching.

    This is what I've currently settled on doing in these cases. Applied to the most recent example, it's

    def assert_matches(
        exception: BaseException, raises: _pytest.raises.AbstractRaises[BaseException]
    ) -> None:
        assert raises.matches(exception), raises.fail_reason
    
    
    @pytest.mark.parametrize("order", tuple(range(math.perm(2))))
    def test(order: int) -> None:
        def check_foo(exc: ValueError, /) -> bool:
            assert exc.args[0] == "foo"
            return True
    
        def check_bar(exc: ValueError, /) -> bool:
            assert exc.args[0] == "bar"
            return True
    
        def check_group(exc: ExceptionGroup[Exception], /) -> bool:
            assert len(exc.exceptions) == 2
            for exceptions in itertools.permutations(exc.exceptions):
                try:
                    # TODO: https://github.com/python/mypy/issues/19304: Remove the
                    # dummy variables.
                    assert_matches(
                        exceptions[0], _ := pytest.RaisesExc(ValueError, check=check_foo)
                    )
                    assert_matches(
                        exceptions[1], _ := pytest.RaisesExc(ValueError, check=check_bar)
                    )
                except AssertionError:  # cov: ignore
                    continue
                else:
                    return True
            return False  # cov: ignore
    
        with pytest.raises(ExceptionGroup, check=check_group):
            raise ExceptionGroup(
                "",
                tuple(
                    itertools.permutations(
                        (
                            ValueError("foo"),
                            ValueError("bar"),
                        )
                    )
                )[order],
            )
    

    It works well, but it's a bit tedious to write.[^3]

    It could also be done with with raises(ExceptionGroup) as exc_info and asserting properties of exc_info.value, but it's just as tedious.

    [^3]: The permutation does in principle blow up quicker than pytest's greedy resolver does, but it does theoretically also have the advantage of avoiding some of the cases where pytest's resolver is too greedy and fails to find the (or a) permutation that succeeds.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by locating the RaisesGroup and RaisesExc matching and check-function handling, then review the existing tests for exception-group matching and assertion failures. Done means assertion-based RaisesExc checks can coexist in a RaisesGroup with multiple checks, regardless of exception order, while preserving clear assertion failure reports.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
testing-qa
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.