6 Essential Selenium Python Libraries for Test Automation in 2026

test-automation

pytest fixtures are a core feature that eliminate duplicate test code and centralize setup and teardown logic. Mastering yield, scope, and conftest.py will help you write maintainable, production-grade tests.

๐Ÿ“Œ Who This Article Is For

โœ… Developers just getting started with pytest
โœ… Anyone who isn’t fully confident writing fixtures
โœ… Those who want to clearly understand yield, scope, and conftest.py

๐Ÿ“– What You’ll Learn in This Article

โœ” Basic fixture syntax and how injection works
โœ” The yield teardown pattern (the most common approach in real projects)
โœ” How to choose scope and what to watch out for in parallel runs
โœ” Where to place conftest.py correctly
โœ” How to use autouse and built-in fixtures like tmp_path
โœ” Common pitfalls and how to fix them

I have worked as a QA engineer for over 15 years, focusing on test automation with Selenium, Playwright, and pytest.
This article goes beyond textbook explanations โ€” it covers real problems I’ve seen in actual codebases, along with practical solutions.

โœ… Key Takeaways

  • Use yield to combine setup and teardown in a single fixture
  • Use scope to reuse expensive operations and speed up your test suite
  • Put shared fixtures in conftest.py so the whole team can use them

What Are pytest Fixtures?

A pytest fixture is a mechanism for managing setup, teardown, and shared data needed before and after tests.
You define a function with the @pytest.fixture decorator, and pytest automatically injects it into any test function that declares a matching argument name.

The concept is similar to @BeforeEach / @AfterEach in JUnit, but pytest fixtures are more powerful โ€” they support flexible scope control, nesting, and parameterization.

Basic Syntax and Simple Usage

Let’s start with the smallest possible example.

import pytest

# โ‘  Define a fixture
@pytest.fixture
def sample_data() -> dict:
    return {"user": "alice", "age": 30}

# โ‘ก Argument name = fixture name โ†’ pytest auto-injects it
def test_user_name(sample_data: dict) -> None:
    assert sample_data["user"] == "alice"

def test_user_age(sample_data: dict) -> None:
    assert sample_data["age"] == 30

As long as the argument name matches the fixture name, pytest handles injection automatically.
No import is needed, and multiple test functions can share the same fixture.

5 Recommended Fixture Patterns

1. Use yield to Combine Setup and Teardown

This is the most commonly used pattern in real projects. Everything before yield is setup; everything after is teardown.
The teardown code runs regardless of whether the test passes or fails.

import pytest
import requests
import logging

logger = logging.getLogger(__name__)

@pytest.fixture
def api_session() -> requests.Session:
    # --- Setup ---
    session = requests.Session()
    session.headers.update({"Content-Type": "application/json"})
    logger.info("Session opened")

    yield session  # โ† handed to the test function

    # --- Teardown (always runs, even on test failure) ---
    session.close()
    logger.info("Session closed")


def test_get_users(api_session: requests.Session) -> None:
    response = api_session.get("https://example.com/api/users")  # sample URL
    assert response.status_code == 200

You can technically implement teardown with return using try/finally, but pytest’s yield pattern lets you write teardown more naturally.
For resources that need to be closed โ€” DB connections, HTTP sessions, browser drivers โ€” the yield pattern is the widely adopted approach in real projects.

2. Use scope to Reuse Expensive Operations

By default, fixtures are created and destroyed for each individual test. Setting a scope extends their lifetime so they can be shared across multiple tests.

scopeLifetimeTypical Use
function (default)Per testLightweight setup, data that must be independent
classPer classShared data within a test class
modulePer fileDB connections, heavy mock servers
sessionEntire test runBrowser launch, auth token retrieval
import pytest
import requests

# Login API is called only once for the entire test session
@pytest.fixture(scope="session")
def auth_token() -> str:
    response = requests.post(
        "https://example.com/api/auth/login",  # sample URL
        json={"username": "testuser", "password": "secret"},
        timeout=(3, 10),
    )
    response.raise_for_status()
    data = response.json()
    assert "token" in data
    return data["token"]


def test_get_profile(auth_token: str) -> None:
    headers = {"Authorization": f"Bearer {auth_token}"}
    res = requests.get(
        "https://example.com/api/profile",  # sample URL
        headers=headers,
        timeout=(3, 10),
    )
    assert res.status_code == 200
โš ๏ธ Caution: Don’t mutate state in a session-scoped fixture
scope="session" itself is fine, but mutating shared state inside a session-scoped fixture can cause interference between tests when running in parallel with tools like pytest-xdist.
Keep session-scoped fixtures limited to read-only data and connection objects.

3. Share Fixtures via conftest.py

Fixtures defined in conftest.py are available to all tests in the same directory and its subdirectories โ€” no import needed.
It’s the standard place to put shared fixtures for your whole team.

