
Pytest
- 4 installs
- 19 repo stars
- Updated August 1, 2026
- xobotyi/cc-foundry
Helps with testing & qa tasks.
About
pytest is a Claude Code skill for testing & qa. It helps solo builders move faster with AI-assisted development.
- pytest
- Testing & QA
- AI-coding skill
Pytest by the numbers
- 4 all-time installs (skills.sh)
- Ranked #1,631 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/xobotyi/cc-foundry --skill pytestAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4 |
|---|---|
| repo stars | ★ 19 |
| Last updated | August 1, 2026 |
| Repository | xobotyi/cc-foundry ↗ |
What it does
Helps with testing & qa tasks.
Files
pytest
Test behavior, not implementation. Tests are executable documentation — if the test name doesn't explain what the code does, rewrite it.
pytest is Python's standard testing framework. It uses plain assert statements, fixtures for setup/teardown, and a rich plugin ecosystem. All patterns target Python 3.14+.
References
- Fixture patterns, scope, factories, teardown — [
${CLAUDE_SKILL_DIR}/references/fixtures.md]: Fixture lifecycle,
yield fixtures, factory pattern, request object, parametrized fixtures
- Parametrize patterns, indirect, IDs — [
${CLAUDE_SKILL_DIR}/references/parametrize.md]: Multi-parameter examples,
indirect fixtures, custom IDs, stacking decorators
- Monkeypatch patterns, scoped patches — [
${CLAUDE_SKILL_DIR}/references/monkeypatch.md]: API overview,
attribute/env/dict patching, scoped monkeypatch, common recipes
- Plugin ecosystem and configuration — [
${CLAUDE_SKILL_DIR}/references/plugins.md]: pytest-asyncio, pytest-mock,
pytest-xdist, pytest-cov configuration patterns
Test Structure
Discovery and Naming
- Files:
test_*.pyor*_test.py. Prefertest_<module>.pymatching source module. - Functions:
test_<behavior>— describe the behavior, not the method:test_returns_empty_list_when_no_matches
not test_search.
- Classes:
TestClassNamegroups related tests. No__init__method. Use classes when tests share setup; use bare
functions for independent tests.
- conftest.py is auto-discovered — no import needed. Place shared fixtures at the appropriate directory level.
Arrange-Act-Assert
Structure every test in three phases:
def test_user_creation_sets_defaults():
# Arrange
data = {"name": "Alice", "email": "alice@example.com"}
# Act
user = User.from_dict(data)
# Assert
assert user.name == "Alice"
assert user.is_active is True
assert user.roles == []- One act per test. If you need multiple acts, write multiple tests.
- Comments optional when phases are obvious. Add them when the test is long enough that phases aren't immediately
clear.
Test Granularity
- One concept per test. Multiple assertions are fine when they verify the same behavior. Separate tests when
behaviors are independent.
- Fast by default. Unit tests should run in milliseconds. Gate slow tests (network, DB) behind markers:
@pytest.mark.slow.
- Isolation is mandatory. Tests must not depend on execution order or shared mutable state. Each test sets up its
own world.
Fixtures
Core Rules
- Fixtures over setup methods. Fixtures are composable, scoped, and explicit. Never use
setUp/tearDownfrom
unittest.
- Explicit injection. Request fixtures by name in test parameters. Every dependency is visible in the test
signature.
- Smallest viable scope. Default is
functionscope (fresh per test). Use broader scopes (class,module,
session) only for expensive resources.
- `autouse=True` sparingly. Only for setup that genuinely applies to every test in scope (e.g., database transaction
rollback, temp directory cleanup).
Yield Fixtures (Setup + Teardown)
@pytest.fixture
def db_connection():
conn = create_connection()
yield conn
conn.close()
@pytest.fixture
def temp_config(tmp_path: Path):
config_file = tmp_path / "config.toml"
config_file.write_text('[app]\ndebug = true\n')
yield config_file
# cleanup automatic — tmp_path handles it- `yield` separates setup from teardown. Code after
yieldruns even if the test fails. - Prefer `yield` over
addfinalizer— clearer control flow. - Teardown must not raise. If cleanup can fail, wrap in
try/exceptand log.
Fixture Factories
When tests need multiple instances with varying configuration:
@pytest.fixture
def make_user():
def _make_user(name: str = "Alice", *, active: bool = True) -> User:
return User(name=name, is_active=active)
return _make_user
def test_inactive_users_excluded(make_user):
active = make_user("Alice", active=True)
inactive = make_user("Bob", active=False)
assert filter_active([active, inactive]) == [active]Fixture Scope
- `function` — Each test (default). Most fixtures — cheap setup, isolation.
- `class` — All tests in a class. Shared expensive setup within a test class.
- `module` — All tests in a file. Database connection per test file.
- `session` — Entire test run. Server startup, heavy resource initialization.
- Session-scoped fixtures must be in
conftest.pyat the root test directory. - Don't mix scopes carelessly. A function-scoped fixture cannot depend on a function-scoped fixture that modifies
state from a broader scope.
Built-in Fixtures
- `tmp_path` —
Pathto a temporary directory unique to the test (function scope) - `tmp_path_factory` — Factory for creating temp directories (session scope)
- `capsys` — Capture
sys.stdout/sys.stderrwrites - `capfd` — Capture file descriptor 1/2 output (catches C-level writes)
- `caplog` — Capture
loggingoutput with access to records - `monkeypatch` — Dynamic attribute/env/dict patching with automatic restore
- `request` — Fixture metadata:
.param,.node,.config,.fspath - `pytestconfig` — Access to the pytest config object
See ${CLAUDE_SKILL_DIR}/references/fixtures.md for fixture lifecycle details, parametrized fixtures, and advanced patterns.
Parametrize
Basic Usage
@pytest.mark.parametrize("input_val, expected", [
("hello", 5),
("", 0),
(" spaces ", 10),
])
def test_string_length(input_val: str, expected: int):
assert len(input_val) == expected- Use descriptive IDs:
pytest.param("", 0, id="empty-string")for readable output. - Each row is a distinct test. Failures report which parameter combination failed.
Stacking Decorators
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_combinations(x: int, y: int):
assert x + y > 0
# Generates: (1,10), (1,20), (2,10), (2,20)Indirect Parametrize
Pass parameter values to fixtures instead of directly to the test:
@pytest.fixture
def user(request) -> User:
return User(name=request.param)
@pytest.mark.parametrize("user", ["Alice", "Bob"], indirect=True)
def test_user_greeting(user: User):
assert user.name in user.greet()See ${CLAUDE_SKILL_DIR}/references/parametrize.md for multi-parameter patterns, conditional skipping within parametrize, and dynamic parametrize generation.
Markers
Built-in Markers
- `@pytest.mark.skip(reason="...")` — unconditionally skip.
- `@pytest.mark.skipif(condition, reason="...")` — skip when condition is true:
@pytest.mark.skipif(sys.platform == "win32", reason="Unix only").
- `@pytest.mark.xfail(reason="...")` — expected failure. Passes if the test fails, reports unexpected pass if it
succeeds. Use strict=True to fail on unexpected pass.
- `@pytest.mark.usefixtures("fixture_name")` — inject fixture without using its value.
- `@pytest.mark.filterwarnings("ignore::DeprecationWarning")` — per-test warning filter.
Custom Markers
Register in pyproject.toml to avoid warnings:
[tool.pytest.ini_options]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks integration tests",
]@pytest.mark.slow
def test_full_pipeline():
...Run subsets: pytest -m "not slow", pytest -m "integration and not slow".
Mocking
monkeypatch (Preferred for Simple Cases)
def test_reads_env_variable(monkeypatch):
monkeypatch.setenv("API_KEY", "test-key")
assert get_api_key() == "test-key"
def test_overrides_attribute(monkeypatch):
monkeypatch.setattr("myapp.config.DEBUG", True)
assert is_debug_mode() is True- `monkeypatch` auto-restores on test exit. No manual cleanup.
- Use for: environment variables, module attributes, dictionary entries,
sys.path.
unittest.mock (For Complex Mocking)
from unittest.mock import MagicMock, patch, AsyncMock
def test_service_calls_repository():
repo = MagicMock(spec=UserRepository)
repo.get.return_value = User(name="Alice")
service = UserService(repo=repo)
result = service.find_user("alice")
repo.get.assert_called_once_with("alice")
assert result.name == "Alice"
@patch("myapp.services.httpx.get")
def test_fetches_external_data(mock_get):
mock_get.return_value = MagicMock(json=lambda: {"status": "ok"})
assert fetch_status() == "ok"- Always use `spec=` on MagicMock — catches attribute typos at test time.
- `AsyncMock` for async functions. Auto-detected when patching async targets.
- `patch` target is where the name is looked up, not where it's defined:
@patch("myapp.services.httpx.get")not
@patch("httpx.get").
pytest-mock (mocker Fixture)
def test_with_mocker(mocker):
mock_fetch = mocker.patch("myapp.services.fetch_data")
mock_fetch.return_value = {"key": "value"}
result = process_data()
mock_fetch.assert_called_once()- `mocker` auto-restores after each test. Prefer over manual
patchcontext managers. - `mocker.patch("module.Class", autospec=True)` — recursively specs all attributes and method signatures from the
real object. Catches signature mismatches at test time.
- `mocker.spy(obj, "method")` wraps the real method — tracks calls while preserving behavior.
Mocking Rules
- Mock at boundaries. Mock external services, databases, filesystems, clocks — not internal functions.
- Don't mock what you own when a fake or in-memory implementation is available.
- Prefer dependency injection over patching. Pass collaborators as parameters, mock in tests.
- Never mock the thing you're testing. If you need to mock part of the SUT, the SUT has too many responsibilities —
split it.
Assertions
Plain Assert
pytest rewrites assert statements to show detailed failure messages:
assert result == expected # shows both values on failure
assert "error" in message # shows the full string
assert len(items) == 3 # shows actual length
assert all(x > 0 for x in values) # shows the values- No assertion library needed. Plain
assertwith pytest's rewrite engine gives clear failure messages. - Multiple assertions per test are fine when they verify the same behavior.
Exception Testing
def test_raises_on_invalid_input():
with pytest.raises(ValueError, match=r"must be positive"):
calculate(-1)
def test_exception_attributes():
with pytest.raises(ValidationError) as exc_info:
validate(bad_data)
assert exc_info.value.field == "email"
assert "invalid format" in str(exc_info.value)- Always use `match=` when the exception type is broad — validates the message.
- Access `.value` for exception attributes via
exc_info. - `pytest.raises` is a context manager. The code that raises must be inside the
with.
Approximate Comparisons
assert result == pytest.approx(3.14, abs=0.01)
assert results == pytest.approx([1.0, 2.0, 3.0], rel=1e-3)Warning Testing
def test_deprecation_warning():
with pytest.warns(DeprecationWarning, match="use new_func"):
old_func()Async Testing
With pytest-asyncio:
import pytest
@pytest.mark.asyncio
async def test_async_fetch():
result = await fetch_data("https://api.example.com")
assert result.status == 200
@pytest.fixture
async def async_client():
async with AsyncClient() as client:
yield client
@pytest.mark.asyncio
async def test_with_async_client(async_client):
response = await async_client.get("/health")
assert response.status_code == 200- `@pytest.mark.asyncio` on every async test (or configure
asyncio_mode = "auto"inpyproject.toml). - Async fixtures work with
yieldfor teardown — same pattern as sync fixtures.
conftest.py Patterns
Hierarchy
tests/
├── conftest.py # session/root fixtures
├── unit/
│ ├── conftest.py # unit test fixtures
│ └── test_models.py
└── integration/
├── conftest.py # integration fixtures (DB, services)
└── test_api.py- Fixtures cascade downward. A fixture in
tests/conftest.pyis available to all tests. A fixture in
tests/unit/conftest.py is available only to unit tests.
- Don't import from conftest. pytest discovers and injects conftest fixtures automatically.
- Split by concern. Root conftest for shared utilities (factories, settings). Subdirectory conftest for
environment-specific setup (database, external services).
Output Capture
def test_prints_greeting(capsys):
greet("Alice")
captured = capsys.readouterr()
assert "Hello, Alice" in captured.out
def test_logs_warning(caplog):
with caplog.at_level(logging.WARNING):
process_legacy_data()
assert "deprecated" in caplog.text
assert caplog.records[0].levelname == "WARNING"Configuration
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra -q --strict-markers"
markers = [
"slow: slow tests",
"integration: integration tests",
]
filterwarnings = [
"error", # treat all warnings as errors
"ignore::DeprecationWarning", # except deprecations from deps
]
asyncio_mode = "auto" # pytest-asyncio: auto-detect async tests- `--strict-markers` — fail on unregistered markers. Catches typos.
- `-ra` — show summary of all non-passing tests at the end.
- `filterwarnings = ["error"]` — catch hidden warnings early.
Plugin Ecosystem
- `pytest-asyncio` — Async test support with
@pytest.mark.asyncio - `pytest-mock` —
mockerfixture wrappingunittest.mock - `pytest-cov` — Coverage reporting (
--cov=src) - `pytest-xdist` — Parallel test execution (
-n auto) - `pytest-httpx` — Mock
httpxrequests in tests - `pytest-randomly` — Randomize test order to catch hidden dependencies
See ${CLAUDE_SKILL_DIR}/references/plugins.md for configuration patterns and usage details.
Application
When writing tests: apply all conventions silently — don't narrate each rule being followed. Match the project's existing test style. If an existing codebase contradicts a convention, follow the codebase and flag the divergence once.
When reviewing tests: cite the specific issue and show the fix inline. Don't lecture — state what's wrong and how to fix it.
Bad: "According to pytest best practices, you should use fixtures
instead of setUp methods..."
Good: "setUp/tearDown -> @pytest.fixture with yield"Integration
The python skill governs language choices; this skill governs pytest testing decisions. The coding skill governs workflow (discovery, planning, verification).
Test behavior, not implementation. When in doubt, mock less.
{
"sources": {
"pytest: How to use fixtures": "https://docs.pytest.org/en/stable/how-to/fixtures.html",
"pytest: How to parametrize": "https://docs.pytest.org/en/stable/how-to/parametrize.html",
"pytest: How to use monkeypatch": "https://docs.pytest.org/en/stable/how-to/monkeypatch.html",
"pytest: How to capture output": "https://docs.pytest.org/en/stable/how-to/capture-warnings.html",
"pytest: Configuration": "https://docs.pytest.org/en/stable/reference/reference.html",
"pytest: Fixtures reference": "https://docs.pytest.org/en/stable/reference/fixtures.html",
"pytest: Parametrize examples": "https://docs.pytest.org/en/stable/example/parametrize.html",
"pytest: Good practices": "https://docs.pytest.org/en/stable/explanation/goodpractices.html",
"pytest-asyncio: Documentation": "https://pytest-asyncio.readthedocs.io/en/latest/",
"pytest-mock: Documentation": "https://pytest-mock.readthedocs.io/en/latest/"
},
"lastFetched": "2026-02-28T20:10:27.758Z"
}
Fixture Patterns
Extended patterns and lifecycle details for pytest fixtures, distilled from official pytest documentation.
Fixture Lifecycle
Fixtures execute in dependency order. When test test_foo(db, user) runs:
1. pytest resolves the fixture dependency graph 2. Broadest-scope fixtures initialize first (session > package > module > class > function) 3. Within the same scope, fixtures initialize in dependency order 4. After the test completes, teardown runs in reverse order
Fixtures are cached within their scope. If two tests in the same module both request a module-scoped fixture, the fixture executes once and both tests receive the same instance. Parametrized fixtures are the exception — pytest caches only one instance at a time and may invoke a fixture more than once in a given scope.
Yield Fixtures (Recommended)
@pytest.fixture
def managed_resource():
# SETUP — runs before test
resource = acquire_resource()
yield resource
# TEARDOWN — runs after test, even on failure
resource.release()Teardown code after yield runs unconditionally — even when the test fails or raises an exception. This makes yield fixtures more reliable than try/finally in test bodies.
Teardown Order
Teardown runs in reverse fixture initialization order. For a test requesting (fix_a, fix_b), fix_b tears down first:
@pytest.fixture
def fix_a():
yield
print("teardown_a") # runs second
@pytest.fixture
def fix_b():
yield
print("teardown_b") # runs firstError Handling in Yield Fixtures
- If a yield fixture raises before
yield, its teardown code does not run, but all previously-initialized fixtures
still tear down normally.
- If teardown itself can raise, wrap it to avoid masking the original test failure:
@pytest.fixture
def db_session():
session = create_session()
yield session
try:
session.rollback()
session.close()
except Exception:
logging.warning("Session cleanup failed", exc_info=True)Safe Fixture Structure
Each fixture should perform one state-changing action with its corresponding teardown. Avoid monolithic fixtures that create multiple resources and try to clean them all up:
# BAD — if create_user raises, browser never closes
@pytest.fixture
def setup():
browser = launch_browser()
user = create_user()
yield browser, user
delete_user(user)
browser.quit()
# GOOD — independent fixtures, independent teardown
@pytest.fixture
def browser():
b = launch_browser()
yield b
b.quit()
@pytest.fixture
def user(admin_client):
u = admin_client.create_user()
yield u
admin_client.delete_user(u)If user raises during setup, browser still tears down correctly because each fixture manages its own lifecycle independently.
addfinalizer (Alternative to Yield)
request.addfinalizer registers teardown callbacks that run in LIFO order. Unlike yield, finalizers execute even if the fixture raises after registration — useful when setup has multiple steps that each need independent cleanup:
@pytest.fixture
def complex_resource(request):
db = start_database()
request.addfinalizer(db.shutdown) # runs even if next line raises
schema = db.create_schema()
request.addfinalizer(schema.drop) # registered second, runs first
return dbPrefer `yield` for straightforward setup/teardown. Use addfinalizer only when you need multiple independent cleanup steps where later steps might fail during setup.
Fixture Factories
Use factories when tests need multiple instances with different configurations:
@pytest.fixture
def make_order():
created = []
def _make_order(
*,
product: str = "Widget",
quantity: int = 1,
status: str = "pending",
) -> Order:
order = Order(product=product, quantity=quantity, status=status)
created.append(order)
return order
yield _make_order
# Cleanup all created orders
for order in created:
order.cancel()When to use factories vs direct fixtures:
- Direct fixture — test needs exactly one instance with standard config
- Factory — test needs multiple instances or custom configuration per test
Parametrized Fixtures
@pytest.fixture(params=["sqlite", "postgres"])
def db_backend(request):
if request.param == "sqlite":
db = create_sqlite()
else:
db = create_postgres()
yield db
db.teardown()Every test using db_backend runs twice — once per parameter value. This is powerful for testing the same behavior against multiple backends.
IDs for Parametrized Fixtures
@pytest.fixture(params=[
pytest.param("sqlite", id="sqlite"),
pytest.param("postgres", id="pg"),
pytest.param("mysql", id="mysql"),
])
def db_backend(request):
...IDs can also be a callable that receives the param value and returns a string (or None to fall back to auto-generated ID):
@pytest.fixture(params=[0, 1, 2], ids=lambda val: f"level-{val}")
def severity(request):
return request.paramMarks on Parametrized Fixtures
@pytest.fixture(params=[
pytest.param(0),
pytest.param(1),
pytest.param(2, marks=pytest.mark.skip),
])
def data_set(request):
return request.paramAutomatic Test Grouping
pytest minimizes active fixture instances. With parametrized fixtures, all tests run with the first parameter value, then finalizers execute before the next value is created. This keeps resource usage predictable and avoids interleaving.
Dynamic Scope
Set fixture scope at runtime based on configuration:
def determine_scope(fixture_name, config):
if config.getoption("--keep-containers", None):
return "session"
return "function"
@pytest.fixture(scope=determine_scope)
def docker_container():
yield spawn_container()The callable receives fixture_name (str) and config (pytest config object), executes once during fixture definition, and must return a valid scope string.
Request Object
The request fixture provides test metadata and introspection:
@pytest.fixture
def resource(request):
# request.param — parametrize value (if parametrized)
# request.node — the test item (access markers, name, etc.)
# request.node.name — test function name
# request.node.get_closest_marker("name") — access custom markers
# request.config — pytest config object
# request.fspath — test file path
# request.fixturename — name of this fixture
# request.module — test module object
# request.cls — test class (or None)
...Passing Data via Markers
@pytest.fixture
def resource(request):
marker = request.node.get_closest_marker("resource_config")
config = marker.args[0] if marker else {}
return create_resource(**config)
@pytest.mark.resource_config({"timeout": 30})
def test_slow_resource(resource):
...Scope Interactions
@pytest.fixture(scope="session")
def database():
"""Expensive — created once for entire test run."""
db = start_database()
yield db
db.shutdown()
@pytest.fixture(scope="function")
def clean_db(database):
"""Function-scoped — uses session-scoped database."""
database.reset()
yield databaseRules:
- A fixture can depend on same-scope or broader-scope fixtures
- A fixture CANNOT depend on narrower-scope fixtures (session cannot use function-scoped)
- pytest raises
ScopeMismatchif this rule is violated
conftest.py Fixture Discovery
tests/
├── conftest.py # fixtures available to ALL tests
├── api/
│ ├── conftest.py # fixtures for api tests only
│ └── test_endpoints.py
└── unit/
├── conftest.py # fixtures for unit tests only
└── test_models.py- pytest collects
conftest.pyfiles from the rootdir down to the test file's directory - Fixtures in closer conftest files override those in parent directories
- Never import from conftest — pytest handles injection automatically
- Session-scoped fixtures should live in the root
conftest.py
Autouse Fixtures
@pytest.fixture(autouse=True)
def reset_state():
"""Runs for every test in scope without explicit request."""
yield
global_state.reset()- Autouse fixtures execute for every test within their scope without being requested
- Useful for environment reset, transaction rollback, or test isolation
- Autouse fixtures in
conftest.pyapply to all tests in that directory and below - Autouse fixtures in a test class apply only to that class's tests
- Use sparingly — implicit dependencies make tests harder to understand
Fixture Composition
Build complex fixtures from simple ones:
@pytest.fixture
def auth_token() -> str:
return create_token(user_id="test-user", expires_in=3600)
@pytest.fixture
def authenticated_client(async_client, auth_token):
async_client.headers["Authorization"] = f"Bearer {auth_token}"
return async_clientEach fixture is independently testable and reusable. Prefer composition over monolithic fixtures that set up everything at once.
Monkeypatch Patterns
Extended patterns for pytest's monkeypatch fixture, distilled from official pytest documentation. Covers attribute patching, environment variables, dictionary mutation, and scoped patches.
API Overview
All modifications are automatically undone after the test (or fixture) completes.
- `monkeypatch.setattr(obj, name, value)` — Replace attribute on object or module
- `monkeypatch.delattr(obj, name)` — Remove attribute
- `monkeypatch.setitem(mapping, name, value)` — Set dictionary key
- `monkeypatch.delitem(mapping, name)` — Remove dictionary key
- `monkeypatch.setenv(name, value)` — Set environment variable
- `monkeypatch.delenv(name)` — Remove environment variable
- `monkeypatch.syspath_prepend(path)` — Prepend to
sys.path - `monkeypatch.chdir(path)` — Change working directory
- `monkeypatch.context()` — Context manager for scoped patches
The raising parameter (default True) controls whether KeyError/AttributeError is raised when the target doesn't exist. Pass raising=False to silently skip.
Patching Functions
from pathlib import Path
def test_getssh(monkeypatch):
monkeypatch.setattr(Path, "home", lambda: Path("/abc"))
assert getssh() == Path("/abc/.ssh")setattr must be called before the function under test is invoked.
String Target Syntax
setattr accepts a dotted string path instead of (obj, name):
def test_override(monkeypatch):
monkeypatch.setattr("myapp.config.DEBUG", True)
assert is_debug_mode() is TruePatching Returned Objects (Mock Classes)
When a function returns a complex object (e.g., HTTP response), create a mock class:
import requests
import app
class MockResponse:
status_code = 200
@staticmethod
def json():
return {"mock_key": "mock_value"}
def test_get_json(monkeypatch):
def mock_get(*args, **kwargs):
return MockResponse()
monkeypatch.setattr(requests, "get", mock_get)
result = app.get_json("https://example.com")
assert result["mock_key"] == "mock_value"Extracting to a Fixture
@pytest.fixture
def mock_response(monkeypatch):
"""Patch requests.get to return a mock response."""
def mock_get(*args, **kwargs):
return MockResponse()
monkeypatch.setattr(requests, "get", mock_get)
def test_api_call(mock_response):
result = app.get_json("https://example.com")
assert result["mock_key"] == "mock_value"Environment Variables
def test_env_set(monkeypatch):
monkeypatch.setenv("API_KEY", "test-key-123")
assert os.getenv("API_KEY") == "test-key-123"
def test_env_missing(monkeypatch):
monkeypatch.delenv("API_KEY", raising=False)
with pytest.raises(OSError):
get_api_key() # expects API_KEY to existPATH Modification
def test_custom_path(monkeypatch):
monkeypatch.setenv("PATH", "/custom/bin", prepend=os.pathsep)
# PATH is now "/custom/bin:<original PATH>"Env Fixtures
@pytest.fixture
def mock_env(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "sqlite:///test.db")
monkeypatch.setenv("SECRET_KEY", "test-secret")
def test_config(mock_env):
config = load_config()
assert config.database_url == "sqlite:///test.db"Dictionary Patching
# app.py
DEFAULT_CONFIG = {"user": "admin", "database": "prod_db"}
# test_app.py
def test_custom_config(monkeypatch):
monkeypatch.setitem(app.DEFAULT_CONFIG, "user", "test_user")
monkeypatch.setitem(app.DEFAULT_CONFIG, "database", "test_db")
result = app.create_connection_string()
assert "test_user" in result
def test_missing_key(monkeypatch):
monkeypatch.delitem(app.DEFAULT_CONFIG, "user", raising=False)
with pytest.raises(KeyError):
app.create_connection_string()Scoped Patches with context()
monkeypatch.context() limits patches to a specific block — useful when patching stdlib or third-party code that pytest itself uses:
import functools
def test_partial(monkeypatch):
with monkeypatch.context() as m:
m.setattr(functools, "partial", lambda *a, **kw: None)
assert functools.partial is not None
# functools.partial is restored here, even within the same testWhen to Use context()
- Patching stdlib functions (
os,sys,functools) that pytest depends on - Patching third-party libraries used by pytest plugins
- When you need different patches for different phases within a single test
Global Patches (autouse)
Prevent network access across all tests:
# conftest.py
@pytest.fixture(autouse=True)
def no_requests(monkeypatch):
"""Block all HTTP requests in tests."""
monkeypatch.delattr("requests.sessions.Session.request")Any test that tries to make an HTTP request will get AttributeError instead of a network call.
Stdlib Patching Warnings
Patching builtins (open, compile, etc.) can break pytest internals. If unavoidable:
- Use
monkeypatch.context()to limit the patch scope - Pass
--tb=native --assert=plain --capture=noto pytest to reduce pytest's own use of patched functions - Prefer
mocker.patch(pytest-mock) for complex patching — it integrates better with pytest's assertion rewriting
monkeypatch vs mocker.patch
| Aspect | monkeypatch | mocker.patch |
|---|---|---|
| Source | Built-in pytest fixture | pytest-mock plugin |
| Best for | Env vars, simple attrs, dicts | Complex mocking with assertions |
| Auto-restore | Yes | Yes |
| Call tracking | No | Yes (assert_called_once_with, etc.) |
| Spec enforcement | No | Yes (spec=Type) |
| Async support | No native async mock | AsyncMock |
Use monkeypatch for simple value replacement. Use mocker.patch when you need call tracking, return value configuration, or spec enforcement.
Parametrize Patterns
Extended examples and edge cases for @pytest.mark.parametrize, distilled from official pytest documentation.
Basic Parametrize
@pytest.mark.parametrize("input_str, expected_len", [
("hello", 5),
("", 0),
(" ", 2),
])
def test_string_length(input_str: str, expected_len: int):
assert len(input_str) == expected_lenNamed Parameters with pytest.param
@pytest.mark.parametrize("data, expected", [
pytest.param({"name": "Alice"}, True, id="valid-user"),
pytest.param({}, False, id="empty-dict"),
pytest.param({"name": ""}, False, id="blank-name"),
])
def test_is_valid_user(data: dict, expected: bool):
assert is_valid(data) is expectedIDs appear in test output: test_is_valid_user[valid-user], test_is_valid_user[empty-dict].
HIDDEN_PARAM (pytest 8.4+)
Hide a parameter set from the test name when it adds noise:
@pytest.mark.parametrize("db", [
pytest.param(create_db(), id=pytest.HIDDEN_PARAM),
])
def test_query(db):
...Can only be used on at most one parameter set per test (test names must remain unique).
Multi-Parameter Combinations (Stacking)
@pytest.mark.parametrize("method", ["GET", "POST", "PUT"])
@pytest.mark.parametrize("auth", [True, False])
def test_endpoint_access(method: str, auth: bool):
# Runs 6 times: all combinations of method x auth
...Parameters exhaust in the order of decorators — the last decorator's values vary fastest. For the above: (GET, True), (POST, True), (PUT, True), (GET, False), ...
Conditional Skip Within Parametrize
@pytest.mark.parametrize("backend", [
pytest.param("postgres", marks=pytest.mark.skipif(
not HAS_POSTGRES, reason="postgres not available"
)),
pytest.param("sqlite"),
])
def test_query_execution(backend: str):
...Expected Failures in Parametrize
@pytest.mark.parametrize("x, y, expected", [
(2, 3, 5),
(0, 0, 0),
pytest.param(-1, 1, 0, marks=pytest.mark.xfail(reason="known bug #123")),
])
def test_addition(x: int, y: int, expected: int):
assert add(x, y) == expectedIndirect Parametrize
Pass values to fixtures instead of directly to the test:
@pytest.fixture
def database(request):
db_type = request.param
db = create_database(db_type)
yield db
db.close()
@pytest.mark.parametrize("database", ["sqlite", "postgres"], indirect=True)
def test_insert_and_query(database):
database.insert({"key": "value"})
assert database.query("key") == "value"With indirect=True, the parameter value arrives as request.param in the fixture.
Partial Indirect
Only specific parameters routed to fixtures:
@pytest.mark.parametrize(
"database, query, expected",
[
("sqlite", "SELECT 1", [(1,)]),
("postgres", "SELECT 1", [(1,)]),
],
indirect=["database"], # only 'database' is a fixture
)
def test_raw_query(database, query: str, expected: list):
assert database.execute(query) == expectedDynamic Parametrize with pytest_generate_tests
For parametrization driven by CLI options, config, or runtime data:
# conftest.py
def pytest_addoption(parser):
parser.addoption(
"--backend",
action="append",
default=[],
help="database backends to test against",
)
def pytest_generate_tests(metafunc):
if "backend" in metafunc.fixturenames:
backends = metafunc.config.getoption("backend") or ["sqlite"]
metafunc.parametrize("backend", backends)pytest --backend=sqlite --backend=postgresKey rules:
metafunc.parametrize()has the same interface as@pytest.mark.parametrize- Cannot call
metafunc.parametrize()multiple times with overlapping parameter names - If the parameter list is empty, the test is skipped (controlled by
empty_parameter_set_markconfig option — default
is skip)
Module-Level Parametrize
Apply parametrize to all tests in a module via pytestmark:
import pytest
pytestmark = pytest.mark.parametrize("n, expected", [(1, 2), (3, 4)])
def test_increment(n: int, expected: int):
assert n + 1 == expected
def test_double_increment(n: int, expected: int):
assert n + 2 == expected + 1Class-Level Parametrize
@pytest.mark.parametrize("n, expected", [(1, 2), (3, 4)])
class TestArithmetic:
def test_increment(self, n: int, expected: int):
assert n + 1 == expected
def test_double_increment(self, n: int, expected: int):
assert n + 2 == expected + 1Every method in the class runs with each parameter set.
Complex Object Parameters
For readability with complex parameters, define data outside the decorator:
VALID_CONFIGS = [
pytest.param(
Config(host="localhost", port=8080, debug=True),
id="dev-config",
),
pytest.param(
Config(host="0.0.0.0", port=443, debug=False),
id="prod-config",
),
]
@pytest.mark.parametrize("config", VALID_CONFIGS)
def test_config_validation(config: Config):
assert config.validate() is TrueParameter Mutation Warning
Parameter values are passed as-is to tests — no copy is made. If a test mutates a list or dict parameter, subsequent tests in the same parametrize set see the mutation:
# BUG — second test sees mutated list
@pytest.mark.parametrize("items", [[1, 2, 3]])
def test_append(items):
items.append(4)
assert len(items) == 4
# FIX — copy in the test or use a fixture factory
@pytest.mark.parametrize("items", [[1, 2, 3]])
def test_append_safe(items):
local = items.copy()
local.append(4)
assert len(local) == 4Empty Parameter Sets
When a parametrize decorator receives an empty list (e.g., from dynamic generation), the behavior is controlled by the empty_parameter_set_mark config option:
[tool.pytest.ini_options]
empty_parameter_set_mark = "skip" # default: skip the test
# Other options: "xfail", "fail_at_collect"Plugin Ecosystem and Configuration
pytest's plugin ecosystem extends the framework with async support, mocking, parallelism, and coverage. Distilled from official plugin documentation.
pytest-asyncio
Enables async test functions and fixtures.
Configuration
[tool.pytest.ini_options]
asyncio_mode = "auto" # auto-detect async tests (recommended)- `auto` — Async tests and fixtures detected automatically — no decorator needed
- `strict` — Requires explicit
@pytest.mark.asyncioon every async test (default)
With auto mode, any async def test_* function is treated as an async test without needing @pytest.mark.asyncio.
Event Loop Scope
[tool.pytest.ini_options]
asyncio_default_fixture_loop_scope = "session"- `function` — Fresh event loop per test (more isolated, default)
- `session` — One event loop for the entire test run (faster, shared connections)
Async Fixtures
@pytest.fixture
async def async_client():
async with httpx.AsyncClient(base_url="http://test") as client:
yield client
@pytest.fixture(scope="session")
async def database():
db = await Database.connect("postgresql://test")
yield db
await db.disconnect()Async fixtures use yield for teardown, same as sync fixtures. Both sync and async fixtures can be mixed freely — a sync test can use async fixtures and vice versa when pytest-asyncio is configured.
Limitations
- Test classes subclassing
unittest.TestCaseare not supported — useunittest.IsolatedAsyncioTestCaseinstead or
plain pytest async tests
- When using
scope="session"on async fixtures, configureasyncio_default_fixture_loop_scope = "session"to avoid
loop mismatch errors
pytest-mock
Provides the mocker fixture — a thin wrapper around unittest.mock with automatic cleanup after each test.
Core API
def test_sends_email(mocker):
mock_send = mocker.patch("myapp.notifications.send_email")
notify_user("alice@example.com", "Hello")
mock_send.assert_called_once_with("alice@example.com", "Hello")Method Reference
- `mocker.patch("target")` — Replace target with
MagicMock - `mocker.patch.object(obj, "attr")` — Patch attribute on a specific object
- `mocker.patch.dict(dict_obj, values)` — Temporarily modify dict entries
- `mocker.spy(obj, "method")` — Wrap method — track calls while preserving behavior
- `mocker.stub(name="stub")` — Create a standalone stub (no spec)
- `mocker.MagicMock(spec=Type)` — Create spec-constrained mock
- `mocker.AsyncMock(spec=Type)` — Create async-compatible mock
- `mocker.patch("target", new_callable=mocker.AsyncMock)` — Patch with async mock
Spy Pattern
Spy wraps the real method — calls pass through to the original implementation, but calls are recorded for assertion:
def test_spy_on_method(mocker):
spy = mocker.spy(UserService, "create")
service = UserService()
result = service.create(name="Alice")
spy.assert_called_once_with(mocker.ANY, name="Alice")
assert result is not None # real return valueContext Manager Usage
For scoped mocking within a test:
def test_scoped_mock(mocker):
# Mock active for entire test
mock_fetch = mocker.patch("myapp.api.fetch")
# For narrower scope, use unittest.mock directly
from unittest.mock import patch
with patch("myapp.api.other_fetch") as mock_other:
result = do_something()
mock_other.assert_called_once()Where to Patch
Patch where the name is looked up, not where it's defined:
# myapp/services.py
from myapp.clients import http_client # name looked up in services module
# test
def test_service(mocker):
# CORRECT — patch where it's imported
mocker.patch("myapp.services.http_client")
# WRONG — patches the definition, not the import
# mocker.patch("myapp.clients.http_client")Improved Assertion Errors
pytest-mock enhances mock assertion error messages with introspection. When assert_called_once_with fails, the error shows the actual calls made, making debugging easier than raw unittest.mock.
pytest-xdist
Parallel test execution across multiple CPUs or remote machines.
Usage
# Auto-detect CPU count
pytest -n auto
# Specific worker count
pytest -n 4
# Distribute by file (each worker gets whole files)
pytest -n auto --dist loadfile
# Distribute by group (tests marked with same group run together)
pytest -n auto --dist loadgroupDistribution Modes
- `load` — Distribute tests to workers as they become free (default)
- `loadfile` — Group tests by file — each worker runs whole files
- `loadgroup` — Group by
@pytest.mark.xdist_group("name") - `loadscope` — Group by test module/class scope
- `no` — Disable distribution (useful for debugging)
Configuration
[tool.pytest.ini_options]
addopts = "-n auto" # always run in parallelIsolation Constraints
- Tests must be fully isolated — no shared mutable state, no execution order dependency
- Session-scoped fixtures run once per worker, not once globally
- Use
tmp_path(not hardcoded paths) to avoid file conflicts between workers - Database tests need per-worker isolation (e.g., unique database per worker)
Worker-Aware Fixtures
@pytest.fixture(scope="session")
def database(worker_id):
"""Create a unique database per xdist worker."""
if worker_id == "master":
db_name = "test_db" # not running under xdist
else:
db_name = f"test_db_{worker_id}"
db = create_database(db_name)
yield db
db.drop()worker_id is "master" when not running under xdist, or "gw0", "gw1", etc. when distributed.
pytest-cov
Coverage reporting integrated with pytest.
Usage
# Basic coverage
pytest --cov=src
# With reports
pytest --cov=src --cov-report=term-missing --cov-report=html
# Fail under threshold
pytest --cov=src --cov-fail-under=80Configuration
[tool.coverage.run]
source = ["src"]
branch = true
[tool.coverage.report]
show_missing = true
fail_under = 80
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"if __name__ == .__main__.:",
"@overload",
"raise NotImplementedError",
]Best Practices
- Run coverage in CI only — it slows down local development feedback loops
- Set `source` to include untested files in the report (files with zero imports)
- Use `branch = true` to measure branch coverage, not just line coverage
- Don't chase 100% — focus on critical paths. Defensive code and error handlers legitimately need
# pragma: no cover in some cases
Warning Testing Patterns
pytest captures warnings automatically and provides tools for testing warning behavior.
pytest.warns
def test_deprecation_warning():
with pytest.warns(DeprecationWarning, match="use new_func"):
old_func()
# Record and inspect warnings
with pytest.warns(RuntimeWarning) as record:
do_something()
assert len(record) == 1
assert "expected message" in str(record[0].message)recwarn Fixture
def test_warning_details(recwarn):
trigger_warning()
assert len(recwarn) == 1
w = recwarn.pop(UserWarning)
assert issubclass(w.category, UserWarning)
assert str(w.message) == "expected text"
assert w.filename
assert w.linenopytest.deprecated_call
def test_function_deprecated():
with pytest.deprecated_call():
legacy_function()Matches DeprecationWarning, PendingDeprecationWarning, and FutureWarning.
filterwarnings Configuration
[tool.pytest.ini_options]
filterwarnings = [
"error", # treat all warnings as errors
"ignore::UserWarning", # except UserWarning
'ignore:function ham\(\) is deprecated:DeprecationWarning',
]Precedence: last matching filter wins. Mark-level filters (@pytest.mark.filterwarnings) take precedence over config-level filters.
Decorator ordering caveat: decorators evaluate bottom-to-top, so earlier (top) @pytest.mark.filterwarnings decorators take precedence over later (bottom) ones — the reverse of the config file ordering:
@pytest.mark.filterwarnings("ignore:api v1") # higher priority
@pytest.mark.filterwarnings("error") # lower priority
def test_one():
...pytest-httpx
Mock httpx requests without touching the network:
def test_api_call(httpx_mock):
httpx_mock.add_response(
url="https://api.example.com/users",
json={"users": [{"name": "Alice"}]},
)
result = fetch_users()
assert result[0].name == "Alice"pytest-randomly
Randomizes test execution order to catch hidden dependencies:
# Run with random seed
pytest -p randomly
# Reproduce a specific order
pytest -p randomly --randomly-seed=12345Install and enable by default — hidden test dependencies cause intermittent CI failures that are expensive to debug.