| name | pytest-testing |
| description | pytest testing patterns for any Python project: test structure, fixtures, parametrize, conftest.py, monkeypatch, tmp_path, markers, unittest.mock (MagicMock, patch), pytest-cov coverage, and best practices. Stack-agnostic — referenced by every Python plugin in the marketplace.
Use this skill to:
- Organise tests with conftest.py shared fixtures and a clear test/src layout.
- Write parametrized tests to cover multiple inputs without duplication.
- Mock external dependencies (HTTP, DB, filesystem) with unittest.mock and monkeypatch.
- Measure and enforce code coverage with pytest-cov.
Do NOT use this skill for:
- Framework-specific test types (Django TestCase / WebTestCase, FastAPI TestClient, Flask test client — see framework plugin skills).
- Language idioms — see python-foundation:python-conventions.
|
pytest Testing Patterns (stack-agnostic)
pytest is the standard test runner for modern Python projects. These patterns apply to any Python 3.10+ codebase regardless of framework.
Project layout
Keep tests outside the source tree so installed packages are tested rather than the raw source directory:
src/
myapp/
__init__.py
service.py
repository.py
tests/
conftest.py
test_service.py
test_repository.py
integration/
conftest.py
test_db.py
pyproject.toml
Configure pytest to find the source in pyproject.toml:
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--strict-markers -q"
Install the package in editable mode before running tests so import myapp resolves correctly:
pip install -e .
pytest
conftest.py — shared fixtures
conftest.py files are loaded automatically by pytest. Place shared fixtures there instead of importing them into every test module.
from __future__ import annotations
import pytest
from myapp.database import Database, get_connection
from myapp.service import UserService
@pytest.fixture(scope="module")
def db() -> Database:
"""Module-scoped DB connection — created once per test module."""
conn = get_connection(url="sqlite:///:memory:")
conn.create_tables()
yield conn
conn.close()
@pytest.fixture
def user_service(db: Database) -> UserService:
"""Function-scoped service — fresh instance per test, shared DB."""
return UserService(db)
Fixture scopes (from broadest to narrowest):
| Scope | Created | Destroyed |
|---|
session | Once per pytest run | After all tests |
module | Once per test module | After last test in module |
class | Once per test class | After last test in class |
function (default) | Once per test function | After the test returns |
Use the narrowest scope that avoids expensive re-creation. Use session scope only for truly global state (e.g., a Docker container started via pytest-docker).
Basic test patterns
from __future__ import annotations
import pytest
from myapp.service import UserService
from myapp.exceptions import NotFoundError
def test_create_user_returns_new_id(user_service: UserService) -> None:
user_id = user_service.create(name="Alice", email="alice@example.com")
assert user_id > 0
def test_find_user_by_id(user_service: UserService) -> None:
user_id = user_service.create(name="Bob", email="bob@example.com")
user = user_service.find(user_id)
assert user.name == "Bob"
assert user.email == "bob@example.com"
def test_find_missing_user_raises(user_service: UserService) -> None:
with pytest.raises(NotFoundError):
user_service.find(99999)
def test_find_missing_user_message(user_service: UserService) -> None:
with pytest.raises(NotFoundError, match="User.*99999"):
user_service.find(99999)
Use plain assert — pytest rewrites assertions to produce detailed failure messages. Do not use assertEqual / assertTrue / assertRaises (those are unittest.TestCase methods).
Fixtures — yield, teardown, autouse
Use yield in a fixture to run teardown code after the test:
@pytest.fixture
def temp_user(user_service: UserService) -> int:
user_id = user_service.create(name="Temp", email="temp@example.com")
yield user_id
user_service.delete(user_id)
Fixtures can depend on other fixtures — pytest resolves the dependency graph automatically:
@pytest.fixture
def admin_user(user_service: UserService) -> User:
user_id = user_service.create(name="Admin", email="admin@example.com")
return user_service.promote_to_admin(user_id)
Use autouse=True for cross-cutting setup that every test in a module or package needs:
@pytest.fixture(autouse=True)
def rollback_after_test(db: Database) -> None:
"""Wrap every integration test in a transaction that rolls back."""
db.begin()
yield
db.rollback()
parametrize — data-driven tests
Drive many input/output combinations through a single test body:
import pytest
@pytest.mark.parametrize("amount,currency,expected_str", [
(100, "USD", "1.00 USD"),
(0, "EUR", "0.00 EUR"),
(999, "GBP", "9.99 GBP"),
])
def test_money_format(amount: int, currency: str, expected_str: str) -> None:
money = Money(amount, currency)
assert str(money) == expected_str
Use pytest.param to give test cases readable IDs and to mark individual cases:
@pytest.mark.parametrize("raw,expected", [
pytest.param("draft", Status.DRAFT, id="draft"),
pytest.param("active", Status.ACTIVE, id="active"),
pytest.param("archived", Status.ARCHIVED, id="archived"),
])
def test_status_parsing(raw: str, expected: Status) -> None:
assert Status(raw) == expected
@pytest.mark.parametrize("raw", [
pytest.param("unknown", id="unknown-value"),
pytest.param("", id="empty-string"),
])
def test_status_invalid(raw: str) -> None:
with pytest.raises(ValueError):
Status(raw)
Multiple independent parameters are passed as separate arguments — pytest generates all combinations:
@pytest.mark.parametrize("x", [0, 1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_add(x: int, y: int) -> None:
assert add(x, y) == x + y
monkeypatch — patching at test time
monkeypatch is a pytest fixture that temporarily modifies attributes, environment variables, or dictionary entries and automatically restores them after the test.
import requests
def get_user_count(api_url: str) -> int:
response = requests.get(f"{api_url}/users/count")
response.raise_for_status()
return response.json()["count"]
def test_get_user_count(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeResponse:
def raise_for_status(self) -> None: ...
def json(self) -> dict:
return {"count": 42}
monkeypatch.setattr(requests, "get", lambda url: FakeResponse())
count = get_user_count("https://api.example.com")
assert count == 42
Other common monkeypatch uses:
def test_reads_env_var(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
assert get_database_url() == "sqlite:///:memory:"
def test_missing_env_var(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("DATABASE_URL", raising=False)
with pytest.raises(EnvironmentError):
get_database_url()
def test_patches_dict(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setitem(config, "timeout", 5)
assert effective_timeout() == 5
Prefer monkeypatch over unittest.mock.patch for patching attributes and environment variables in pytest — it integrates with pytest's fixture lifecycle and does not require a context manager or decorator.
unittest.mock — mocking objects and calls
Use unittest.mock when you need to assert that a collaborator was called with specific arguments, or to make a mock raise an exception.
from unittest.mock import MagicMock, patch
from myapp.service import OrderService
def test_sends_confirmation_email() -> None:
mailer = MagicMock()
gateway = MagicMock()
gateway.charge.return_value = Receipt(id="rcpt_1", amount=500)
service = OrderService(payment_gateway=gateway, mailer=mailer)
service.checkout(order=make_order(total_cents=500))
gateway.charge.assert_called_once_with(500)
mailer.send.assert_called_once()
def test_checkout_raises_on_payment_failure() -> None:
gateway = MagicMock()
gateway.charge.side_effect = PaymentDeclinedError("insufficient funds")
service = OrderService(payment_gateway=gateway, mailer=MagicMock())
with pytest.raises(PaymentDeclinedError, match="insufficient funds"):
service.checkout(order=make_order(total_cents=500))
Use patch as a context manager when you need to replace a name in a module's namespace:
from unittest.mock import patch
def test_sends_welcome_email() -> None:
with patch("myapp.service.send_email") as mock_send:
create_user(name="Alice", email="alice@example.com")
mock_send.assert_called_once_with(
to="alice@example.com",
subject="Welcome",
)
Or as a decorator:
@patch("myapp.service.send_email")
def test_email_on_signup(mock_send: MagicMock) -> None:
create_user(name="Bob", email="bob@example.com")
assert mock_send.call_count == 1
Patch the name where it is used, not where it is defined. If myapp.service imports send_email from myapp.email, patch myapp.service.send_email, not myapp.email.send_email.
tmp_path — tests that write files
Use the built-in tmp_path fixture (a pathlib.Path pointing to a unique temporary directory) for tests that need to read or write files. pytest cleans it up after the session.
from pathlib import Path
import pytest
from myapp.export import write_csv
def test_write_csv_creates_file(tmp_path: Path) -> None:
output = tmp_path / "report.csv"
rows = [{"name": "Alice", "score": "95"}, {"name": "Bob", "score": "87"}]
write_csv(output, rows)
assert output.exists()
lines = output.read_text(encoding="utf-8").splitlines()
assert lines[0] == "name,score"
assert len(lines) == 3
def test_load_config_from_file(tmp_path: Path) -> None:
config_file = tmp_path / "config.toml"
config_file.write_text('[app]\nport = 8080\n', encoding="utf-8")
config = load_config(config_file)
assert config.port == 8080
tmp_path is function-scoped by default. Use tmp_path_factory (session fixture) if multiple fixtures or tests need to share the same temporary directory.
Coverage — measuring and enforcing
Run pytest with pytest-cov:
pytest --cov=src --cov-report=term-missing
Configure coverage in pyproject.toml:
[tool.coverage.run]
source = ["src"]
branch = true
[tool.coverage.report]
fail_under = 80
show_missing = true
omit = ["src/myapp/migrations/*", "src/myapp/__main__.py"]
With fail_under = 80, pytest --cov=src exits non-zero if coverage drops below 80% — useful as a CI gate.
Generate an HTML report for local inspection:
pytest --cov=src --cov-report=html
open htmlcov/index.html
Coverage is a floor, not a goal. A covered line is not necessarily an asserted behaviour — 100% coverage does not mean 0 bugs. Focus on asserting meaningful behaviours; the coverage number confirms you did not miss entire code paths.
Running tests
pytest
pytest -x
pytest -v
pytest -q
pytest -k "test_create"
pytest tests/test_service.py
pytest tests/integration/
pytest --tb=short
pytest --lf
pytest -n auto
Run the full quality pipeline before committing:
ruff format --check . && ruff check . && mypy src/ && pytest --cov=src