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.
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.
Related articles
- Python Advanced pytest Patterns for Real-World Projects
Master pytest fixtures, parametrize, monkeypatch, and custom markers to write maintainable, expressive tests for production Python code.
- FastAPI Testing FastAPI Apps with TestClient and Pytest Fixtures
Build a robust test suite for FastAPI with TestClient, pytest fixtures, dependency overrides, and async testing patterns.
- FastAPI FastAPI Testing with pytest
Write fast, reliable tests for FastAPI apps using TestClient, pytest fixtures, dependency overrides, and a separate test database.
- Testing Pytest Basics: Writing Your First Python Tests
A practical introduction to pytest — installation, test discovery, the assert statement, parametrize, fixtures, and the command-line flags you will use every day.