Optimizing Your Tests with Pytest Scopes

Ever wondered why your Pytest suite takes ages to run? The secret often lies in a powerful but misunderstood feature: fixture scopes. We break down the crucial trade-off between perfect test isolation and fast performance for more efficient tests.



I still remember the first time I inherited a mature backend project. The test suite was my safety net, but running it felt like watching paint dry. It took a solid 15 minutes to run about 100–150 tests. My first thought was, "OK, these tests must be doing some serious work". My second and more accurate thought was, "Something is deeply, deeply wrong here".

After some digging with the profiler, the culprit became glaringly obvious: for every single test function, I was spinning up a brand-new, empty PostgreSQL database, running migrations, and then tearing it all down. Every time!

To be honest, at first I wasn’t even aware I was doing that, and later I convinced myself that having a completely isolated and clean database for each test was the best way to do it. And sure, for some cases you definitely need that. But for every test? Where was my mind?

The fix? I thought it would be something complicated, but it turned out to be just a single line of code: scope="session". That one change dropped the test suite’s runtime from 15 minutes to under 45 seconds.

It was a great moment. I realized that understanding Pytest fixtures isn’t just about writing setup code, it’s about understanding the lifecycle of that setup. And that lifecycle is governed by one powerful concept: scope.

So let's talk about scopes, and learn how to stop building disposable worlds for every single test.

 

Starting with the basics: What is a fixture?

Before we talk about scopes, let’s get on the same page. In Pytest, a fixture is simply a function that provides a fixed baseline state or data for your tests. Think of it as the mise en place for your tests (hi, home chefs!), all the prep work you do before you start the actual cooking or, in our case, asserting.

You define a fixture by decorating a function with @pytest.fixture. Then, any test function that includes the fixture’s name as a parameter will receive whatever that fixture returns or yields.

A super simple example:

import pytest

@pytest.fixture
def sample_user_data():
    """
    A fixture that just returns a dictionary
    """
    return {
        "username": "coffee_lover",
        "email": "coffee_lover@example.com",
        "is_active": True
    }

def test_user_creation(sample_user_data):
    # Here "sample_user_data" is the dictionary from our fixture
    assert sample_user_data["username"] == "coffee_lover"
    assert "@" in sample_user_data["email"]

Easy enough, right? The magic, and the potential for slowness, begins when we ask the question: “How often is sample_user_data() called?” That's where scopes come in.

 

Our Playground: The “ToyCraft” API

To make this real, let's imagine we're building a simple e-commerce for toys backend API called “ToyCraft”. Our tests will need to interact with a few things:

  1. A database connection.
  2. An authenticated user to perform actions.
  3. Products to add to the cart.
  4. A shopping cart itself.

We'll use this scenario to explore how different scopes can dramatically change how our test suite behaves.

 

The Scopes: From Individual to Global

Pytest offers four scopes, each defining a different lifecycle for a fixture. Let's go through them, from the shortest-lived to the longest-lived.

1. scope="function"

This is the default scope if you don't specify one. If you write @pytest.fixture without any arguments, you're using scope=”function”.

  • Lifecycle: The fixture is set up once for every single test function that uses it. After the test function completes whether it passes or fails, the fixture's teardown logic is executed.
  • Philosophy: This scope champions absolute test isolation. Each test gets a completely fresh, sterile version of the fixture. Nothing that happens in test_A can possibly affect test_B, because they never share fixture state. This is the gold standard for writing predictable, non-flaky tests.
  • Best for: Anything that is stateful and modified by your tests. Think of empty shopping carts, specific user states, or mock objects that need to have their call counts reset for every assertion.

In our ToyCraft API, the perfect example is a user's shopping cart. We need to guarantee it's empty before each test that adds items to it.

# conftest.py
import pytest
from my_app.models import User, ShoppingCart, Product

@pytest.fixture(scope="function")
def empty_cart(db_session, test_user: User):
    """
    Ensures the user's cart is empty before each test
    """
    # This setup block runs before each test that uses this fixture
    cart = ShoppingCart.get_for_user(test_user)
    cart.empty()
    db_session.commit()
    
    # "yield" passes control to the test function
    yield cart

    # This teardown block runs after each test that uses this fixture
    # If your db_session doesn't rollback you can add these block
    # cart.empty() 
    # db_session.commit()
 

