Skip to content
Codeloom
Python

Python Testing with pytest: Fixtures, Marks, and Plugins

Master pytest from fixtures and parametrize to marks, plugins, and CI integration. Write fast, maintainable Python tests with practical examples.

·9 min read · By Codeloom
Intermediate 13 min read

What you'll learn

  • Write and organize tests with pytest conventions
  • Use fixtures for setup, teardown, and dependency injection
  • Parametrize tests and apply marks for flexible test runs
  • Extend pytest with popular plugins

Prerequisites

  • Python functions and classes
  • Basic command-line usage

pytest is the de facto standard for testing Python code. It replaces the verbose unittest.TestCase boilerplate with plain functions, powerful fixtures, and a plugin ecosystem that covers everything from coverage reporting to parallel execution. This guide walks through the features you will actually use day to day.

Getting Started

Install pytest and verify it works:

# Install
# pip install pytest

# test_basic.py
def test_addition():
    assert 1 + 1 == 2

def test_string_contains():
    greeting = "hello world"
    assert "world" in greeting

Run with pytest test_basic.py -v. pytest discovers any file matching test_*.py and runs any function starting with test_.

Assertion Introspection

Unlike unittest, you use plain assert statements. pytest rewrites them at import time to show detailed failure messages:

def test_list_equality():
    expected = [1, 2, 3, 4]
    actual = [1, 2, 3, 5]
    assert actual == expected
    # Output shows exactly which element differs:
    # assert [1, 2, 3, 5] == [1, 2, 3, 4]
    # At index 3 diff: 5 != 4

No need for assertEqual, assertIn, or assertRaises. Plain Python does the job.

Testing Exceptions

Use pytest.raises as a context manager:

import pytest

def divide(a: float, b: float) -> float:
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

def test_divide_by_zero():
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        divide(10, 0)

def test_divide_normal():
    assert divide(10, 2) == 5.0

The match parameter accepts a regex, so you can verify the error message content.

Fixtures: Setup and Dependency Injection

Fixtures replace setUp and tearDown methods. They are functions decorated with @pytest.fixture that provide data or resources to tests.

import pytest

@pytest.fixture
def sample_users() -> list[dict]:
    return [
        {"name": "Alice", "age": 30, "active": True},
        {"name": "Bob", "age": 25, "active": False},
        {"name": "Charlie", "age": 35, "active": True},
    ]

def test_active_users(sample_users):
    active = [u for u in sample_users if u["active"]]
    assert len(active) == 2

def test_user_names(sample_users):
    names = [u["name"] for u in sample_users]
    assert "Alice" in names

pytest matches the fixture name to the test parameter name and injects the return value automatically.

Fixture Scopes

Fixtures can be scoped to control how often they run:

import pytest
import sqlite3

@pytest.fixture(scope="session")
def db_connection():
    """Created once for the entire test session."""
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
    yield conn
    conn.close()

@pytest.fixture(scope="function")
def db_cursor(db_connection):
    """Created fresh for each test function."""
    cursor = db_connection.cursor()
    db_connection.execute("DELETE FROM users")  # clean slate
    yield cursor
    cursor.close()

def test_insert_user(db_cursor):
    db_cursor.execute("INSERT INTO users (name) VALUES (?)", ("Alice",))
    db_cursor.execute("SELECT COUNT(*) FROM users")
    assert db_cursor.fetchone()[0] == 1

def test_empty_table(db_cursor):
    db_cursor.execute("SELECT COUNT(*) FROM users")
    assert db_cursor.fetchone()[0] == 0  # cleaned by fixture

Available scopes: function (default), class, module, package, session.

Fixture Teardown with yield

The yield keyword splits a fixture into setup and teardown phases:

import pytest
import tempfile
import os

@pytest.fixture
def temp_file():
    # Setup
    fd, path = tempfile.mkstemp(suffix=".txt")
    os.write(fd, b"test data")
    os.close(fd)

    yield path  # Test runs here

    # Teardown
    os.unlink(path)

