diff --git a/changelog/15028.breaking.rst b/changelog/15028.breaking.rst new file mode 100644 index 00000000000..e727d0a5d85 --- /dev/null +++ b/changelog/15028.breaking.rst @@ -0,0 +1 @@ +Collection errors now abort from the default :hook:`pytest_collection` hook rather than :hook:`pytest_runtestloop`. A plugin that replaces ``pytest_runtestloop`` still sees the abort. A plugin that replaces ``pytest_collection`` entirely has to perform the ``testsfailed`` check itself if it wants the same guard. diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 350b333144a..eee562b2f16 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -251,6 +251,8 @@ def pytest_collection(session: Session) -> object | None: 3. Set ``session.items`` to the list of collected items 4. ``pytest_collection_finish(session)`` 5. Set ``session.testscollected`` to the number of collected items + 6. Abort with :class:`~pytest.Interrupted` if collection reported errors + (unless ``--continue-on-collection-errors`` is set) You can implement this hook to only perform some action before collection, for example the terminal plugin uses it to start displaying the collection @@ -595,8 +597,8 @@ def pytest_runtestloop(session: Session) -> object | None: """Perform the main runtest loop (after collection finished). The default hook implementation performs the runtest protocol for all items - collected in the session (``session.items``), unless the collection failed - or the ``collectonly`` pytest option is set. + collected in the session (``session.items``), unless the + ``collectonly`` pytest option is set. If at any point :py:func:`pytest.exit` is called, the loop is terminated immediately. diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 1b337e20c7e..6a7d3c2b2d8 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -392,14 +392,13 @@ def _main(config: Config, session: Session) -> int | ExitCode | None: def pytest_collection(session: Session) -> None: session.perform_collect() - - -def pytest_runtestloop(session: Session) -> bool: if session.testsfailed and not session.config.option.continue_on_collection_errors: raise session.Interrupted( f"{session.testsfailed} error{'s' if session.testsfailed != 1 else ''} during collection" ) + +def pytest_runtestloop(session: Session) -> bool: if session.config.option.collectonly: return True diff --git a/testing/test_collection.py b/testing/test_collection.py index 093162ddec4..bd98aa40f28 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -1132,6 +1132,23 @@ def test_continue_on_collection_errors_maxfail(pytester: Pytester) -> None: res.stdout.fnmatch_lines(["collected 2 items / 2 errors", "*1 failed, 2 errors*"]) +def test_collection_error_aborts_before_custom_runtestloop(pytester: Pytester) -> None: + pytester.makeconftest( + """ + def pytest_runtestloop(session): + raise AssertionError("runtestloop must not run after a collection error") + """ + ) + pytester.makepyfile( + """ + import nosuchmodule + """ + ) + res = pytester.runpytest() + assert res.ret == ExitCode.INTERRUPTED + res.stdout.fnmatch_lines(["*! Interrupted: 1 error during collection !*"]) + + def test_fixture_scope_sibling_conftests(pytester: Pytester) -> None: """Regression test case for https://github.com/pytest-dev/pytest/issues/2836""" foo_path = pytester.mkdir("foo")