diff --git a/changelog/12960.breaking.rst b/changelog/12960.breaking.rst new file mode 100644 index 00000000000..3ab87e6fe23 --- /dev/null +++ b/changelog/12960.breaking.rst @@ -0,0 +1,3 @@ +Test functions containing a yield now cause an explicit error. They have not been run since pytest 4.0, and were previously marked as an expected failure and deprecation warning. + +See :ref:`the docs ` for more information. diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index 59f9d83451b..18df64c9204 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -374,6 +374,42 @@ an appropriate period of deprecation has passed. Some breaking changes which could not be deprecated are also listed. +.. _yield tests deprecated: + +``yield`` tests +~~~~~~~~~~~~~~~ + +.. versionremoved:: 4.0 + + ``yield`` tests ``xfail``. + +.. versionremoved:: 8.4 + + ``yield`` tests raise a collection error. + +pytest no longer supports ``yield``-style tests, where a test function actually ``yield`` functions and values +that are then turned into proper test methods. Example: + +.. code-block:: python + + def check(x, y): + assert x**x == y + + + def test_squared(): + yield check, 2, 4 + yield check, 3, 9 + +This would result in two actual test functions being generated. + +This form of test function doesn't support fixtures properly, and users should switch to ``pytest.mark.parametrize``: + +.. code-block:: python + + @pytest.mark.parametrize("x, y", [(2, 4), (3, 9)]) + def test_squared(x, y): + assert x**x == y + .. _nose-deprecation: Support for tests written for nose @@ -1270,36 +1306,6 @@ with the ``name`` parameter: return cell() -.. _yield tests deprecated: - -``yield`` tests -~~~~~~~~~~~~~~~ - -.. versionremoved:: 4.0 - -pytest supported ``yield``-style tests, where a test function actually ``yield`` functions and values -that are then turned into proper test methods. Example: - -.. code-block:: python - - def check(x, y): - assert x**x == y - - - def test_squared(): - yield check, 2, 4 - yield check, 3, 9 - -This would result into two actual test functions being generated. - -This form of test function doesn't support fixtures properly, and users should switch to ``pytest.mark.parametrize``: - -.. code-block:: python - - @pytest.mark.parametrize("x, y", [(2, 4), (3, 9)]) - def test_squared(x, y): - assert x**x == y - .. _internal classes accessed through node deprecated: Internal classes accessed through ``Node`` diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 614848e0dba..82aea5e635e 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -43,11 +43,6 @@ class NotSetType(enum.Enum): # fmt: on -def is_generator(func: object) -> bool: - genfunc = inspect.isgeneratorfunction(func) - return genfunc and not iscoroutinefunction(func) - - def iscoroutinefunction(func: object) -> bool: """Return True if func is a coroutine function (a function defined with async def syntax, and doesn't contain yield), or a function decorated with diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index e24dd9fb34e..5817e88f47d 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -49,7 +49,6 @@ from _pytest.compat import getfuncargnames from _pytest.compat import getimfunc from _pytest.compat import getlocation -from _pytest.compat import is_generator from _pytest.compat import NOTSET from _pytest.compat import NotSetType from _pytest.compat import safe_getattr @@ -893,7 +892,7 @@ def toterminal(self, tw: TerminalWriter) -> None: def call_fixture_func( fixturefunc: _FixtureFunc[FixtureValue], request: FixtureRequest, kwargs ) -> FixtureValue: - if is_generator(fixturefunc): + if inspect.isgeneratorfunction(fixturefunc): fixturefunc = cast( Callable[..., Generator[FixtureValue, None, None]], fixturefunc ) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index d48a6c4a9fb..1456b5212d4 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -43,7 +43,6 @@ from _pytest.compat import get_real_func from _pytest.compat import getimfunc from _pytest.compat import is_async_function -from _pytest.compat import is_generator from _pytest.compat import LEGACY_PATH from _pytest.compat import NOTSET from _pytest.compat import safe_getattr @@ -57,7 +56,6 @@ from _pytest.fixtures import FuncFixtureInfo from _pytest.fixtures import get_scope_node from _pytest.main import Session -from _pytest.mark import MARK_GEN from _pytest.mark import ParameterSet from _pytest.mark.structures import get_unpacked_marks from _pytest.mark.structures import Mark @@ -231,16 +229,13 @@ def pytest_pycollect_makeitem( lineno=lineno + 1, ) elif getattr(obj, "__test__", True): - if is_generator(obj): - res = Function.from_parent(collector, name=name) - reason = ( - f"yield tests were removed in pytest 4.0 - {name} will be ignored" + if inspect.isgeneratorfunction(obj): + fail( + f"'yield' keyword is allowed in fixtures, but not in tests ({name})", + pytrace=False, ) - res.add_marker(MARK_GEN.xfail(run=False, reason=reason)) - res.warn(PytestCollectionWarning(reason)) - return res - else: - return list(collector._genfunctions(name, obj)) + return list(collector._genfunctions(name, obj)) + return None return None diff --git a/testing/test_collection.py b/testing/test_collection.py index aba8f8ea48d..7d28610e015 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -1878,3 +1878,20 @@ def test_respect_system_exceptions( result.stdout.fnmatch_lines([f"*{head}*"]) result.stdout.fnmatch_lines([msg]) result.stdout.no_fnmatch_line(f"*{tail}*") + + +def test_yield_disallowed_in_tests(pytester: Pytester): + """Ensure generator test functions with 'yield' fail collection (#12960).""" + pytester.makepyfile( + """ + def test_with_yield(): + yield 1 + """ + ) + result = pytester.runpytest() + assert result.ret == 2 + result.stdout.fnmatch_lines( + ["*'yield' keyword is allowed in fixtures, but not in tests (test_with_yield)*"] + ) + # Assert that no tests were collected + result.stdout.fnmatch_lines(["*collected 0 items*"]) diff --git a/testing/test_compat.py b/testing/test_compat.py index 2c6b0269c27..86868858956 100644 --- a/testing/test_compat.py +++ b/testing/test_compat.py @@ -5,17 +5,14 @@ from functools import cached_property from functools import partial from functools import wraps -import sys from typing import TYPE_CHECKING from _pytest.compat import _PytestWrapper from _pytest.compat import assert_never from _pytest.compat import get_real_func -from _pytest.compat import is_generator from _pytest.compat import safe_getattr from _pytest.compat import safe_isclass from _pytest.outcomes import OutcomeException -from _pytest.pytester import Pytester import pytest @@ -23,17 +20,6 @@ from typing_extensions import Literal -def test_is_generator() -> None: - def zap(): - yield # pragma: no cover - - def foo(): - pass # pragma: no cover - - assert is_generator(zap) - assert not is_generator(foo) - - def test_real_func_loop_limit() -> None: class Evil: def __init__(self): @@ -95,65 +81,6 @@ def foo(x): assert get_real_func(partial(foo)) is foo -@pytest.mark.skipif(sys.version_info >= (3, 11), reason="coroutine removed") -def test_is_generator_asyncio(pytester: Pytester) -> None: - pytester.makepyfile( - """ - from _pytest.compat import is_generator - import asyncio - @asyncio.coroutine - def baz(): - yield from [1,2,3] - - def test_is_generator_asyncio(): - assert not is_generator(baz) - """ - ) - # avoid importing asyncio into pytest's own process, - # which in turn imports logging (#8) - result = pytester.runpytest_subprocess() - result.stdout.fnmatch_lines(["*1 passed*"]) - - -def test_is_generator_async_syntax(pytester: Pytester) -> None: - pytester.makepyfile( - """ - from _pytest.compat import is_generator - def test_is_generator_py35(): - async def foo(): - await foo() - - async def bar(): - pass - - assert not is_generator(foo) - assert not is_generator(bar) - """ - ) - result = pytester.runpytest() - result.stdout.fnmatch_lines(["*1 passed*"]) - - -def test_is_generator_async_gen_syntax(pytester: Pytester) -> None: - pytester.makepyfile( - """ - from _pytest.compat import is_generator - def test_is_generator(): - async def foo(): - yield - await foo() - - async def bar(): - yield - - assert not is_generator(foo) - assert not is_generator(bar) - """ - ) - result = pytester.runpytest() - result.stdout.fnmatch_lines(["*1 passed*"]) - - class ErrorsHelper: @property def raise_baseexception(self): diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 14c152d6123..872703900cd 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -1042,10 +1042,6 @@ def test_pass(): class TestClass(object): def test_skip(self): pytest.skip("hello") - def test_gen(): - def check(x): - assert x == 1 - yield check, 0 """ ) @@ -1058,7 +1054,6 @@ def test_verbose_reporting(self, verbose_testfile, pytester: Pytester) -> None: "*test_verbose_reporting.py::test_fail *FAIL*", "*test_verbose_reporting.py::test_pass *PASS*", "*test_verbose_reporting.py::TestClass::test_skip *SKIP*", - "*test_verbose_reporting.py::test_gen *XFAIL*", ] ) assert result.ret == 1