def test_file_exists(temp_file):
    assert os.path.exists(temp_file)

def test_file_content(temp_file):
    with open(temp_file) as f:
        assert f.read() == "test data"

Teardown code runs even if the test fails, making it reliable for cleanup.

Parametrize: Data-Driven Tests

@pytest.mark.parametrize runs a test function multiple times with different inputs:

import pytest

def is_palindrome(s: str) -> bool:
    cleaned = s.lower().replace(" ", "")
    return cleaned == cleaned[::-1]

@pytest.mark.parametrize("text, expected", [
    ("racecar", True),
    ("hello", False),
    ("A man a plan a canal Panama", True),
    ("", True),
    ("ab", False),
])
def test_is_palindrome(text: str, expected: bool):
    assert is_palindrome(text) == expected

Each tuple becomes a separate test case with its own pass/fail status. This eliminates copy-paste test functions.

Stacking Parametrize

You can stack multiple decorators to create a cartesian product:

import pytest

@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_multiplication(x: int, y: int):
    result = x * y
    assert result == x * y  # 6 test cases total

Parametrize with IDs

Give test cases readable names:

import pytest

@pytest.mark.parametrize("input_val, expected", [
    pytest.param(0, "zero", id="zero-case"),
    pytest.param(1, "positive", id="positive-case"),
    pytest.param(-1, "negative", id="negative-case"),
])
def test_classify(input_val: int, expected: str):
    if input_val == 0:
        result = "zero"
    elif input_val > 0:
        result = "positive"
    else:
        result = "negative"
    assert result == expected

Marks: Categorize and Control Tests

Marks are labels you attach to tests. pytest includes several built-in marks.

skip and skipif

import pytest
import sys

@pytest.mark.skip(reason="Not implemented yet")
def test_future_feature():
    pass

@pytest.mark.skipif(sys.platform == "win32", reason="Unix-only test")
def test_unix_permissions():
    import os
    assert os.getuid() >= 0

xfail: Expected Failures

import pytest

@pytest.mark.xfail(reason="Known bug in parser, tracked in JIRA-1234")
def test_edge_case_parsing():
    result = parse("malformed<<input")  # noqa: F821
    assert result is not None

If the test fails, it shows as xfail (not a failure). If it unexpectedly passes, it shows as xpass.

Custom Marks

Define your own marks for selective test runs:

import pytest

@pytest.mark.slow
def test_large_dataset():
    data = list(range(10_000_000))
    assert len(data) == 10_000_000

@pytest.mark.integration
def test_api_connection():
    # Would connect to real API
    pass

Run specific marks from the command line:

# Run only slow tests
# pytest -m slow

# Run everything except slow tests
# pytest -m "not slow"

# Run integration OR slow tests
# pytest -m "integration or slow"

Register custom marks in pyproject.toml to avoid warnings:

# pyproject.toml
# [tool.pytest.ini_options]
# markers = [
#     "slow: marks tests as slow",
#     "integration: marks integration tests",
# ]

conftest.py: Shared Fixtures and Hooks

Place a conftest.py file in your test directory to share fixtures across multiple test files:

# tests/conftest.py
import pytest

@pytest.fixture
def api_client():
    """Available to all tests in this directory and subdirectories."""
    from myapp.client import APIClient
    client = APIClient(base_url="http://localhost:8000")
    yield client
    client.close()

@pytest.fixture
def auth_headers():
    return {"Authorization": "Bearer test-token-123"}

Any test file in the same directory or subdirectories can use these fixtures without importing them. pytest discovers conftest.py files automatically.

Layered conftest Files

You can have multiple conftest.py files at different directory levels:

# tests/conftest.py           -> session-wide fixtures
# tests/unit/conftest.py      -> unit test fixtures
# tests/integration/conftest.py -> integration test fixtures

Inner conftest files can use fixtures from outer ones.

Essential Plugins

pytest’s plugin ecosystem is one of its greatest strengths.

