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.fixtureto 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
functionscope and only widen it once runtime becomes a real problem yieldlets 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
- โ scope: Deciding How Widely a Fixture Gets Shared
- โก yield Fixtures: Setup and Teardown in One Place
- โข autouse: Running Automatically Without Being Requested
- โฃ Composing Fixtures: One Fixture Using Another
- โค The Factory Pattern: Generating Data More Than Once Per Test
- Common Mistakes Seen in Practice
- FAQ
- ๐ Summary
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.
โ scope: Deciding How Widely a Fixture Gets Shared
The scope argument on a fixture controls how widely it gets reused. The default is function.
| scope | Created | Good fit for |
|---|---|---|
function (default) | Fresh for every test function | The baseline whenever you don’t want state shared across tests |
class | Once per test class | Reusing a common precondition across multiple tests in a class |
module | Once per file | Sharing across multiple test classes or functions in the same file |
session | Once for the entire test run | Expensive 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()session scope in particular, think through whether it can stay in a state that doesn’t depend on test execution order.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.
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"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() == 0autouse 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()โค 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 fixture | The factory pattern | |
|---|---|---|
| What it returns | The value itself | A function that generates the value |
| Times it can generate data per test | Once (the return value is fixed) | As many times as you call it |
| Good fit for | A single precondition is enough | You 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) == 1000created_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.
๐ Related Articles
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?
