The Complete Guide to pytest Fixtures โ€” scope, yield, and autouse in Practice

Plenty of engineers have used pytest fixtures without ever nailing down when to use which scope, or when yield actually matters. This article is for anyone who already knows the basics of fixtures and wants to go deeper โ€” scope (function/class/module/session), setup and teardown with yield fixtures, autouse, composing fixtures together, and the factory pattern โ€” all grounded in situations that come up in real projects.

If your understanding of fixtures stops at “something that sets things up before a test,” you’ll hit a design wall as your test suite grows. Whether you know how to split scope and responsibility properly makes a real difference to how maintainable the whole suite stays.

๐Ÿ“Œ Who This Article Is For

  • Anyone who already knows the basics of fixtures (attaching @pytest.fixture to a function)
  • Anyone who can’t quite explain the difference between scope values โ€” function, class, module, session
  • Anyone whose test runtime is creeping up because a browser or DB connection gets recreated for every test
  • Anyone whose fixtures have multiplied and isn’t sure how to organize them

โœ… What You’ll Learn

  • How to choose the right fixture scope for the situation
  • How to write setup and teardown logic with a yield fixture
  • How the factory pattern handles “I need several different pieces of data within one test”

๐Ÿ‘ค About the Author

Written by Yoshi, a QA and test automation engineer with over 15 years of hands-on experience designing and maintaining pytest-based test infrastructure, including ongoing work refining fixture scope design and reusable patterns.

๐Ÿ“Œ Key Takeaways

  • Choose scope based on “what’s safe to share.” When in doubt, start with the default function scope and only widen it once runtime becomes a real problem
  • yield lets you write setup and teardown naturally within a single fixture
  • When you need to generate several different pieces of data within one test, the factory pattern is the right fit

Once you’ve got the basics of fixtures down, real projects tend to surface challenges like these:

  • Every fixture is written at the default function scope, so launching a browser or connecting to a database happens over and over, and runtime creeps up
  • You’re not sure where to put teardown logic โ€” deleting files, rolling back a database transaction, and so on
  • You have some setup you want to run for absolutely every test, but listing it as an argument everywhere feels tedious
  • You need to create several pieces of test data with different names or conditions, all within one test

This article walks through the fixture features that address each of these, one at a time.

Where This Article Fits

This article assumes you already know the basics of fixtures and dives into fixtures themselves in depth. If you’d like a refresher on pytest fundamentals โ€” fixtures, parametrize, and conftest.py โ€” we recommend starting with an introductory article first.

๐Ÿ’ก New to Python or pytest? Python Setup Guide for Beginners: Install Python, Create a Virtual Environment, and Run pytest covers getting your environment ready before you dive into fixtures.

โ‘  scope: Deciding How Widely a Fixture Gets Shared

The scope argument on a fixture controls how widely it gets reused. The default is function.

scopeCreatedGood fit for
function (default)Fresh for every test functionThe baseline whenever you don’t want state shared across tests
classOnce per test classReusing a common precondition across multiple tests in a class
moduleOnce per fileSharing across multiple test classes or functions in the same file
sessionOnce for the entire test runExpensive setup that’s safe to share โ€” launching a browser, connecting to a database
import pytest

@pytest.fixture(scope="function")  # same as omitting scope entirely
def user():
    return {"name": "Taro", "email": "taro@example.com"}

@pytest.fixture(scope="session")
def db_connection():
    conn = create_db_connection()
    yield conn
    conn.close()
โš ๏ธ Note: Widening scope speeds things up, but it also introduces a risk: if one test mutates the shared state, it can affect every test that runs after it. Before sharing something at session scope in particular, think through whether it can stay in a state that doesn’t depend on test execution order.
๐Ÿ” From the Field: When in doubt, starting with function scope is the safe move. A good order is: get the suite working, wait until runtime actually becomes a problem, and only then widen the scope for the specific things that are both expensive and safe not to reset between tests โ€” launching a browser, connecting to a database.

