pytest Fixtures Explained: Setup, Teardown, yield, Scope and autouse

Fixtures are pytest’s way of preparing everything a test needs and cleaning up afterwards. The page fixture you have used since Lesson 4 is one, provided by pytest-playwright. In this lesson you will write your own: simple data fixtures, setup and teardown with yield, fixtures that depend on other fixtures, scopes that control how often a fixture runs, autouse fixtures, factories, and a logged-in page fixture for Playwright tests.

Advertisement

📖 What You’ll Learn: what a fixture is and how tests request it by name, return vs yield, teardown that runs even after a failure, fixture scopes (function, class, module, package, session) and how to see them with –setup-show, fixtures that use other fixtures, autouse, the request object, factory fixtures, useful built-in fixtures, and a reusable logged-in Playwright page.


🧩 What Is a Fixture?

A fixture is a function decorated with @pytest.fixture. A test asks for it by adding a parameter with the same name; pytest runs the fixture first and passes its result in. This is called *dependency injection*: the test declares what it needs, and pytest provides it.

📄 tests/test_data_fixtures.py

import pytest


@pytest.fixture
def user():
    """Test data for a standard user."""
    return {"username": "standard_user", "password": "secret_sauce"}


@pytest.fixture
def cart_items():
    return ["Sauce Labs Backpack", "Sauce Labs Onesie"]


def test_user_has_password(user):
    assert user["password"] == "secret_sauce"


def test_cart_count(cart_items):
    assert len(cart_items) == 2


def test_both_fixtures(user, cart_items):
    assert user["username"].startswith("standard")
    assert "Sauce Labs Backpack" in cart_items
  • Each test lists the fixtures it needs as parameters, in any order.
  • The fixture runs once per test that uses it by default, so every test gets a fresh dictionary or list. One test changing its data can never affect another.
  • The docstring appears in pytest --fixtures, which lists every fixture available to your tests.

🖥️ Output

...                                                                      [100%]
3 passed in 0.01s

🔁 Setup and Teardown with yield

Many resources must be cleaned up: files, test users, database rows, browser contexts. Put the setup before yield, give the test its value with yield, and put the cleanup after it (tests/test_yield.py):

📄 tests/test_yield.py

import pytest


@pytest.fixture
def temp_user():
    print("\n  [setup] create user qa_temp")
    user = {"name": "qa_temp", "active": True}
    yield user                                  # the test runs here
    print("  [teardown] delete user qa_temp")   # runs after the test, pass or fail


def test_user_is_active(temp_user):
    print("  [test] checking user")
    assert temp_user["active"]


def test_failure_still_tears_down(temp_user):
    print("  [test] about to fail on purpose")
    with pytest.raises(AssertionError):
        assert temp_user["name"] == "someone_else"

⌨️ Terminal

pytest -q -s tests/test_yield.py

🖥️ Output

  [setup] create user qa_temp
  [test] checking user
.  [teardown] delete user qa_temp

  [setup] create user qa_temp
  [test] about to fail on purpose
.  [teardown] delete user qa_temp

2 passed in 0.01s
  • The order is always setup, test, teardown. (The dot that marks a passed test is printed between the test and its teardown.)
  • Teardown code after yield runs even when the test fails, because pytest resumes the fixture after the test finishes, whatever the result.
  • If the setup part raises an exception, the test is reported as an *error* and the teardown part does not run (there was nothing set up to clean).

This is exactly how pytest-playwright’s context and page fixtures work: create a context and page, yield the page to your test, close them afterwards.


📏 Fixture Scope: How Often Does It Run?

scope= decides how long a fixture’s value lives and how often it is created:

ScopeCreated once perTypical use
function (default)testPages, test data, anything a test may change
classtest classShared state for a group of related tests
moduletest fileExpensive setup used by one file
packagepackage (folder with __init__.py)Shared setup for a folder
sessionwhole test runBrowsers, API clients, config loaded once

pytest can show exactly when each fixture is set up and torn down with --setup-show (tests/test_scopes.py):