pytest-cov: Coverage Reporting

# pip install pytest-cov
# pytest --cov=myapp --cov-report=term-missing

# Output shows which lines are not covered:
# Name                 Stmts   Miss  Cover   Missing
# myapp/core.py           50      5    90%   23-27

pytest-xdist: Parallel Execution

# pip install pytest-xdist
# pytest -n auto          # Use all CPU cores
# pytest -n 4             # Use 4 workers

This can dramatically speed up large test suites by running tests in parallel across multiple processes.

pytest-mock: Simplified Mocking

# pip install pytest-mock

def test_api_call(mocker):
    # mocker is a fixture from pytest-mock
    mock_get = mocker.patch("requests.get")
    mock_get.return_value.status_code = 200
    mock_get.return_value.json.return_value = {"status": "ok"}

    import requests
    response = requests.get("https://api.example.com/health")

    assert response.status_code == 200
    assert response.json() == {"status": "ok"}
    mock_get.assert_called_once_with("https://api.example.com/health")

pytest-randomly: Randomize Test Order

# pip install pytest-randomly
# pytest -p randomly

# Reveals tests that depend on execution order (hidden bugs)

Structuring a Real Test Suite

Here is a practical project layout:

# project/
# +-- src/
# |   +-- myapp/
# |       +-- __init__.py
# |       +-- models.py
# |       +-- services.py
# +-- tests/
# |   +-- conftest.py
# |   +-- unit/
# |   |   +-- test_models.py
# |   |   +-- test_services.py
# |   +-- integration/
# |       +-- test_api.py
# +-- pyproject.toml

A typical pyproject.toml configuration:

# [tool.pytest.ini_options]
# testpaths = ["tests"]
# addopts = "-v --strict-markers --cov=myapp"
# markers = [
#     "slow: marks tests as slow (deselect with '-m \"not slow\"')",
#     "integration: marks integration tests",
# ]

Fixture Factories and Advanced Patterns

Sometimes you need fixtures that accept arguments. Use a factory pattern:

import pytest

@pytest.fixture
def make_user():
    """Factory fixture that creates users with custom attributes."""
    created_users = []

    def _make_user(name: str = "Test User", age: int = 25, active: bool = True):
        user = {"name": name, "age": age, "active": active}
        created_users.append(user)
        return user

    yield _make_user

    # Cleanup: delete all created users
    created_users.clear()

def test_young_user(make_user):
    user = make_user(name="Young", age=18)
    assert user["age"] < 21

def test_inactive_user(make_user):
    user = make_user(active=False)
    assert not user["active"]

Autouse Fixtures

Fixtures with autouse=True run for every test without being requested:

import pytest
import time

@pytest.fixture(autouse=True)
def timer(request):
    start = time.perf_counter()
    yield
    elapsed = time.perf_counter() - start
    print(f"\n{request.node.name}: {elapsed:.4f}s")

This prints execution time for every test without modifying any test function.

Testing Async Code

pytest-asyncio lets you test coroutines directly:

# pip install pytest-asyncio
import pytest
import asyncio

@pytest.mark.asyncio
async def test_async_addition():
    await asyncio.sleep(0.01)  # simulate async work
    assert 1 + 1 == 2

@pytest.mark.asyncio
async def test_gather():
    async def double(n: int) -> int:
        await asyncio.sleep(0.01)
        return n * 2

    results = await asyncio.gather(double(1), double(2), double(3))
    assert results == [2, 4, 6]

Wrapping Up

pytest makes testing in Python productive instead of painful. Start with plain assert statements and simple test functions. Add fixtures when you need shared setup. Use parametrize to eliminate duplicate test code. Apply marks to control which tests run in different environments. Layer in plugins like pytest-cov and pytest-xdist as your test suite grows. The combination of fixtures for dependency injection, parametrize for data-driven tests, and a rich plugin ecosystem means pytest scales from a single script to a thousand-file monorepo without changing your testing patterns.