pytest icon indicating copy to clipboard operation
pytest copied to clipboard

Parametrization with Variables Unexpectedly Changes Fixture Scope from Class-Level to Function-Level

Open yogendrasinghx opened this issue 1 year ago • 6 comments

I have a setup fixture that I've parameterized with class-level scope. When I directly specify the argument values as [("chrome", "windows", "latest")], it works fine, maintaining the class-level scope. However, when I try to use variables to set these argument values, I've noticed that the scope unexpectedly shifts to function-level.

tests/test_app.py

import pytest


@pytest.mark.usefixtures("setup")
class TestSmoke:
    def test_1(self):
        assert True

    def test_2(self):
        assert True

Scenario 1 with hardcoded values

tests/conftest.py

import pytest

def pytest_generate_tests(metafunc):
    if 'setup' in metafunc.fixturenames:
        metafunc.parametrize("setup",argvalues=[("chrome", "windows", "latest")], indirect=True, scope='class')


@pytest.fixture
def setup(request):
    print(f"\nSetting up")
    print(f"Param {request.param}")
    print(f"fixture (scope={request.scope})")

    yield
    print(f"\nTearing down")

Output

python -m pytest -s -v -k "TestSmoke" tests
======================================================================================================= test session starts =======================================================================================================
platform darwin -- Python 3.12.2, pytest-8.1.1, pluggy-1.4.0 -- /Volumes/T7/mywork/pythonProject/dynamic_parameter/venv/bin/python
cachedir: .pytest_cache
rootdir: /Volumes/T7/mywork/pythonProject/dynamic_parameter
collected 2 items                                                                                                                                                                                                                 

tests/test_app.py::TestSmoke::test_1[setup0] 
Setting up
Param ('chrome', 'windows', 'latest')
fixture (scope=class)
PASSED
tests/test_app.py::TestSmoke::test_2[setup0] PASSED
Tearing down

Scenario 2 with variable values

tests/conftest.py

import pytest


def pytest_generate_tests(metafunc):
    browser_name = "chrome"
    os_name = "windows"
    browser_version = "latest"

    if 'setup' in metafunc.fixturenames:
        metafunc.parametrize("setup", argvalues=[(os_name, browser_name, browser_version)], indirect=True, scope='class')


@pytest.fixture
def setup(request):
    print(f"\nSetting up")
    print(f"Param {request.param}")
    yield
    print(f"\nTearing down")

Output

python -m pytest -s -v -k "TestSmoke" tests
======================================================================================================= test session starts =======================================================================================================
platform darwin -- Python 3.12.2, pytest-7.4.4, pluggy-1.4.0 -- /Volumes/T7/mywork/pythonProject/dynamic_parameter/venv/bin/python
cachedir: .pytest_cache
rootdir: /Volumes/T7/mywork/pythonProject/dynamic_parameter
collected 2 items                                                                                                                                                                                                                 

tests/test_app.py::TestSmoke::test_1[setup0] 
Setting up
Param ('windows', 'chrome', 'latest')
PASSED
tests/test_app.py::TestSmoke::test_2[setup0] 
Tearing down

Setting up
Param ('windows', 'chrome', 'latest')
PASSED
Tearing down
(venv) yogen@Mac-mini dynamic_parameter % pip list
Package        Version
-------------- -------
attrs          23.2.0
iniconfig      2.0.0
more-itertools 10.2.0
packaging      24.0
pip            24.0
pluggy         1.4.0
py             1.11.0
pytest         8.1.1
toml           0.10.2
wcwidth        0.2.13

yogendrasinghx avatar Mar 21 '24 12:03 yogendrasinghx

Slightly simpler example:

import pytest


@pytest.mark.usefixtures("fixt")
class TestSmoke:
    def test_1(self):
        assert True

    def test_2(self):
        assert True
import pytest


def pytest_generate_tests(metafunc):
    if "fixt" in metafunc.fixturenames:
        arg = "latest"
        metafunc.parametrize(
            "fixt",
            argvalues=[
                (
                    arg,
                    # "latest",
                )
            ],
            indirect=True,
            scope="class",
        )


@pytest.fixture
def fixt(request):
    yield

with --setup-show:

tests/test_app.py 
        SETUP    F fixt[('latest',)]
        tests/test_app.py::TestSmoke::test_1[fixt0] (fixtures used: fixt, request).
        TEARDOWN F fixt[('latest',)]
        SETUP    F fixt[('latest',)]
        tests/test_app.py::TestSmoke::test_2[fixt0] (fixtures used: fixt, request).
        TEARDOWN F fixt[('latest',)]

but when commenting out arg, and using "latest", instead:

tests/test_app.py 
        SETUP    F fixt[('latest',)]
        tests/test_app.py::TestSmoke::test_1[fixt0] (fixtures used: fixt, request).
        tests/test_app.py::TestSmoke::test_2[fixt0] (fixtures used: fixt, request).
        TEARDOWN F fixt[('latest',)]

I'm... completely flabbergasted. How is this even possible‽

The-Compiler avatar Mar 21 '24 14:03 The-Compiler

@The-Compiler i suspect/fear we somewhere match object identities

when the string is passed as a constant, its likely the python interpreter render it as single tuple value instead of creating it

if that suspicion holds true, then can expect the behaviour as you observe

RonnyPfannschmidt avatar Mar 22 '24 12:03 RonnyPfannschmidt

i jsut made a small test in ipython on python 3.12


In [1]: import dis

In [2]: def fun():
   ...:     a = ""
   ...:     return (a,)
   ...: 

In [3]: print(dis.dis(fun))
  1           0 RESUME                   0

  2           2 LOAD_CONST               1 ('')
              4 STORE_FAST               0 (a)

  3           6 LOAD_FAST                0 (a)
              8 BUILD_TUPLE              1
             10 RETURN_VALUE
None

In [4]: def fun2():
   ...:     return ("",)
   ...: 

In [5]: print(dis.dis(fun2))
  1           0 RESUME                   0

  2           2 RETURN_CONST             1 (('',))
None

RonnyPfannschmidt avatar Mar 22 '24 12:03 RonnyPfannschmidt

its possible https://github.com/pytest-dev/pytest/pull/11257 fixes this

RonnyPfannschmidt avatar Mar 22 '24 13:03 RonnyPfannschmidt

Any update on this?

yogendrasinghx avatar May 14 '24 06:05 yogendrasinghx

When test2 requests the value of setup fixture , setup checks if its cached param key (which is that of test1) is the same as the one coming from test2. It does this check using is operator which tests exact object equality.

In the ("chrome", "windows", "latest") scenario, as this value is a literal or const object (as showed above by @RonnyPfannschmidt ), Python reuses the same object created in pytest_generate_tests(test1) for test2 so is test passes and the cached value is returned.

But in (os_name, browser_name, browser_version) scenario, two separate objects are created for test1 and test2 so the check doesn't pass. As a result, the fixture tears down and re-execute itself.

The check takes place here: https://github.com/pytest-dev/pytest/blob/400b22d6ca9eac3ec9af2fde8348b712e38ad230/src/_pytest/fixtures.py#L1056-L1067

sadra-barikbin avatar Jul 16 '24 23:07 sadra-barikbin