📄 tests/test_scopes.py

import pytest


@pytest.fixture(scope="session")
def config():
    return {"base_url": "https://www.saucedemo.com/"}


@pytest.fixture(scope="module")
def api_token(config):
    return "token-for-" + config["base_url"]


@pytest.fixture
def cart():
    return []


def test_first(config, api_token, cart):
    cart.append("Backpack")
    assert len(cart) == 1


def test_second(config, api_token, cart):
    assert cart == []                   # a fresh cart: function scope

These tests do not use a browser, so the command below adds -p no:playwright to switch the pytest-playwright plugin off for this run and keep the output short:

⌨️ Terminal

pytest -q --setup-show -p no:playwright tests/test_scopes.py

🖥️ Output

SETUP    S config
    SETUP    M api_token (fixtures used: config)
        SETUP    F cart
        tests/test_scopes.py::test_first (fixtures used: api_token, cart, config) .
        TEARDOWN F cart
        SETUP    F cart
        tests/test_scopes.py::test_second (fixtures used: api_token, cart, config) .
        TEARDOWN F cart
    TEARDOWN M api_token
TEARDOWN S config
2 passed in 0.00s

With the plugin active, the same command also lists pytest-playwright’s own automatic fixtures, such as SETUP S delete_output_dir (which clears test-results once per run) and a function-scoped _pw_trace_api_requests. Fixtures whose names start with _ are the plugin’s internals; you never request them yourself.

Reading it:

  • S, M and F mark session, module and function scope.
  • config and api_token were created once and reused by both tests; cart was created fresh for each test, which is why the second test sees an empty list.
  • Teardown happens in reverse order: function first, then module, then session.
  • api_token asks for config: fixtures can use other fixtures, and pytest resolves the whole chain for you.

⚠️ Watch out: A fixture can only use fixtures of the same or wider scope. A session fixture that asks for a function-scoped fixture (such as page) fails with ScopeMismatch: You tried to access the function scoped fixture page with a session scoped request object., because the session value would outlive the page.

Scope in pytest-playwright

pytest-playwright uses these scopes to make tests fast and isolated at the same time:

FixtureScopeWhy
playwright, browser_type, browser, browser_namesessionLaunching a browser is slow, so it happens once per run
browser_context_argssessionSettings are calculated once
context, pagefunctionEvery test gets a clean, isolated session

🤖 autouse Fixtures

A fixture with autouse=True runs for every test in its scope without being requested. Use it for things every test needs, such as resetting state or setting timeouts:

@pytest.fixture(autouse=True)
def short_action_timeout(page):
    page.set_default_timeout(10_000)

Use autouse sparingly: hidden setup makes tests harder to understand. If only some tests need something, request it explicitly.


🏭 Factory Fixtures and the request Object

Sometimes a test needs several objects of the same kind, with different settings. Return a function (a *factory*) instead of a value. And when a fixture needs information about the test that uses it, ask for the built-in request fixture (tests/test_factory.py):

📄 tests/test_factory.py

import pytest


@pytest.fixture
def make_user():
    created = []

    def _make_user(name: str, role: str = "viewer") -> dict:
        user = {"name": name, "role": role}
        created.append(user)
        return user

    yield _make_user
    print(f"\n  cleaning up {len(created)} users: {[u['name'] for u in created]}")


def test_two_users(make_user, request):
    admin = make_user("ana", role="admin")
    viewer = make_user("ben")
    print(f"\n  running {request.node.name}")
    assert admin["role"] == "admin" and viewer["role"] == "viewer"
  • make_user yields the inner function _make_user; the test calls it as many times as it wants.
  • The fixture remembers what it created and cleans everything up after the test.
  • request.node.name is the current test’s name. The request object also gives access to markers, the config and parametrized values.

🧰 Useful Built-in Fixtures

pytest itself provides fixtures you can request at any time (tests/test_builtins.py):

📄 tests/test_builtins.py

import os


