Testing with Pytest
Quick Start
ini
# pytest.ini
[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
python
# tests/conftest.py
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
python
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
python
@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
python
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
python
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 |