Testing with Pytest
Quick Start
[pytest]
testpaths = tests
python_files = test_*.py
addopts = -v --strict-markers --cov=src --cov-report=term-missing --cov-fail-under=80
markers =
slow: marks tests as slow
integration: marks tests as integration tests
asyncio_mode = auto
import pytest
from sqlalchemy.orm import Session
@pytest.fixture
def db_session(engine) -> Session:
"""Create database session with automatic rollback."""
connection = engine.connect()
transaction = connection.begin()
session = sessionmaker(bind=connection)()
yield session
session.close()
transaction.rollback()
connection.close()
Features
| Feature |
Description |
Reference |
| Fixtures |
Reusable test setup with dependency injection |
Fixtures Guide |
| Parametrization |
Test multiple inputs with @pytest.mark.parametrize |
Parametrize |
| Mocking |
unittest.mock integration for patching |
pytest-mock |
| Async Testing |
Native async/await support with pytest-asyncio |
pytest-asyncio |
| Markers |
Categorize and filter tests |
Markers |
| Coverage |
Code coverage with pytest-cov |
pytest-cov |
Common Patterns
Parametrized Validation Tests
import pytest
from src.validators import validate_email, ValidationError
class TestEmailValidation:
@pytest.mark.parametrize("email", [
"user@example.com",
"user.name@example.com",
"user+tag@example.co.uk",
])
def test_valid_emails(self, email: str):
assert validate_email(email) is True
@pytest.mark.parametrize("email,error", [
("", "Email is required"),
("invalid", "Invalid email format"),
("@example.com", "Invalid email format"),
])
def test_invalid_emails(self, email: str, error: str):
with pytest.raises(ValidationError, match=error):
validate_email(email)
Factory Fixtures
@pytest.fixture
def user_factory(db_session):
"""Factory for creating test users."""
def _create_user(email="test@example.com", name="Test User", **kwargs):
user = User(email=email, name=name, **kwargs)
db_session.add(user)
db_session.commit()
return user
return _create_user
@pytest.fixture
def test_user(user_factory):
return user_factory(email="testuser@example.com")
Mocking External Services
from unittest.mock import patch, MagicMock
class TestUserService:
def test_create_user_sends_email(self, user_service, mock_email_service):
user = user_service.create_user(email="new@example.com", name="New")
mock_email_service.send_email.assert_called_once_with(
to="new@example.com",
template="welcome",
context={"name": "New"},
)
@patch("src.services.user_service.datetime")
def test_last_login_updated(self, mock_datetime, user_service, test_user):
from datetime import datetime
mock_datetime.utcnow.return_value = datetime(2024, 1, 15, 10, 30)
user_service.authenticate(test_user.email, "password")
assert test_user.last_login == datetime(2024, 1, 15, 10, 30)
Async API Testing
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
class TestAPIEndpoints:
async def test_get_users(self, async_client: AsyncClient, test_user):
response = await async_client.get("/api/users")
assert response.status_code == 200
assert len(response.json()["users"]) >= 1
async def test_create_user(self, async_client: AsyncClient):
response = await async_client.post("/api/users", json={
"email": "new@example.com",
"name": "New User",
"password": "SecurePass123!",
})
assert response.status_code == 201
Best Practices
| Do |
Avoid |
| Use descriptive test names explaining the scenario |
Sharing state between tests |
| Create reusable fixtures for common setup |
Using sleep for timing issues |
| Use parametrize for testing multiple inputs |
Testing implementation details |
| Mock external dependencies |
Writing tests that depend on order |
| Group related tests in classes |
Using hardcoded file paths |
| Use factories for test data creation |
Leaving commented-out test code |
References