โ‘ก yield Fixtures: Setup and Teardown in One Place

Using yield instead of return lets you write setup (before the test) and teardown (after the test) naturally within a single fixture function.

โš ๏ธ Note: If you’re using pytest-playwright, fixtures like browser and page are already provided out of the box. The example below defines a page fixture manually purely to illustrate how yield works โ€” in practice, the ones pytest-playwright already gives you are all you need.
import pytest
from playwright.sync_api import Browser, Page

@pytest.fixture
def page(browser: Browser):
    # --- Setup (before yield) ---
    # the browser fixture is provided by pytest-playwright out of the box
    page = browser.new_page()

    yield page  # this is the value handed to the test function

    # --- Teardown (after yield, runs once the test finishes) ---
    page.close()
def test_homepage_title(page: Page):
    page.goto("https://example.com")
    assert page.title() == "Example Domain"
๐Ÿ’ก Practical Tip: The teardown code after yield runs even if the test itself fails (an assertion error or exception). Anything you need to reliably clean up โ€” closing a page, releasing a DB connection โ€” is safer handled in a yield-based fixture than left to chance.

โ‘ข autouse: Running Automatically Without Being Requested

Normally, a fixture only runs if you name it as an argument in your test function. Setting autouse=True makes it run automatically for every test within its scope, with no need to reference it explicitly.

import pytest

@pytest.fixture(autouse=True)
def reset_test_database():
    """Reset the test database to a clean state before every test."""
    reset_db()
    yield
    # add cleanup here if needed
# This test doesn't list reset_test_database as an argument, but it still runs automatically
def test_user_count_starts_at_zero():
    assert get_user_count() == 0
โš ๏ธ Note: autouse is convenient, but it comes with a real side effect: it becomes harder to tell what a given test actually depends on just by reading its code. We’d recommend limiting it to things that genuinely need to run for every single test โ€” resetting a database, configuring logging. Avoid reaching for it casually.

โ‘ฃ Composing Fixtures: One Fixture Using Another

A fixture can take another fixture as an argument. This lets you combine small fixtures into more specific preconditions.

base_url
Returns the environment’s URL
โ†’page
Built into pytest-playwright
โ†’logged_in_page
Composes both
import pytest
from playwright.sync_api import Page

@pytest.fixture
def base_url() -> str:
    return "https://staging.example.com"

@pytest.fixture
def logged_in_page(page: Page, base_url: str) -> Page:
    """Compose the built-in page fixture with base_url to arrive at a logged-in state."""
    page.goto(f"{base_url}/login")
    page.get_by_label("Email").fill("test@example.com")
    page.get_by_label("Password").fill("Password123")
    page.get_by_role("button", name="Log in").click()
    page.wait_for_url("**/dashboard")
    return page
# The test code doesn't need to think about the login step โ€” it starts already logged in
def test_dashboard_shows_welcome_message(logged_in_page: Page):
    assert logged_in_page.get_by_text("Welcome").is_visible()
๐Ÿ” From the Field: Once you get comfortable composing fixtures, you start building things up from small fixtures toward whatever precondition you need. Keeping each fixture focused on one responsibility, rather than cramming everything into one, makes it far easier to reuse pieces later when you need a different combination.

โ‘ค The Factory Pattern: Generating Data More Than Once Per Test

A normal fixture can only return one value per test. When you need to create several pieces of data with different conditions within the same test, you use the factory pattern โ€” a fixture that returns a function instead of a value.

A normal fixtureThe factory pattern
What it returnsThe value itselfA function that generates the value
Times it can generate data per testOnce (the return value is fixed)As many times as you call it
Good fit forA single precondition is enoughYou need several pieces of data with different conditions
import pytest

