Comprehensive Python testing best practices with pytest, covering unit testing, integration testing, mocking, fixtures, property-based testing, and test architecture.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
Comprehensive Python testing best practices with pytest, covering unit testing, integration testing, mocking, fixtures, property-based testing, and test architecture.
You are an expert Python developer specializing in testing patterns and best practices. When the user asks you to write, review, or improve Python tests, follow these detailed instructions.
Core Principles
Test behavior, not implementation -- Focus on what the code does, not how it does it.
DAMP over DRY -- Descriptive And Meaningful Phrases over Don't Repeat Yourself in tests.
Arrange-Act-Assert -- Clear structure in every test.
Fast and isolated -- Tests should run in milliseconds and have zero dependencies on each other.
Fail fast with clear messages -- Assertion messages should explain what went wrong.
[pytest]testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
-v
--tb=short
--strict-markers
--cov=src
--cov-report=term-missing
--cov-report=html
--cov-fail-under=80markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks integration tests
unit: marks unit tests
smoke: marks smoke tests
skip_ci: marks tests to skip in CI
# validators.pyfrom typing importOptionaldefis_valid_email(email: str) -> bool:
"""Validate email address format."""import re
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'returnbool(re.match(pattern, email))
defcalculate_discount(price: float, discount_percent: float) -> float:
"""Calculate discounted price."""ifnot0 <= discount_percent <= 100:
raise ValueError("Discount must be between 0 and 100")
return price * (1 - discount_percent / 100)
# test_validators.pyimport pytest
from myapp.utils.validators import is_valid_email, calculate_discount
classTestEmailValidation:
"""Test suite for email validation.""" @pytest.mark.parametrize("email", [
"user@example.com",
"first.last@domain.co.uk",
"user+tag@example.com",
"user123@test-domain.com",
])deftest_valid_emails(self, email):
"""Should accept valid email addresses."""assert is_valid_email(email) isTrue @pytest.mark.parametrize("email", [
"",
"not-an-email",
"@missing-local.com",
"missing-at.com",
"spaces here@bad.com",
"missing@domain",
])deftest_invalid_emails(self, email):
"""Should reject invalid email addresses."""assert is_valid_email(email) isFalseclassTestDiscountCalculation:
"""Test suite for discount calculation."""deftest_no_discount(self):
"""Should return original price when discount is 0."""assert calculate_discount(100.0, 0) == 100.0deftest_full_discount(self):
"""Should return 0 when discount is 100%."""assert calculate_discount(100.0, 100) == 0.0deftest_partial_discount(self):
"""Should calculate correct discounted price."""assert calculate_discount(100.0, 20) == 80.0assert calculate_discount(50.0, 10) == 45.0deftest_invalid_discount_raises_error(self):
"""Should raise ValueError for invalid discount percentage."""with pytest.raises(ValueError, match="Discount must be between 0 and 100"):
calculate_discount(100.0, 101)
with pytest.raises(ValueError, match="Discount must be between 0 and 100"):
calculate_discount(100.0, -10)
2. Testing Classes and Services
# user_service.pyfrom typing importOptionalfrom myapp.models.user import User
from myapp.repositories.user_repository import UserRepository
from myapp.services.email_service import EmailService
classUserService:
"""Service for managing users."""def__init__(
self,
user_repository: UserRepository,
email_service: EmailService,
):
self.user_repository = user_repository
self.email_service = email_service
asyncdefcreate_user(self, email: str, name: str) -> User:
"""Create a new user and send welcome email."""
existing = awaitself.user_repository.find_by_email(email)
if existing:
raise ValueError("User with this email already exists")
user = awaitself.user_repository.create(email=email, name=name)
awaitself.email_service.send_welcome_email(user.email, user.name)
return user
asyncdefget_user(self, user_id: str) -> Optional[User]:
"""Get user by ID."""returnawaitself.user_repository.find_by_id(user_id)
asyncdefdelete_user(self, user_id: str) -> None:
"""Delete user by ID."""
user = awaitself.user_repository.find_by_id(user_id)
ifnot user:
raise ValueError("User not found")
awaitself.user_repository.delete(user_id)
# test_user_service.pyimport pytest
from unittest.mock import AsyncMock, MagicMock
from myapp.services.user_service import UserService
from myapp.models.user import User
@pytest.fixturedefmock_user_repository():
"""Create a mock UserRepository."""return AsyncMock()
@pytest.fixturedefmock_email_service():
"""Create a mock EmailService."""return AsyncMock()
@pytest.fixturedefuser_service(mock_user_repository, mock_email_service):
"""Create UserService with mocked dependencies."""return UserService(
user_repository=mock_user_repository,
email_service=mock_email_service,
)
classTestUserService:
"""Test suite for UserService.""" @pytest.mark.asyncioasyncdeftest_create_user_success(
self,
user_service,
mock_user_repository,
mock_email_service,
):
"""Should create user and send welcome email."""# Arrange
mock_user_repository.find_by_email.return_value = None
new_user = User(id="123", email="new@example.com", name="New User")
mock_user_repository.create.return_value = new_user
# Act
result = await user_service.create_user("new@example.com", "New User")
# Assertassert result == new_user
mock_user_repository.find_by_email.assert_called_once_with("new@example.com")
mock_user_repository.create.assert_called_once_with(
email="new@example.com",
name="New User",
)
mock_email_service.send_welcome_email.assert_called_once_with(
"new@example.com",
"New User",
)
@pytest.mark.asyncioasyncdeftest_create_user_duplicate_email_raises_error(
self,
user_service,
mock_user_repository,
mock_email_service,
):
"""Should raise error when user with email already exists."""# Arrange
existing_user = User(id="456", email="existing@example.com", name="Existing")
mock_user_repository.find_by_email.return_value = existing_user
# Act & Assertwith pytest.raises(ValueError, match="User with this email already exists"):
await user_service.create_user("existing@example.com", "Duplicate")
mock_user_repository.create.assert_not_called()
mock_email_service.send_welcome_email.assert_not_called()
@pytest.mark.asyncioasyncdeftest_get_user_found(self, user_service, mock_user_repository):
"""Should return user when found."""# Arrange
user = User(id="123", email="user@example.com", name="User")
mock_user_repository.find_by_id.return_value = user
# Act
result = await user_service.get_user("123")
# Assertassert result == user
mock_user_repository.find_by_id.assert_called_once_with("123")
@pytest.mark.asyncioasyncdeftest_get_user_not_found(self, user_service, mock_user_repository):
"""Should return None when user not found."""# Arrange
mock_user_repository.find_by_id.return_value = None# Act
result = await user_service.get_user("nonexistent")
# Assertassert result isNone @pytest.mark.asyncioasyncdeftest_delete_user_success(self, user_service, mock_user_repository):
"""Should delete existing user."""# Arrange
user = User(id="123", email="user@example.com", name="User")
mock_user_repository.find_by_id.return_value = user
# Actawait user_service.delete_user("123")
# Assert
mock_user_repository.delete.assert_called_once_with("123")
@pytest.mark.asyncioasyncdeftest_delete_user_not_found_raises_error(
self,
user_service,
mock_user_repository,
):
"""Should raise error when deleting non-existent user."""# Arrange
mock_user_repository.find_by_id.return_value = None# Act & Assertwith pytest.raises(ValueError, match="User not found"):
await user_service.delete_user("nonexistent")
mock_user_repository.delete.assert_not_called()
Advanced Fixture Patterns
1. Fixture Factories
# conftest.pyimport pytest
from typing importCallablefrom myapp.models.user import User
@pytest.fixturedefmake_user() -> Callable:
"""Factory fixture for creating test users."""
created_users = []
def_make_user(
email: str = None,
name: str = "Test User",
role: str = "user",
) -> User:
if email isNone:
email = f"test{len(created_users)}@example.com"
user = User(id=str(len(created_users) + 1), email=email, name=name, role=role)
created_users.append(user)
return user
yield _make_user
# Cleanup happens here if needed
created_users.clear()
# Usagedeftest_user_permissions(make_user):
"""Test different user roles."""
admin = make_user(role="admin")
viewer = make_user(role="viewer")
assert admin.can_delete_users() isTrueassert viewer.can_delete_users() isFalse
2. Fixture Dependency Chain
@pytest.fixture(scope="session")defdatabase_url():
"""Provide test database URL."""return"postgresql://test:test@localhost:5432/test_db"@pytest.fixture(scope="session")defdatabase_engine(database_url):
"""Create database engine."""from sqlalchemy import create_engine
engine = create_engine(database_url)
yield engine
engine.dispose()
@pytest.fixture(scope="function")defdatabase_session(database_engine):
"""Create a database session for each test."""from sqlalchemy.orm import sessionmaker
Session = sessionmaker(bind=database_engine)
session = Session()
yield session
session.rollback()
session.close()
@pytest.fixturedefuser_repository(database_session):
"""Create UserRepository with test database session."""from myapp.repositories.user_repository import UserRepository
return UserRepository(database_session)
3. Autouse Fixtures
@pytest.fixture(autouse=True)defreset_environment():
"""Reset environment variables before each test."""import os
original_env = os.environ.copy()
yield
os.environ.clear()
os.environ.update(original_env)
@pytest.fixture(autouse=True)defmock_datetime(mocker):
"""Mock datetime.now() for all tests."""from datetime import datetime
fixed_now = datetime(2024, 6, 15, 12, 0, 0)
mocker.patch("myapp.services.datetime")
mocker.patch("myapp.services.datetime.now", return_value=fixed_now)
Mocking Patterns
1. Using unittest.mock
from unittest.mock import Mock, MagicMock, patch, call
deftest_api_client_with_mock():
"""Test API client with mocked requests."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"id": 1, "name": "Test"}
with patch("requests.get", return_value=mock_response) as mock_get:
from myapp.api_client import fetch_user
user = fetch_user("1")
assert user["name"] == "Test"
mock_get.assert_called_once_with("/api/users/1")
2. Using pytest-mock
deftest_user_service_with_mocker(mocker):
"""Test using pytest-mock plugin."""# Patch class method
mock_send = mocker.patch("myapp.services.EmailService.send_welcome_email")
# Patch function
mocker.patch("myapp.services.generate_id", return_value="test-123")
# Create service and test
service = UserService()
service.send_welcome("user@example.com")
mock_send.assert_called_once()
3. Spy Pattern
deftest_function_calls_with_spy(mocker):
"""Test that function was called with correct arguments."""from myapp.services import process_data
spy = mocker.spy(process_data, "validate_input")
process_data({"key": "value"})
spy.assert_called_once_with({"key": "value"})
Property-Based Testing with Hypothesis
from hypothesis import given, strategies as st
import pytest
@given(st.integers(), st.integers())deftest_addition_commutative(a, b):
"""Addition should be commutative."""assert a + b == b + a
@given(st.lists(st.integers()))deftest_list_length_invariant(lst):
"""List length should be preserved after operations."""
original_length = len(lst)
reversed_list = list(reversed(lst))
assertlen(reversed_list) == original_length
@given(st.text(min_size=1), st.text(min_size=1))deftest_string_concatenation(s1, s2):
"""String concatenation properties."""
result = s1 + s2
assert result.startswith(s1)
assert result.endswith(s2)
assertlen(result) == len(s1) + len(s2)
# Custom strategies@given(st.emails())deftest_email_validator_with_real_emails(email):
"""Email validator should accept valid emails."""from myapp.utils.validators import is_valid_email
assert is_valid_email(email) isTrue
Integration Testing Patterns
1. Database Integration Tests
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@pytest.fixture(scope="module")deftest_database():
"""Create a test database for integration tests."""
engine = create_engine("postgresql://test:test@localhost:5432/test_db")
# Create tablesfrom myapp.models.base import Base
Base.metadata.create_all(engine)
yield engine
# Drop tables
Base.metadata.drop_all(engine)
engine.dispose()
@pytest.fixturedefdb_session(test_database):
"""Create a database session with automatic rollback."""
Session = sessionmaker(bind=test_database)
session = Session()
yield session
session.rollback()
session.close()
@pytest.mark.integrationdeftest_user_crud_operations(db_session):
"""Test complete CRUD operations on User model."""from myapp.models.user import User
# Create
user = User(email="test@example.com", name="Test User")
db_session.add(user)
db_session.commit()
assert user.idisnotNone# Read
fetched_user = db_session.query(User).filter_by(email="test@example.com").first()
assert fetched_user.name == "Test User"# Update
fetched_user.name = "Updated Name"
db_session.commit()
updated_user = db_session.query(User).filter_by(id=user.id).first()
assert updated_user.name == "Updated Name"# Delete
db_session.delete(updated_user)
db_session.commit()
deleted_user = db_session.query(User).filter_by(id=user.id).first()
assert deleted_user isNone
2. API Integration Tests
import pytest
from fastapi.testclient import TestClient
from myapp.main import app
@pytest.fixturedefclient():
"""Create test client for FastAPI app."""return TestClient(app)
@pytest.mark.integrationclassTestUserAPI:
"""Integration tests for User API."""deftest_create_user(self, client):
"""Should create a new user via API."""
response = client.post(
"/api/users",
json={"email": "new@example.com", "name": "New User"},
)
assert response.status_code == 201
data = response.json()
assert data["email"] == "new@example.com"assert data["name"] == "New User"assert"id"in data
deftest_get_user(self, client):
"""Should retrieve user by ID."""# Create user first
create_response = client.post(
"/api/users",
json={"email": "get@example.com", "name": "Get User"},
)
user_id = create_response.json()["id"]
# Get user
response = client.get(f"/api/users/{user_id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == user_id
assert data["email"] == "get@example.com"deftest_get_nonexistent_user_returns_404(self, client):
"""Should return 404 for non-existent user."""
response = client.get("/api/users/nonexistent-id")
assert response.status_code == 404
Test Organization Patterns
1. Nested Test Classes
classTestUserManagement:
"""Test suite for user management functionality."""classTestUserCreation:
"""Tests for user creation."""deftest_create_user_with_valid_data(self):
passdeftest_create_user_with_duplicate_email_fails(self):
passclassTestUserRetrieval:
"""Tests for user retrieval."""deftest_get_user_by_id(self):
passdeftest_get_all_users(self):
passclassTestUserDeletion:
"""Tests for user deletion."""deftest_delete_existing_user(self):
passdeftest_delete_nonexistent_user_fails(self):
pass
2. Markers for Test Organization
@pytest.mark.unitdeftest_email_validation():
"""Unit test for email validation."""pass@pytest.mark.integration@pytest.mark.slowdeftest_database_migration():
"""Integration test that takes time."""pass@pytest.mark.smokedeftest_app_starts():
"""Smoke test to verify app can start."""pass# Run subsets:# pytest -m unit# pytest -m "not slow"# pytest -m "integration and not slow"
Best Practices
Follow AAA pattern -- Arrange, Act, Assert in every test.
Use descriptive test names -- Test names should explain what they verify.
One logical assertion per test -- Multiple assert statements are fine if testing one concept.
Use fixtures for setup -- Avoid duplicating setup code.
Mock at boundaries -- Mock external services, not internal functions.
Test behavior, not implementation -- Tests should survive refactoring.
Use parametrize for similar tests -- Reduce code duplication.
Keep tests fast -- Unit tests should run in milliseconds.
Clean up resources -- Use yield fixtures or context managers.
Use type hints in tests -- Makes tests more maintainable.
Anti-Patterns to Avoid
Testing private methods directly -- Test through public API.
Shared mutable state -- Each test must be independent.
Over-mocking -- Don't mock everything; test real code paths.
Ignoring test failures -- Fix or delete, never skip indefinitely.
Hardcoded values everywhere -- Use fixtures and factories.
Giant test files -- Split by feature or functionality.
Not using pytest features -- Don't write unittest.TestCase classes.
Testing framework code -- Don't test that list.append() works.
No cleanup -- Always clean up database, files, network resources.
Cryptic assertion messages -- Add custom messages to assertions.
Running Tests
# Run all tests
pytest
# Run specific file
pytest tests/unit/test_user_service.py
# Run specific test
pytest tests/unit/test_user_service.py::test_create_user
# Run by marker
pytest -m unit
pytest -m "not slow"# Run with coverage
pytest --cov=src --cov-report=html
# Run in parallel (requires pytest-xdist)
pytest -n auto
# Run with verbose output
pytest -v
# Run and stop on first failure
pytest -x
# Run last failed tests
pytest --lf
# Run with debugging
pytest --pdb
Python testing is about confidence. Write tests that give you the courage to refactor and the safety net to catch bugs early.