# tests/test_cart.py
def test_add_single_item_to_cart(api_client, empty_cart, sample_product):
    assert empty_cart.item_count == 0
    api_client.post(f"/cart/add/{sample_product.id}")
    assert empty_cart.refresh().item_count == 1

def test_cart_is_initially_empty(empty_cart):
    assert empty_cart.item_count == 0

In this example, both test_add_single_item_to_cart and test_cart_is_initially_empty get their own, independent execution of the empty_cart fixture. The database operation to clear the cart runs before the first test, and then again before the second test. This guarantees that the state change in the first test (adding an item) doesn't leak into the second test.

2. scope="class"

  • Lifecycle: The fixture is set up once per test class. All test methods within that class will share the exact same instance of the fixture. The teardown logic runs only after the very last test in the class has finished.
  • Philosophy: This scope is a pragmatic trade-off. You sacrifice perfect isolation for a significant performance boost. It acknowledges that sometimes, the setup cost is too high to run for every function, especially when a group of tests all operate on the same baseline state.
  • Best for: A moderately expensive resource that is the subject of a group of related tests. For example, creating a specific type of user with complex permissions, and then having multiple tests that check different aspects of that user's abilities.

Let's say we have several tests related to a user's profile. Creating a user, hashing a password, and creating a session token for every single test is wasteful. We can do it once for the whole class.

# conftest.py
@pytest.fixture(scope="class")
def authenticated_user(db_session):
    """
    Creates a user and auth context once for an entire test class
    """
    user = User.create(username="class_user", password="password123")
    auth_headers = get_auth_headers_for(user)
    db_session.commit()
    
    yield {"user": user, "headers": auth_headers}
    
    user.delete()
    db_session.commit()

# tests/test_user_profile.py
@pytest.mark.usefixtures("authenticated_user")
class TestUserProfile:
    def test_view_profile(self, api_client, authenticated_user):
        headers = authenticated_user["headers"]
        response = api_client.get("/profile", headers=headers)
        assert response.status_code == 200
        assert response.json()["username"] == "class_user"

    def test_update_profile(self, api_client, authenticated_user):
        headers = authenticated_user["headers"]
        new_data = {"email": "new.email@example.com"}
        response = api_client.patch("/profile", json=new_data, headers=headers)
        assert response.status_code == 200
        assert authenticated_user["user"].refresh().email == "new.email@example.com"

Here, the user class_user is created only once. Both test_view_profile and test_update_profile receive the exact same authenticated_user dictionary object.

This introduces shared state! Notice that test_update_profile actually changes the user's email in the database. If another test in this class ran after it and expected the original email, that test would fail. With class-scoped fixtures, you must be extremely careful that your tests either don't modify the shared state, or that the order of execution doesn't matter.

3. scope="module"

  • Lifecycle: The fixture is set up once per test file (module). The teardown runs after all tests in that file are complete.
  • Philosophy: This scope is for when an entire file of tests is dedicated to a single feature or context that requires a common, expensive setup. It's less common than class or session but is the perfect tool for certain organizational patterns.
  • Best for: Seeding a database with a specific set of data that an entire suite of tests in a file will query against. For example, populating a search index for test_search.py or creating a set of products with special promotional flags for test_promotions.py.
# tests/test_promotions.py
import pytest

@pytest.fixture(scope="module")
def promo_products(db_session):
    """
    Creates a set of specific promotional products for the whole module
    """
    products = [Product.create(name=f"Promo {i}", price=10.0) for i in range(3)]
    db_session.commit()
    yield products

    # Teardown logic here to delete the products.

def test_promo_banner_shows_correct_count(promo_products, api_client):
    assert len(promo_products) == 3
    # ... test logic ...

def test_applying_promo_code_to_promo_product(promo_products, api_client):
    # ... test logic using one of the promo_products ...

In this scenario, the three "Promo" products are created in the database only once when Pytest starts running tests/test_promotions.py. Every test inside that file can then use this pre-existing data, saving significant time compared to creating these products for every single function.