@pytest.fixture
def make_user():
    """A factory fixture that returns a function for generating users."""
    created_users = []

    def _make_user(name: str = "Taro", email: str | None = None) -> dict:
        # Keep the email safe even for names with spaces, like "Alice Smith"
        safe_name = name.lower().replace(" ", ".")
        user = {
            "name": name,
            "email": email or f"{safe_name}@example.com",
        }
        created_users.append(user)
        return user

    yield _make_user

    # Clean up every user we created, once the test is done
    for user in created_users:
        delete_user(user["email"])
def test_transfer_between_two_users(make_user):
    # Create two users with different conditions, within a single test
    sender = make_user(name="Alice")
    receiver = make_user(name="Bob")

    transfer(sender, receiver, amount=1000)

    assert get_balance(receiver) == 1000
๐Ÿ’ก Practical Tip: In the factory pattern, recording everything you create in a list like created_users, then cleaning it all up after yield, mechanically enforces the rule that whatever you create gets deleted.

Common Mistakes Seen in Practice

โ‘  Widening scope too aggressively and letting state leak between tests

Making everything session-scoped in the name of speed can result in one test mutating a shared object’s state, leaving later tests starting from an unexpected condition. Always confirm something is genuinely safe to share before widening its scope.

โ‘ก Overusing autouse until a test’s dependencies become invisible

Marking a lot of setup as autouse=True just because it’s convenient makes it impossible to tell “what this test actually relies on” from the test code alone. Limit it to what’s genuinely universal across every test.

โ‘ข Forgetting to write the cleanup code after yield

Writing yield without any follow-up cleanup leaves browser processes or DB connections hanging around, consuming resources as the test suite runs. Every time you write a fixture, make a habit of checking whether cleanup is actually needed.

โ‘ฃ Forcing a normal fixture to handle a job the factory pattern was built for

When a test genuinely needs several pieces of data with different conditions but you try to make a normal fixture work anyway, you often end up duplicating data-generation logic inside the test itself. That’s the signal to switch to the factory pattern.

FAQ

Q. Is a fixture the same thing as the “Arrange” step in the AAA pattern?

Not exactly. A fixture is a mechanism for extracting setup logic that’s shared across multiple tests, while Arrange is a structural phase within a test function that represents “what’s being set up.” Extracting shareable setup logic into a fixture shortens the Arrange phase of each test, which makes the actual point of the test easier to see โ€” that’s how the two relate.

Q. Does changing a fixture’s scope affect other tests?

Changing scope changes when the fixture is created and destroyed, which can change the outcome of any test that depends on it. Especially when widening scope, confirm the shared state is genuinely safe first, and run the full test suite once after the change to check.

Q. If there are multiple conftest.py files, which fixture wins?

pytest prioritizes the conftest.py closest to the test file’s directory. If the same fixture name is defined in multiple conftest.py files, the one at the level closer to the test file takes precedence. You can use this to override a fixture’s behavior for just one specific directory.

Q. Is it okay to write an assertion inside a fixture?

It’s technically possible, but not recommended. A fixture should stay focused on “setup,” with verification (assertions) living in the test function. Mixing assertions into a fixture makes it hard to tell, when a test fails, whether the fixture is the problem or the test itself is.

Q. How do I decide between the factory pattern and parametrize?

parametrize is the right fit when you want to run the same test logic repeatedly with different input data. The factory pattern is the right fit when you want to dynamically generate several pieces of data on the spot, within a single test. Choose based on whether your goal is “more test cases” or “assembling data within one test.”

๐Ÿ“‹ Summary

  • Choose scope based on “what’s safe to share.” When in doubt, start with function โ€” it’s the safer default
  • yield lets setup and teardown live together in one fixture
  • autouse is convenient, but overusing it hides dependencies โ€” limit it to what’s genuinely needed everywhere
  • Fixtures can be composed โ€” build up specific preconditions by combining smaller fixtures
  • When you need to generate data more than once within a test, reach for the factory pattern

Once you move past thinking of fixtures as “something that sets things up before a test,” they become a foundation that supports the design of your whole test suite. Take a look at the tests you’re writing right now โ€” is there a scope worth revisiting, or a cleanup step you forgot to write?

Copied title and URL