diff --git a/docs/changelog.rst b/docs/changelog.rst
index 92e9b47..a9114a3 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -4,6 +4,10 @@ Changelog
`CalVer, YY.month.patch `_
+27.7.1
+======
+- Add :ref:`ASYNC401 ` pytest-raises-exception-group, recommending ``pytest.RaisesGroup`` over ``pytest.raises(ExceptionGroup)``. `(issue #430) `_
+
26.6.1
======
- Add :ref:`ASYNC127 ` unmaintained-httpx: use ``httpx2`` instead of ``httpx``, which is no longer maintained, to get security updates. `(issue #460) `_
diff --git a/docs/rules.rst b/docs/rules.rst
index 47d7f6d..f7c3ee1 100644
--- a/docs/rules.rst
+++ b/docs/rules.rst
@@ -220,6 +220,9 @@ ExceptionGroup rules
_`ASYNC400` : except-star-invalid-attribute
When converting a codebase to use `except* ` it's easy to miss that the caught exception(s) are wrapped in a group, so accessing attributes on the caught exception must now check the contained exceptions. This checks for any attribute access on a caught ``except*`` that's not a known valid attribute on `ExceptionGroup`. This can be safely disabled on a type-checked or coverage-covered code base.
+_`ASYNC401` : pytest-raises-exception-group
+ ``pytest.raises(ExceptionGroup)`` and ``pytest.raises(BaseExceptionGroup)`` usually hide the structure of exception groups. Prefer ``pytest.RaisesGroup``.
+
Optional rules disabled by default
==================================
diff --git a/flake8_async/visitors/visitor4xx.py b/flake8_async/visitors/visitor4xx.py
index 602b9c1..90cdf56 100644
--- a/flake8_async/visitors/visitor4xx.py
+++ b/flake8_async/visitors/visitor4xx.py
@@ -1,6 +1,7 @@
"""4XX error classes, which handle exception groups.
ASYNC400 except-star-invalid-attribute checks for invalid attribute access on except*
+ASYNC401 pytest-raises-exception-group checks for pytest.raises(ExceptionGroup)
"""
from __future__ import annotations
@@ -96,3 +97,51 @@ def visit_FunctionDef(
visit_AsyncFunctionDef = visit_FunctionDef
visit_Lambda = visit_FunctionDef
+
+
+EXCGROUP_QUALNAMES = (
+ "ExceptionGroup",
+ "BaseExceptionGroup",
+ "builtins.ExceptionGroup",
+ "builtins.BaseExceptionGroup",
+ "exceptiongroup.ExceptionGroup",
+ "exceptiongroup.BaseExceptionGroup",
+)
+
+
+@error_class
+class Visitor401(Flake8AsyncVisitor):
+ error_codes: Mapping[str, str] = {
+ "ASYNC401": (
+ "Use `pytest.RaisesGroup` instead of `pytest.raises({})` when expecting"
+ " exception groups."
+ )
+ }
+
+ def _exception_group_name(self, node: ast.expr) -> str | None:
+ if isinstance(node, ast.Tuple):
+ for elt in node.elts:
+ if name := self._exception_group_name(elt):
+ return name
+ return None
+
+ canonical = self.canonical_name(node)
+ if canonical in EXCGROUP_QUALNAMES:
+ return ast.unparse(node)
+ return None
+
+ def _expected_exception_arg(self, node: ast.Call) -> ast.expr | None:
+ if node.args:
+ return node.args[0]
+ for kw in node.keywords:
+ if kw.arg == "expected_exception":
+ return kw.value
+ return None
+
+ def visit_Call(self, node: ast.Call):
+ if (
+ self.canonical_name(node.func) == "pytest.raises"
+ and (expected_exception := self._expected_exception_arg(node)) is not None
+ and (exception_group := self._exception_group_name(expected_exception))
+ ):
+ self.error(node, exception_group)
diff --git a/tests/eval_files/async401.py b/tests/eval_files/async401.py
new file mode 100644
index 0000000..0abaf85
--- /dev/null
+++ b/tests/eval_files/async401.py
@@ -0,0 +1,34 @@
+import builtins
+import sys
+
+import pytest
+from exceptiongroup import BaseExceptionGroup as BackportBaseExceptionGroup
+from exceptiongroup import ExceptionGroup as BackportExceptionGroup
+from pytest import raises
+from pytest import raises as pytest_raises
+
+if sys.version_info < (3, 11):
+ from exceptiongroup import BaseExceptionGroup, ExceptionGroup
+
+
+class _NotPytest:
+ def raises(self, expected_exception):
+ pass
+
+
+not_pytest = _NotPytest()
+
+pytest.raises(ExceptionGroup) # error: 0, "ExceptionGroup"
+pytest.raises(BaseExceptionGroup) # error: 0, "BaseExceptionGroup"
+pytest.raises(expected_exception=ExceptionGroup) # error: 0, "ExceptionGroup"
+pytest.raises((ValueError, ExceptionGroup)) # error: 0, "ExceptionGroup"
+pytest.raises(builtins.ExceptionGroup) # type: ignore[attr-defined] # error: 0, "builtins.ExceptionGroup"
+pytest.raises(BackportExceptionGroup) # error: 0, "BackportExceptionGroup"
+pytest.raises(BackportBaseExceptionGroup) # error: 0, "BackportBaseExceptionGroup"
+raises(ExceptionGroup) # error: 0, "ExceptionGroup"
+pytest_raises(ExceptionGroup) # error: 0, "ExceptionGroup"
+
+pytest.raises(ValueError)
+pytest.RaisesGroup(ValueError)
+raises(ValueError)
+not_pytest.raises(ExceptionGroup)
diff --git a/tests/test_flake8_async.py b/tests/test_flake8_async.py
index 15321af..f0dabd5 100644
--- a/tests/test_flake8_async.py
+++ b/tests/test_flake8_async.py
@@ -543,6 +543,7 @@ def _parse_eval_file(
"ASYNC127",
"ASYNC300",
"ASYNC400",
+ "ASYNC401",
"ASYNC912",
}