# Directory layout
tests/
โ”œโ”€โ”€ conftest.py          โ† shared fixtures for all tests
โ”œโ”€โ”€ test_auth.py
โ””โ”€โ”€ api/
    โ”œโ”€โ”€ conftest.py      โ† fixtures scoped to the api/ directory only
    โ””โ”€โ”€ test_users.py
# tests/conftest.py
import pytest

@pytest.fixture(scope="session")
def base_url() -> str:
    return "https://example.com"  # sample URL (use env vars in real projects)

@pytest.fixture
def headers(auth_token: str) -> dict:
    return {"Authorization": f"Bearer {auth_token}"}
# tests/test_auth.py
# No import needed โ€” conftest.py fixtures are auto-discovered
def test_base_url(base_url: str) -> None:
    assert "example" in base_url

You can place conftest.py at multiple directory levels, making it easy to separate “project-wide fixtures” from “module-specific fixtures”.

4. Use params for Data-Driven Testing

The params option lets you run a test multiple times with different inputs by cycling through a list of values.

import pytest
import requests

INVALID_PAYLOADS = [
    pytest.param({"username": "", "password": "pass"}, id="empty_username"),
    pytest.param({"username": "user", "password": ""}, id="empty_password"),
    pytest.param({}, id="empty_body"),
]

# `request` is a special built-in argument pytest provides automatically
# (optional type hint: pytest.FixtureRequest)
@pytest.fixture(params=INVALID_PAYLOADS)
def invalid_login_payload(request) -> dict:
    return request.param


def test_login_validation(invalid_login_payload: dict) -> None:
    response = requests.post(
        "https://example.com/api/auth/login",  # sample URL
        json=invalid_login_payload,
        timeout=(3, 10),
    )
    # All invalid requests should return a 4xx response
    assert response.status_code in (400, 422)

Adding pytest.param(..., id="...") gives each case a name, making failure output much easier to read.

fixture params vs. @pytest.mark.parametrize โ€” a quick comparison

Criterionfixture params@pytest.mark.parametrize
Share same patterns across multiple test functionsโ—Žโ–ณ (must repeat per function)
Simple input variations for a single test functionโ–ณโ—Ž
Parameterize setup logic alongside dataโ—Žโ–ณ
Readability for beginnersโ–ณโ—Ž

5. Inject Fixtures into Fixtures to Separate Concerns

A fixture can receive other fixtures as arguments. Splitting “connection info”, “authentication”, and “client creation” into separate fixtures keeps each responsibility clear and small.

โš ๏ธ Inject in the right direction
When nesting fixtures, always inject a wider-scoped fixture into a narrower-scoped one. The reverse direction raises a ScopeMismatch error.
session โ†’ function  โœ… (wide โ†’ narrow: OK)
function โ†’ session  โŒ (narrow โ†’ wide: ScopeMismatch error)
import pytest
import requests

# 1. Base URL (session scope)
@pytest.fixture(scope="session")
def base_url() -> str:
    return "https://example.com/api"  # sample URL (use env vars in real projects)


# 2. Auth token (session scope โ€” injects base_url)
@pytest.fixture(scope="session")
def auth_token(base_url: str) -> str:
    response = requests.post(
        f"{base_url}/auth/login",
        json={"username": "testuser", "password": "secret"},
        timeout=(3, 10),
    )
    response.raise_for_status()
    data = response.json()
    assert "token" in data
    return data["token"]


# 3. Authenticated session (function scope โ€” injects auth_token)
@pytest.fixture
def authed_session(base_url: str, auth_token: str) -> requests.Session:
    session = requests.Session()
    session.headers.update({
        "Authorization": f"Bearer {auth_token}",
        "Content-Type": "application/json",
    })
    yield session
    session.close()


# Tests only need to receive authed_session
def test_get_users(authed_session: requests.Session) -> None:
    res = authed_session.get(
        "https://example.com/api/users",  # sample URL
        timeout=(3, 10),
    )
    assert res.status_code == 200

With this structure, switching environments only requires changing base_url.
Test functions stay clean โ€” they only need to know about authed_session.

6. Use autouse to Apply a Fixture Automatically (Advanced)

Setting autouse=True applies the fixture to every test automatically, without needing to declare it as an argument.
Common uses include logging, timezone locking, and DB reset between tests.

import pytest
import logging

logger = logging.getLogger(__name__)

# Logs the start and end of every test automatically
@pytest.fixture(autouse=True)
def log_test_name(request: pytest.FixtureRequest) -> None:
    logger.info(f"โ–ถ START: {request.node.name}")
    yield
    logger.info(f"โ–  END  : {request.node.name}")
โš ๏ธ Don’t overuse autouse
autouse=True is convenient, but overusing it makes it harder to understand which fixtures a test depends on. In practice, it’s safer to use autouse only for things that are clearly needed by every single test โ€” and make sure your team is aligned on what those are.

Useful Built-in Fixtures