4. scope="session"

  • Lifecycle: The fixture is set up only once for the entire test run. It is created before the first test starts and is torn down only after the very last test in the entire suite has completed.
  • Philosophy: This scope is for the most expensive, foundational resources that are stable and can be shared across the entire test suite. Using this scope is the single biggest performance optimization you can make in a typical backend test suite.
  • Best for: Database connections, spinning up Docker containers, establishing a connection to a test-specific external service (ike a mock S3 bucket or a Redis instance, or building an in-memory application instance like a Flask or FastAPI app object.

This is the scope that can turn a 15-minute test suite into a 45-second one.

# conftest.py
@pytest.fixture(scope="session")
def db_engine():
    """
    Creates a test database engine for the entire session
    """
    engine = create_engine("postgresql://user:pass@localhost:5433/test_db")
    run_migrations(engine)
    
    yield engine
    
    drop_all_tables(engine)
    engine.dispose()

@pytest.fixture(scope="session")
def db_session_factory(db_engine):
    """
    Returns a factory to create new sessions
    """
    return sessionmaker(bind=db_engine)

@pytest.fixture(scope="function")
def db_session(db_session_factory):
    """
    Provides a transactional database session for a test
    """
    session = db_session_factory()
    try:
        yield session
    finally:
        # We use a function-scoped session with rollback
        # to maintain test isolation, even with a session-scoped engine.
        session.rollback() 
        session.close()

This pattern is crucial. The db_engine is created once for the entire session. But the db_session fixture, which tests actually use to interact with the database, is function-scoped. It gets a fresh session from the factory for each test and, critically, rolls back any changes at the end. This gives you the best of both worlds: the performance of a single database connection (session scope) and the test isolation of clean transactions (function scope).

 

Do we really need to use fixture scopes?

Most of the time you might not even notice them, but my short answer is yes. Learning fixture scopes early in your career, and thinking about them from the beginning of a project, helps you use them naturally over time.

Let me share a quick experience. On one project, we had a rate-limiting service that used Redis. The original tests were slow because they used a function-scoped fixture that connected to Redis and flushed the entire database for every single test.

A well-meaning developer tried to fix this by changing the fixture to scope="session". The test suite became lightning fast. The problem? They forgot that Redis is stateful. The fixture now connected only once, but never cleaned up the keys between tests. This led to chaos.

The first test related to rate limiting, let’s call it test_rate_limit_allows_first_request , would run and set a key in Redis. Then another one, say test_rate_limit_blocks_after_five_requests, would run. But because the key from the first test was still there, the second test would sometimes fail unpredictably. The tests became flaky and non-deterministic. Sometimes passes, sometimes fails. So the fix turned into a little nightmare.

The real solution was a hybrid approach. Just like in our database example, we used a session-scoped fixture to manage the Redis connection, and a separate function-scoped, autouse fixture that surgically deleted only the keys relevant to each test before it ran.

Performance and reliability achieved by understanding fixture scopes.

 

The Golden Rule: Scopes Must Be Compatible

I think this is the most important thing to understand. A fixture can only request other fixtures with the same or a larger scope.

  • A function scoped fixture can request function, class, module, or session fixtures.
  • A class scoped fixture can request class, module, or session fixtures. It cannot request a function fixture.
  • A session scoped fixture can only request other session scoped fixtures.

Think about it logically. How could our db_engine (scope session), which lives for the whole test run, depend on our empty_cart (scope function), which is created and destroyed for every single test? It's like a 100-year-old tortoise trying to borrow a mayfly's car for the afternoon. The mayfly won't be around long enough for that to make sense.

If you try to do this, Pytest will save you from yourself with a ScopeMismatch error.

 

Final Tips and Tricks

  • Visualize It: Unsure about the setup/teardown order? Run Pytest with pytest --setup-show. It will print a detailed plan of which fixtures are being created and destroyed for each test. It's an incredible debugging tool.
  • autouse=True: You can mark a fixture with autouse=True to make it run automatically for every test within its scope, without you needing to list it as an argument. Use this with caution! It can make it unclear where a test's setup is coming from. It's perfect for things that should always happen, like a fixture that rolls back every database transaction.
  • Start with function: When in doubt, start with the default function scope. It is the safest and guarantees isolation. Only increase the scope (class, session) when you identify a performance bottleneck and you are confident you can manage the shared state without creating flaky tests.

Choosing the right scope is a balancing act between test isolation and performance. By understanding how these lifecycles work, you can move from writing tests that just work to writing a test suite that is fast and efficient.

Related Posts