diff --git a/_pytest/python.py b/_pytest/python.py index 00b08ec2b..a842c471d 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -26,19 +26,21 @@ def getimfunc(func): class FixtureFunctionMarker: - def __init__(self, scope, params, autouse=False): + def __init__(self, scope, params, autouse=False, yieldctx=False): self.scope = scope self.params = params self.autouse = autouse + self.yieldctx = yieldctx def __call__(self, function): if inspect.isclass(function): - raise ValueError("class fixtures not supported (may be in the future)") + raise ValueError( + "class fixtures not supported (may be in the future)") function._pytestfixturefunction = self return function -def fixture(scope="function", params=None, autouse=False): +def fixture(scope="function", params=None, autouse=False, yieldctx=False): """ (return a) decorator to mark a fixture factory function. This decorator can be used (with or or without parameters) to define @@ -59,12 +61,17 @@ def fixture(scope="function", params=None, autouse=False): :arg autouse: if True, the fixture func is activated for all tests that can see it. If False (the default) then an explicit reference is needed to activate the fixture. + + :arg yieldctx: if True, the fixture function yields a fixture value. + Code after such a ``yield`` statement is treated as + teardown code. """ if callable(scope) and params is None and autouse == False: # direct decoration - return FixtureFunctionMarker("function", params, autouse)(scope) + return FixtureFunctionMarker( + "function", params, autouse, yieldctx)(scope) else: - return FixtureFunctionMarker(scope, params, autouse=autouse) + return FixtureFunctionMarker(scope, params, autouse, yieldctx) defaultfuncargprefixmarker = fixture() @@ -1616,6 +1623,7 @@ class FixtureManager: assert not name.startswith(self._argprefix) fixturedef = FixtureDef(self, nodeid, name, obj, marker.scope, marker.params, + marker.yieldctx, unittest=unittest) faclist = self._arg2fixturedefs.setdefault(name, []) if not fixturedef.has_location: @@ -1656,8 +1664,18 @@ class FixtureManager: except ValueError: pass -def call_fixture_func(fixturefunc, request, kwargs): - if is_generator(fixturefunc): +def fail_fixturefunc(fixturefunc, msg): + fs, lineno = getfslineno(fixturefunc) + location = "%s:%s" % (fs, lineno+1) + source = py.code.Source(fixturefunc) + pytest.fail(msg + ":\n\n" + str(source.indent()) + "\n" + location, + pytrace=False) + +def call_fixture_func(fixturefunc, request, kwargs, yieldctx): + if yieldctx: + if not is_generator(fixturefunc): + fail_fixturefunc(fixturefunc, + msg="yieldctx=True requires yield statement") iter = fixturefunc(**kwargs) next = getattr(iter, "__next__", None) if next is None: @@ -1669,11 +1687,8 @@ def call_fixture_func(fixturefunc, request, kwargs): except StopIteration: pass else: - fs, lineno = getfslineno(fixturefunc) - location = "%s:%s" % (fs, lineno+1) - pytest.fail( - "fixture function %s has more than one 'yield': \n%s" % - (fixturefunc.__name__, location), pytrace=False) + fail_fixturefunc(fixturefunc, + "fixture function has more than one 'yield'") request.addfinalizer(teardown) else: res = fixturefunc(**kwargs) @@ -1682,7 +1697,7 @@ def call_fixture_func(fixturefunc, request, kwargs): class FixtureDef: """ A container for a factory definition. """ def __init__(self, fixturemanager, baseid, argname, func, scope, params, - unittest=False): + yieldctx, unittest=False): self._fixturemanager = fixturemanager self.baseid = baseid or '' self.has_location = baseid is not None @@ -1693,6 +1708,7 @@ class FixtureDef: self.params = params startindex = unittest and 1 or None self.argnames = getfuncargnames(func, startindex=startindex) + self.yieldctx = yieldctx self.unittest = unittest self._finalizer = [] @@ -1730,7 +1746,8 @@ class FixtureDef: fixturefunc = fixturefunc.__get__(request.instance) except AttributeError: pass - result = call_fixture_func(fixturefunc, request, kwargs) + result = call_fixture_func(fixturefunc, request, kwargs, + self.yieldctx) assert not hasattr(self, "cached_result") self.cached_result = result return result diff --git a/doc/en/fixture.txt b/doc/en/fixture.txt index b7b3df77e..2c15541fe 100644 --- a/doc/en/fixture.txt +++ b/doc/en/fixture.txt @@ -40,7 +40,7 @@ style ` or :ref:`nose based ` projects. .. _`@pytest.fixture`: .. _`pytest.fixture`: -Fixtures as Function arguments (funcargs) +Fixtures as Function arguments ----------------------------------------- Test functions can receive fixture objects by naming them as an input @@ -70,7 +70,8 @@ marked ``smtp`` fixture function. Running the test looks like this:: $ py.test test_smtpsimple.py =========================== test session starts ============================ - platform linux2 -- Python 2.7.3 -- pytest-2.3.5 + platform linux2 -- Python 2.7.3 -- pytest-2.4.0.dev12 + plugins: xdist, pep8, cov, cache, capturelog, instafail collected 1 items test_smtpsimple.py F @@ -78,7 +79,7 @@ marked ``smtp`` fixture function. Running the test looks like this:: ================================= FAILURES ================================= ________________________________ test_ehlo _________________________________ - smtp = + smtp = def test_ehlo(smtp): response, msg = smtp.ehlo() @@ -88,7 +89,7 @@ marked ``smtp`` fixture function. Running the test looks like this:: E assert 0 test_smtpsimple.py:12: AssertionError - ========================= 1 failed in 0.20 seconds ========================= + ========================= 1 failed in 0.17 seconds ========================= In the failure traceback we see that the test function was called with a ``smtp`` argument, the ``smtplib.SMTP()`` instance created by the fixture @@ -123,7 +124,7 @@ with a list of available function arguments. but is not anymore advertised as the primary means of declaring fixture functions. -Funcargs a prime example of dependency injection +"Funcargs" a prime example of dependency injection --------------------------------------------------- When injecting fixtures to test functions, pytest-2.0 introduced the @@ -142,7 +143,7 @@ functions take the role of the *injector* and test functions are the .. _smtpshared: -Working with a module-shared fixture +Sharing a fixture across tests in a module (or class/session) ----------------------------------------------------------------- .. regendoc:wipe @@ -188,7 +189,8 @@ inspect what is going on and can now run the tests:: $ py.test test_module.py =========================== test session starts ============================ - platform linux2 -- Python 2.7.3 -- pytest-2.3.5 + platform linux2 -- Python 2.7.3 -- pytest-2.4.0.dev12 + plugins: xdist, pep8, cov, cache, capturelog, instafail collected 2 items test_module.py FF @@ -196,7 +198,7 @@ inspect what is going on and can now run the tests:: ================================= FAILURES ================================= ________________________________ test_ehlo _________________________________ - smtp = + smtp = def test_ehlo(smtp): response = smtp.ehlo() @@ -208,7 +210,7 @@ inspect what is going on and can now run the tests:: test_module.py:6: AssertionError ________________________________ test_noop _________________________________ - smtp = + smtp = def test_noop(smtp): response = smtp.noop() @@ -217,7 +219,7 @@ inspect what is going on and can now run the tests:: E assert 0 test_module.py:11: AssertionError - ========================= 2 failed in 0.26 seconds ========================= + ========================= 2 failed in 0.16 seconds ========================= You see the two ``assert 0`` failing and more importantly you can also see that the same (module-scoped) ``smtp`` object was passed into the two @@ -233,62 +235,17 @@ instance, you can simply declare it:: # the returned fixture value will be shared for # all tests needing it -.. _`contextfixtures`: +.. _`finalization`: -fixture finalization / teardowns +fixture finalization / executing teardown code ------------------------------------------------------------- -pytest supports two styles of fixture finalization: +pytest supports execution of fixture specific finalization code +when the fixture goes out of scope. By accepting a ``request`` object +into your fixture function you can call its ``request.addfinalizer`` one +or multiple times:: -- (new in pytest-2.4) by writing a contextmanager fixture - generator where a fixture value is "yielded" and the remainder - of the function serves as the teardown code. This integrates - very well with existing context managers. - -- by making a fixture function accept a ``request`` argument - with which it can call ``request.addfinalizer(teardownfunction)`` - to register a teardown callback function. - -Both methods are strictly equivalent from pytest's view and will -remain supported in the future. - -Because a number of people prefer the new contextmanager style -we describe it first:: - - # content of test_ctxfixture.py - - import smtplib - import pytest - - @pytest.fixture(scope="module") - def smtp(): - smtp = smtplib.SMTP("merlinux.eu") - yield smtp # provide the fixture value - print ("teardown smtp") - smtp.close() - -pytest detects that you are using a ``yield`` in your fixture function, -turns it into a generator and: - -a) iterates once into it for producing the value -b) iterates a second time for tearing the fixture down, expecting - a StopIteration (which is produced automatically from the Python - runtime when the generator returns). - -.. note:: - - The teardown will execute independently of the status of test functions. - You do not need to write the teardown code into a ``try-finally`` clause - like you would usually do with ``contextlib.contextmanager`` decorated - functions. - - If the fixture generator yields a second value pytest will report - an error. Yielding cannot be used for parametrization. We'll describe - ways to implement parametrization further below. - -Prior to pytest-2.4 you always needed to register a finalizer by accepting -a ``request`` object into your fixture function and calling -``request.addfinalizer`` with a teardown function:: + # content of conftest.py import smtplib import pytest @@ -299,24 +256,38 @@ a ``request`` object into your fixture function and calling def fin(): print ("teardown smtp") smtp.close() + request.addfinalizer(fin) return smtp # provide the fixture value -This method of registering a finalizer reads more indirect -than the new contextmanager style syntax because ``fin`` -is a callback function. +The ``fin`` function will execute when the last test using +the fixture in the module has finished execution. +Let's execute it:: + + $ py.test -s -q --tb=no + FF + 2 failed in 0.16 seconds + teardown smtp + +We see that the ``smtp`` instance is finalized after the two +tests finished execution. Note that if we decorated our fixture +function with ``scope='function'`` then fixture setup and cleanup would +occur around each single test. In either case the test +module itself does not need to change or know about these details +of fixture setup. + +Note that pytest-2.4 introduced an alternative `yield-context `_ +mechanism which allows to interact nicely with context managers. .. _`request-context`: -Fixtures can interact with the requesting test context +Fixtures can introspect the requesting test context ------------------------------------------------------------- -pytest provides a builtin :py:class:`request ` object, -which fixture functions can use to introspect the function, class or module -for which they are invoked. - +Fixture function can accept the :py:class:`request ` object +to introspect the "requesting" test function, class or module context. Further extending the previous ``smtp`` fixture example, let's -read an optional server URL from the module namespace:: +read an optional server URL from the test module which uses our fixture:: # content of conftest.py import pytest @@ -326,22 +297,21 @@ read an optional server URL from the module namespace:: def smtp(request): server = getattr(request.module, "smtpserver", "merlinux.eu") smtp = smtplib.SMTP(server) - yield smtp # provide the fixture - print ("finalizing %s" % smtp) - smtp.close() + + def fin(): + print ("finalizing %s (%s)" % (smtp, server)) + smtp.close() + + return smtp -The finalizing part after the ``yield smtp`` statement will execute -when the last test using the ``smtp`` fixture has executed:: +We use the ``request.module`` attribute to optionally obtain an +``smtpserver`` attribute from the test module. If we just execute +again, nothing much has changed:: $ py.test -s -q --tb=no FF - finalizing - -We see that the ``smtp`` instance is finalized after the two -tests which use it finished executin. If we rather specify -``scope='function'`` then fixture setup and cleanup occurs -around each single test. Note that in either case the test -module itself does not need to change! + 2 failed in 0.17 seconds + teardown smtp Let's quickly create another test module that actually sets the server URL in its module namespace:: @@ -361,12 +331,11 @@ Running it:: ______________________________ test_showhelo _______________________________ test_anothersmtp.py:5: in test_showhelo > assert 0, smtp.helo() - E AssertionError: (250, 'mail.python.org') + E AssertionError: (250, 'hq.merlinux.eu') voila! The ``smtp`` fixture function picked up our mail server name from the module namespace. - .. _`fixture-parametrize`: Parametrizing a fixture @@ -392,9 +361,11 @@ through the special :py:class:`request ` object:: params=["merlinux.eu", "mail.python.org"]) def smtp(request): smtp = smtplib.SMTP(request.param) - yield smtp - print ("finalizing %s" % smtp) - smtp.close() + def fin(): + print ("finalizing %s" % smtp) + smtp.close() + request.addfinalizer(fin) + return smtp The main change is the declaration of ``params`` with :py:func:`@pytest.fixture <_pytest.python.fixture>`, a list of values @@ -407,7 +378,7 @@ So let's just do another run:: ================================= FAILURES ================================= __________________________ test_ehlo[merlinux.eu] __________________________ - smtp = + smtp = def test_ehlo(smtp): response = smtp.ehlo() @@ -419,7 +390,7 @@ So let's just do another run:: test_module.py:6: AssertionError __________________________ test_noop[merlinux.eu] __________________________ - smtp = + smtp = def test_noop(smtp): response = smtp.noop() @@ -430,7 +401,7 @@ So let's just do another run:: test_module.py:11: AssertionError ________________________ test_ehlo[mail.python.org] ________________________ - smtp = + smtp = def test_ehlo(smtp): response = smtp.ehlo() @@ -441,7 +412,7 @@ So let's just do another run:: test_module.py:5: AssertionError ________________________ test_noop[mail.python.org] ________________________ - smtp = + smtp = def test_noop(smtp): response = smtp.noop() @@ -450,6 +421,7 @@ So let's just do another run:: E assert 0 test_module.py:11: AssertionError + 4 failed in 6.04 seconds We see that our two test functions each ran twice, against the different ``smtp`` instances. Note also, that with the ``mail.python.org`` @@ -489,13 +461,15 @@ Here we declare an ``app`` fixture which receives the previously defined $ py.test -v test_appsetup.py =========================== test session starts ============================ - platform linux2 -- Python 2.7.3 -- pytest-2.3.5 -- /home/hpk/p/pytest/.tox/regen/bin/python + platform linux2 -- Python 2.7.3 -- pytest-2.4.0.dev12 -- /home/hpk/venv/0/bin/python + cachedir: /tmp/doc-exec-120/.cache + plugins: xdist, pep8, cov, cache, capturelog, instafail collecting ... collected 2 items - test_appsetup.py:12: test_smtp_exists[merlinux.eu] PASSED test_appsetup.py:12: test_smtp_exists[mail.python.org] PASSED + test_appsetup.py:12: test_smtp_exists[merlinux.eu] PASSED - ========================= 2 passed in 5.38 seconds ========================= + ========================= 2 passed in 6.98 seconds ========================= Due to the parametrization of ``smtp`` the test will run twice with two different ``App`` instances and respective smtp servers. There is no @@ -534,8 +508,9 @@ to show the setup/teardown flow:: def modarg(request): param = request.param print "create", param - yield param - print ("fin %s" % param) + def fin(): + print ("fin %s" % param) + return param @pytest.fixture(scope="function", params=[1,2]) def otherarg(request): @@ -552,31 +527,31 @@ Let's run the tests in verbose mode and with looking at the print-output:: $ py.test -v -s test_module.py =========================== test session starts ============================ - platform linux2 -- Python 2.7.3 -- pytest-2.3.5 -- /home/hpk/p/pytest/.tox/regen/bin/python + platform linux2 -- Python 2.7.3 -- pytest-2.4.0.dev12 -- /home/hpk/venv/0/bin/python + cachedir: /tmp/doc-exec-120/.cache + plugins: xdist, pep8, cov, cache, capturelog, instafail collecting ... collected 8 items - test_module.py:16: test_0[1] PASSED - test_module.py:16: test_0[2] PASSED - test_module.py:18: test_1[mod1] PASSED - test_module.py:20: test_2[1-mod1] PASSED - test_module.py:20: test_2[2-mod1] PASSED - test_module.py:18: test_1[mod2] PASSED - test_module.py:20: test_2[1-mod2] PASSED - test_module.py:20: test_2[2-mod2] PASSED + test_module.py:15: test_0[1] PASSED + test_module.py:15: test_0[2] PASSED + test_module.py:17: test_1[mod1] PASSED + test_module.py:19: test_2[1-mod1] PASSED + test_module.py:19: test_2[2-mod1] PASSED + test_module.py:17: test_1[mod2] PASSED + test_module.py:19: test_2[1-mod2] PASSED + test_module.py:19: test_2[2-mod2] PASSED - ========================= 8 passed in 0.01 seconds ========================= + ========================= 8 passed in 0.02 seconds ========================= test0 1 test0 2 create mod1 test1 mod1 test2 1 mod1 test2 2 mod1 - fin mod1 create mod2 test1 mod2 test2 1 mod2 test2 2 mod2 - fin mod2 You can see that the parametrized module-scoped ``modarg`` resource caused an ordering of test execution that lead to the fewest possible "active" resources. The finalizer for the ``mod1`` parametrized resource was executed @@ -632,6 +607,7 @@ to verify our fixture is activated and the tests pass:: $ py.test -q .. + 2 passed in 0.02 seconds You can specify multiple fixtures like this:: @@ -702,6 +678,7 @@ If we run it, we get two passing tests:: $ py.test -q .. + 2 passed in 0.02 seconds Here is how autouse fixtures work in other scopes: @@ -750,3 +727,62 @@ to a :ref:`conftest.py ` file or even separately installable fixtures functions starts at test classes, then test modules, then ``conftest.py`` files and finally builtin and third party plugins. + +.. _yieldctx: + +Fixture functions using "yield" / context manager integration +--------------------------------------------------------------- + +.. versionadded:: 2.4 + +pytest-2.4 allows fixture functions to use a ``yield`` instead +of a ``return`` statement to provide a fixture value. Let's +look at a quick example before discussing advantages:: + + # content of conftest.py + + import smtplib + import pytest + + @pytest.fixture(scope="module", yieldctx=True) + def smtp(): + smtp = smtplib.SMTP("merlinux.eu") + yield smtp # provide the fixture value + print ("teardown smtp after a yield") + smtp.close() + +In contrast to the `finalization`_ example, our fixture +function uses a single ``yield`` to provide the ``smtp`` fixture +value. The code after the ``yield`` statement serves as the +teardown code, avoiding the indirection of registering a +teardown function. More importantly, it also allows to +seemlessly re-use existing context managers, for example:: + + @pytest.fixture(yieldctx=True) + def somefixture(): + with open("somefile") as f: + yield f.readlines() + +The file ``f`` will be closed once ``somefixture`` goes out of scope. +It is possible to achieve the same result by using a ``request.addfinalizer`` +call but it is more boilerplate and not very obvious unless +you know about the exact ``__enter__|__exit__`` protocol of with-style +context managers. + +For some background, here is the protocol pytest follows for when +``yieldctx=True`` is specified in the fixture decorator: + +a) iterate once into the generator for producing the value +b) iterate a second time for tearing the fixture down, expecting + a StopIteration (which is produced automatically from the Python + runtime when the generator returns). + +The teardown will always execute, independently of the outcome of +test functions. You do **not need** to write the teardown code into a +``try-finally`` clause like you would usually do with +:py:func:`contextlib.contextmanager` decorated functions. + +If the fixture generator yields a second value pytest will report +an error. Yielding cannot be used for parametrization, rather +see `fixture-parametrize`_. + diff --git a/testing/python/fixture.py b/testing/python/fixture.py index 1218bb7da..30af8cf84 100644 --- a/testing/python/fixture.py +++ b/testing/python/fixture.py @@ -1980,7 +1980,7 @@ class TestContextManagerFixtureFuncs: def test_simple(self, testdir): testdir.makepyfile(""" import pytest - @pytest.fixture + @pytest.fixture(yieldctx=True) def arg1(): print ("setup") yield 1 @@ -2004,7 +2004,7 @@ class TestContextManagerFixtureFuncs: def test_scoped(self, testdir): testdir.makepyfile(""" import pytest - @pytest.fixture(scope="module") + @pytest.fixture(scope="module", yieldctx=True) def arg1(): print ("setup") yield 1 @@ -2025,7 +2025,7 @@ class TestContextManagerFixtureFuncs: def test_setup_exception(self, testdir): testdir.makepyfile(""" import pytest - @pytest.fixture(scope="module") + @pytest.fixture(scope="module", yieldctx=True) def arg1(): pytest.fail("setup") yield 1 @@ -2041,7 +2041,7 @@ class TestContextManagerFixtureFuncs: def test_teardown_exception(self, testdir): testdir.makepyfile(""" import pytest - @pytest.fixture(scope="module") + @pytest.fixture(scope="module", yieldctx=True) def arg1(): yield 1 pytest.fail("teardown") @@ -2054,11 +2054,10 @@ class TestContextManagerFixtureFuncs: *1 passed*1 error* """) - def test_yields_more_than_one(self, testdir): testdir.makepyfile(""" import pytest - @pytest.fixture(scope="module") + @pytest.fixture(scope="module", yieldctx=True) def arg1(): yield 1 yield 2 @@ -2072,3 +2071,20 @@ class TestContextManagerFixtureFuncs: """) + def test_no_yield(self, testdir): + testdir.makepyfile(""" + import pytest + @pytest.fixture(scope="module", yieldctx=True) + def arg1(): + return 1 + def test_1(arg1): + pass + """) + result = testdir.runpytest("-s") + result.stdout.fnmatch_lines(""" + *yieldctx*requires*yield* + *yieldctx=True* + *def arg1* + """) + +