Search code examples
pythonpython-3.xunit-testingpytestfixtures

Why is my pytest fixture with function scope returning an object that isn't resetting it's class variables in a new test?


I have a class called Person(). It has a CURRENT_YEAR class variable, intended to be shared among all instances of the class.

I was hoping that each of my tests in a single module would get a fresh (new) object since I scoped the fixture as 'function'. However, when I change the CURRENT_YEAR in one test function, which happens using a class method that changes the Person.CURRENT_YEAR value, it persists into the next test function. So clearly the object isn't getting wiped out and recreated for each test.

The fixture is created in the conftest.py, accessible by all tests.

In the end, I broke it all down, and moved things around, but keep seeing the same thing. The Person() class is not getting instantiated more than once, as I would have expected. How should a fixture be created, so that each test_ function gets its own scope for the Class?

I've tried moving tests to separate modules, but it didn't help.

I tried making a second fixture, that returns a Person() object. No difference.

I've really stripped it down in the code below, so it's hopefully clear what I'm trying and why I'm confused.

project_root/tests/test_temp.py

import os,sys
tests = os.path.dirname(__file__)
project = os.path.dirname(tests)
sys.path.insert(0,project)
import pytest
from app.person import *

def test_current_year_changes(person_fixture):
    import pytest
    p = person_fixture
    print(f"CY is {p.CURRENT_YEAR} and age is {p.get_age()}")
    p.add_years_to_age(20)
    print(f"CY is {p.CURRENT_YEAR} and age is {p.get_age()}")
    assert p.CURRENT_YEAR == 20

def test_current_year_changes2(person_fixture2):
    import pytest
    p = person_fixture2
    print(f"CY is {p.CURRENT_YEAR} and age is {p.get_age()}")
    p.add_years_to_age(20)
    print(f"CY is {p.CURRENT_YEAR} and age is {p.get_age()}")
    assert p.CURRENT_YEAR == 20


@pytest.fixture(scope='function')
def person_fixture():
    p = Person()
    return p

@pytest.fixture(scope='function')
def person_fixture2():
    p = Person()
    return p

project_root/app/person.py

class Person(object):

    CURRENT_YEAR = 0

    def __init__(self, name=""):
        self.name = name
        self.birth_year = Person.CURRENT_YEAR

    def add_years_to_age(self, years=1):
        Person.CURRENT_YEAR += years

    def get_age(self):
        return Person.CURRENT_YEAR - self.birth_year

The code looks like both tests should be pretty independent. But the second test function shows that the CURRENT_YEAR is not starting with a new class variable.

The assert fails showing that the Person.CURRENT_YEAR is 40, instead of 20


Solution

  • The fixture scope just defines when the function that is decorated with @pytest.fixture is run. It's just a way to factor common test code into a separate function.

    So in your case it's "function" so the fixture will execute the function for each test function (that uses the fixture) and it creates a Person instance. Similarly it would run once per test module if the scope were "module".

    And that is working exactly as intended. It's not just working as intended by pytest but also as intended by yourself - remember that you actually wanted to share CURRENT_YEAR between different instances!

    How should a fixture be created, so that each test_ function gets its own scope for the Class?

    You really shouldn't use global or static variables (and class variables are just global variables hidden behind a class) exactly because it makes testing really hard (and make the program non-thread-safe). Also remember that pytest cannot provide the infrastructure to reset your program if you don't provide it! Think about it: What exactly should happen? Should it create a new interpreter session for each test function? Should it reload modules? Should it reload the class definition? Should it just set Person.CURRENT_YEAR to zero?

    One way to solve this is to abstract the class variables for example with an environment class (the current year also doesn't seem a good fit for a Person class anyway):

    class Environment(object):
        def __init__(self):
            self.CURRENT_YEAR = 0
    
    class Person(object):
        def __init__(self, environment, name=""):
            self.environment = environment
            self.name = name
            self.birth_year = self.environment.CURRENT_YEAR
    
        def add_years_to_age(self, years=1):
            self.environment.CURRENT_YEAR += years
    
        def get_age(self):
            return self.environment.CURRENT_YEAR - self.birth_year
    

    And then let the fixture create a new environment and person instance:

    @pytest.fixture(scope='function')
    def person_fixture():
        e = Environment()
        p = Person(e)
        return p
    

    At that point you probably need a global Environment instance in your code so that different Person instances can share it.

    Note that this makes not much sense if it's just one variable and probably you end up with different classes for different environmental variables. If your app gets more complicated you probably need to think about dependency injection to manage that complexity.


    However if you just want the CURRENT_YEAR to reset for each function that uses your person_fixture you could also just set it to 0 in the fixture:

    @pytest.fixture(scope='function')
    def person_fixture_with_current_year_reset():
        Person.CURRENT_YEAR = 0
        p = Person()
        return p
    

    That should work for now but at the time you run the tests in parallel you might see random failures because global variables (and class variables) are inherently non-thread-safe.