pytest ships with several built-in fixtures that are ready to use without any setup of your own.

FixturePurposeTypical Use Case
tmp_pathTemporary directory per testFile read/write tests
monkeypatchTemporarily patch env vars / attributesMocking external APIs or config values
caplogCapture log outputAssert on logging messages
capsysCapture stdout / stderrAssert on print() output
from pathlib import Path
import pytest

# tmp_path: a fresh temporary directory is auto-created per test
def test_write_csv(tmp_path: Path) -> None:
    output_file = tmp_path / "result.csv"
    output_file.write_text("id,name\n1,alice\n")
    assert output_file.read_text().startswith("id,name")

# monkeypatch: temporarily overrides an env var and restores it after the test
def test_api_base_url(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("API_BASE_URL", "https://staging.example.com")
    import os
    assert os.environ["API_BASE_URL"] == "https://staging.example.com"

7 Common Pitfalls in Real Projects

โš ๏ธ pytest fixture pitfalls to watch out for

  1. Missing test_ prefix on test function names
    pytest only collects functions that start with test_ by default. A function named check_login() will be silently skipped.
  2. conftest.py is in the wrong directory
    conftest.py must be placed in the same directory as your tests or a parent directory. Putting it in a sibling or unrelated directory results in a “fixture not found” error.
  3. Mutating state inside a session-scoped fixture
    Modifying shared state in a session-scoped fixture can cause test interference when running in parallel with pytest-xdist. Keep session fixtures to read-only data and connection objects.
  4. Custom marks not registered in pytest.ini (or pyproject.toml)
    Using @pytest.mark.smoke without registering the mark in your config file produces a PytestUnknownMarkWarning.
  5. print() output is invisible
    pytest captures stdout by default. Add pytest -s to see print output, or use the logging module instead.
  6. An exception before assert shows ERROR, not FAILED
    If an exception occurs in a fixture or before the first assert, pytest reports the result as ERROR rather than FAILED. Read the traceback carefully to locate the actual source.
  7. Running pytest from a subdirectory causes ModuleNotFoundError
    Running pytest from inside tests/ can exclude the project root from Python’s path, causing import errors. Always run pytest from the root directory where pytest.ini lives.

Frequently Asked Questions (FAQ)

Q. What is the difference between fixtures and setup_method / setup_function?

setup_method (inside a class) and setup_function (at module level) are xUnit-style initialization methods.
pytest fixtures offer more power โ€” dependency injection, scope control, and parameterization.
For new projects, fixtures are the widely preferred style. That said, you don’t need to immediately rewrite existing xUnit-style code; both approaches can coexist.

Q. How does pytest determine fixture execution order?

pytest analyzes the dependency graph of fixtures declared in a test function’s arguments and executes dependencies first. The order of fixtures at the same scope with no mutual dependency is not guaranteed. To enforce a specific order, make one fixture depend on another, or consider using a plugin like pytest-ordering.

Q. Should I use autouse=True?

It’s a good fit for “things every test absolutely needs” โ€” logging, DB resets, timezone locking, etc.
However, overusing it can make it hard to see what a test actually depends on, which complicates debugging.
As a general rule, prefer explicit fixture arguments and reserve autouse for cases where your team has a clear shared understanding of its purpose.

Q. When should I use fixture params vs. @pytest.mark.parametrize?

@pytest.mark.parametrize is ideal when you want to apply multiple input patterns to a single test function.
Fixture params is better when you need to reuse the same patterns across multiple test functions or when you want to parameterize setup logic alongside the data. See the comparison table above for details.

Q. Does the teardown code after yield run even when a test fails?

Yes. A fixture using yield behaves like a try/finally block โ€” teardown always runs even if the test reports FAILED.
However, if an exception occurs in the teardown code itself, pytest emits a warning. Add try/except around teardown logic if that’s a concern.

Q. How many conftest.py files can I have?

There is no limit โ€” you can place one in each directory. Organizing them as “project-wide”, “module-wide”, and “test-type-specific” by scope keeps your fixtures easy to navigate.

Summary

Key PointDescription
yield for teardownWrite setup and teardown in one fixture; widely preferred over return in real projects
scope for reuseBroaden to session/module for heavy operations; avoid mutating shared state
conftest.py for sharingNo import required; watch the directory placement carefully
params for data-driven testsHandy when the same patterns need to cover multiple test functions
Fixture injectionSeparate concerns by nesting fixtures; improves readability and maintainability
autouse=TrueAuto-applies to all tests; convenient but easy to overuse
Built-in fixturestmp_path / monkeypatch / caplog โ€” no implementation needed

Using pytest fixtures well leads to less duplicated code, no forgotten teardowns, and a faster test suite overall.
To start, just master the yield pattern and how to use conftest.py โ€” that’s enough to handle most everyday test automation work.

A practical first step: pick one setup block in your existing tests and extract it into a fixture. That’s all it takes to get started.

Copied title and URL