def test_tmp_path(tmp_path):
    report = tmp_path / "report.txt"           # a fresh folder for this test
    report.write_text("2 passed")
    assert report.read_text() == "2 passed"


def test_monkeypatch_env(monkeypatch):
    monkeypatch.setenv("BASE_URL", "https://staging.example.com")
    assert os.environ["BASE_URL"] == "https://staging.example.com"
    # the variable is restored automatically after the test


def test_capsys(capsys):
    print("Products")
    captured = capsys.readouterr()
    assert captured.out == "Products\n"
FixtureWhat it gives you
tmp_pathA unique temporary folder (pathlib.Path) per test
tmp_path_factoryTemporary folders for session-scoped fixtures
monkeypatchTemporarily change environment variables, attributes or dict items; undone after the test
capsysCapture what the test printed
requestInformation about the requesting test (name, markers, params, config)
pytestconfigAccess to command-line options and ini values

⌨️ Terminal

pytest -q -s tests/test_factory.py tests/test_builtins.py

🖥️ Output

  running test_two_users
.
  cleaning up 2 users: ['ana', 'ben']
...
4 passed in 0.02s

🎭 A Logged-in Page Fixture for Playwright

Here is the pattern you will use constantly: a fixture that builds on page, logs in, and hands the test a page that is already on the products screen (tests/test_logged_in.py):

📄 tests/test_logged_in.py

import re

import pytest
from playwright.sync_api import Page, expect


@pytest.fixture
def logged_in_page(page: Page) -> Page:
    page.goto("https://www.saucedemo.com/")
    page.get_by_placeholder("Username").fill("standard_user")
    page.get_by_placeholder("Password").fill("secret_sauce")
    page.get_by_role("button", name="Login").click()
    expect(page).to_have_url(re.compile(r".*/inventory\.html"))
    return page


def test_six_products_listed(logged_in_page: Page):
    expect(logged_in_page.locator(".inventory_item")).to_have_count(6)


def test_add_to_cart(logged_in_page: Page):
    logged_in_page.locator("[data-test='add-to-cart-sauce-labs-backpack']").click()
    expect(logged_in_page.locator("[data-test='shopping-cart-badge']")).to_have_text("1")
  • logged_in_page requests page (function scope), so it is function-scoped too, and every test gets its own fresh, logged-in session.
  • The expect inside the fixture makes the setup fail loudly (as an *error*) if login is broken, instead of producing confusing failures later.
  • The tests are now about behaviour, not login steps.

🖥️ Output

..                                                                       [100%]
2 passed in 3.73s

In Module 8 you will make this even faster by logging in once and reusing the saved session with storage_state.


❓ Frequently Asked Questions

🤔 Where should I put fixtures that many test files use?

In a conftest.py file. Fixtures defined there are available to all tests in that folder and below without imports. That is the next lesson.

🤔 Can a fixture return nothing?

Yes. A fixture that only does setup (such as setting a timeout) can return None; tests request it, or it is autouse=True.

🤔 What happens if two fixtures have the same name?

The one closest to the test wins: a fixture in the test file overrides one in conftest.py, which overrides one from a plugin. pytest-playwright’s browser_context_args customisation in Lesson 6 relies on exactly this.

🤔 Should I use a session-scoped page to make tests faster?

No. Sharing a page lets tests leak state into each other and makes failures depend on test order. Share the browser (pytest-playwright already does), not the page.


🏁 Summary

  • Fixtures are requested by parameter name; pytest runs them and injects the result.
  • yield separates setup from teardown, and teardown runs even after a failed test.
  • Scope controls lifetime: function (default), class, module, package, session; --setup-show reveals the order.
  • Fixtures can use other fixtures of the same or wider scope; factories create many objects; built-ins include tmp_path, monkeypatch, capsys and request.

Next, you will organise fixtures across your project with conftest.py files and learn how pytest overrides them.

🎬 Watch the Playwright tutorial series on YouTube!

Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page

Leave a Comment