Pytest fixtures don’t just provide setup and teardown for your tests; they’re designed to manage resources at different granularities, and understanding their scopes is key to efficient test execution.
Let’s see how this plays out with a concrete example. Imagine you have a database connection that’s expensive to set up. You don’t want to create a new connection for every single test function.
# conftest.py
import pytest
import time
@pytest.fixture(scope="session")
def db_connection():
print("\nEstablishing database connection...")
time.sleep(1) # Simulate expensive setup
connection = {"status": "connected"}
yield connection
print("\nClosing database connection...")
connection["status"] = "closed"
# test_database.py
def test_read_data(db_connection):
assert db_connection["status"] == "connected"
print("Reading data...")
def test_write_data(db_connection):
assert db_connection["status"] == "connected"
print("Writing data...")
When you run pytest, you’ll notice the "Establishing database connection…" message appears only once, before any tests run, and "Closing database connection…" appears only once at the very end. This is the session scope in action. The fixture is created once per test session.
Now, let’s consider a scenario where you need a temporary directory for tests within a specific file.
# conftest.py
import pytest
import tempfile
import os
@pytest.fixture(scope="module")
def temp_dir():
print("\nCreating temporary directory for module...")
temp_dir_path = tempfile.mkdtemp()
yield temp_dir_path
print(f"\nRemoving temporary directory: {temp_dir_path}")
os.rmdir(temp_dir_path)
# test_files.py
def test_create_file_in_dir(temp_dir):
file_path = os.path.join(temp_dir, "my_test_file.txt")
with open(file_path, "w") as f:
f.write("hello")
assert os.path.exists(file_path)
print(f"Created file: {file_path}")
def test_read_file_in_dir(temp_dir):
file_path = os.path.join(temp_dir, "my_test_file.txt")
assert os.path.exists(file_path)
with open(file_path, "r") as f:
content = f.read()
assert content == "hello"
print(f"Read file: {file_path}")
Running pytest on test_files.py will show "Creating temporary directory for module…" once before test_create_file_in_dir and test_read_file_in_dir execute, and the directory will be removed after both tests in that module are done. This is the module scope. The fixture is created once per test module.
What if you have a set of tests within a class that need a shared resource, like a mock API client, but you don’t want it shared across different classes?
# conftest.py
import pytest
class MockAPIClient:
def __init__(self):
self.calls = []
print("\nInitializing MockAPIClient...")
def call(self, method, url):
self.calls.append((method, url))
return {"status": "success", "data": f"response for {method} {url}"}
@pytest.fixture(scope="class")
def api_client():
client = MockAPIClient()
yield client
print("\nMockAPIClient teardown...")
# test_api.py
import pytest
class TestUserAPI:
def test_get_user(self, api_client):
response = api_client.call("GET", "/users/1")
assert response["status"] == "success"
assert ("GET", "/users/1") in api_client.calls
print("Tested GET /users/1")
def test_create_user(self, api_client):
response = api_client.call("POST", "/users")
assert response["status"] == "success"
assert ("POST", "/users") in api_client.calls
print("Tested POST /users")
class TestProductAPI:
def test_get_product(self, api_client):
response = api_client.call("GET", "/products/10")
assert response["status"] == "success"
assert ("GET", "/products/10") in api_client.calls
print("Tested GET /products/10")
When you run pytest test_api.py, you’ll see "Initializing MockAPIClient…" twice: once before TestUserAPI runs and again before TestProductAPI runs. The api_client fixture is created once per test class.
Finally, the function scope is the default. This means a new instance of the fixture is created for every single test function that uses it.
# conftest.py
import pytest
@pytest.fixture
def unique_id():
print("\nGenerating unique ID for function...")
import uuid
return str(uuid.uuid4())
# test_ids.py
def test_first_id(unique_id):
print(f"Test 1 using ID: {unique_id}")
assert unique_id is not None
def test_second_id(unique_id):
print(f"Test 2 using ID: {unique_id}")
assert unique_id is not None
# This assertion will fail if unique_id is the same,
# but with function scope, it's guaranteed to be different.
# assert unique_id != test_first_id(unique_id) # This is not how you'd test it, just illustration
Running pytest test_ids.py will print "Generating unique ID for function…" twice, and each test will receive a distinct UUID. This is the function scope, the most granular, ensuring complete isolation for each test.
The core idea is that fixtures with broader scopes (session, module) are instantiated less frequently, leading to faster test runs if the setup cost is high and the resource can be safely shared. Narrower scopes (class, function) provide greater isolation, which is crucial when tests might modify shared state in ways that affect subsequent tests.
If you have a fixture defined with scope="session" and it’s yielding a value, the teardown code after the yield statement will only execute when the entire test session concludes. This is why it’s essential to place cleanup logic for session-scoped resources correctly, ensuring they are released only after all tests have completed.
The next step is understanding how to parameterize fixtures to run tests with different configurations or inputs.