26 KiB
26 KiB
FastAPI Testing Framework
Comprehensive testing setup for FastAPI applications with pytest and async support.
Usage
# Run all tests
pytest
# Run with coverage
pytest --cov=app --cov-report=html
# Run specific test file
pytest tests/test_api.py
# Run with verbose output
pytest -v -s
Test Configuration
# pytest.ini
[tool:pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
--cov=app
--cov-report=term-missing
--cov-report=html:htmlcov
--asyncio-mode=auto
--strict-markers
--disable-warnings
markers =
unit: Unit tests
integration: Integration tests
e2e: End-to-end tests
slow: Slow running tests
auth: Authentication tests
api: API tests
asyncio_mode = auto
Test Dependencies
# requirements/test.txt
pytest>=7.0.0
pytest-asyncio>=0.21.0
pytest-cov>=4.0.0
httpx>=0.24.0
factory-boy>=3.2.0
faker>=18.0.0
respx>=0.20.0
pytest-mock>=3.10.0
Test Fixtures
# tests/conftest.py
import pytest
import asyncio
from typing import AsyncGenerator, Generator
from fastapi.testclient import TestClient
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from app.main import app
from app.db.database import get_db, Base
from app.models.user import User
from app.core.security import get_password_hash
from tests.factories import UserFactory
# Test database URL
TEST_DATABASE_URL = "sqlite+aiosqlite:///./test.db"
@pytest.fixture(scope="session")
def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:
"""Create event loop for the test session."""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture(scope="session")
async def test_engine():
"""Create test database engine."""
engine = create_async_engine(
TEST_DATABASE_URL,
echo=False,
future=True
)
# Create tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
# Drop tables and dispose engine
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest.fixture
async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
"""Create database session for testing."""
TestSessionLocal = sessionmaker(
test_engine,
class_=AsyncSession,
expire_on_commit=False
)
async with TestSessionLocal() as session:
yield session
@pytest.fixture
def override_get_db(db_session: AsyncSession) -> Generator:
"""Override database dependency."""
async def _override_get_db():
yield db_session
app.dependency_overrides[get_db] = _override_get_db
yield
app.dependency_overrides = {}
@pytest.fixture
def client(override_get_db) -> Generator[TestClient, None, None]:
"""Create test client."""
with TestClient(app) as test_client:
yield test_client
@pytest.fixture
async def async_client(override_get_db) -> AsyncGenerator[AsyncClient, None]:
"""Create async test client."""
async with AsyncClient(app=app, base_url="http://test") as ac:
yield ac
@pytest.fixture
async def test_user(db_session: AsyncSession) -> User:
"""Create test user."""
user_data = {
"username": "testuser",
"email": "test@example.com",
"hashed_password": get_password_hash("testpass123"),
"first_name": "Test",
"last_name": "User",
"is_active": True,
"is_superuser": False
}
user = User(**user_data)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest.fixture
async def superuser(db_session: AsyncSession) -> User:
"""Create superuser."""
user_data = {
"username": "admin",
"email": "admin@example.com",
"hashed_password": get_password_hash("adminpass123"),
"first_name": "Admin",
"last_name": "User",
"is_active": True,
"is_superuser": True
}
user = User(**user_data)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest.fixture
def user_token(test_user: User) -> str:
"""Create authentication token for test user."""
from app.core.security import create_access_token
return create_access_token(subject=test_user.id)
@pytest.fixture
def superuser_token(superuser: User) -> str:
"""Create authentication token for superuser."""
from app.core.security import create_access_token
return create_access_token(subject=superuser.id)
@pytest.fixture
def auth_headers(user_token: str) -> dict[str, str]:
"""Create authorization headers."""
return {"Authorization": f"Bearer {user_token}"}
@pytest.fixture
def superuser_headers(superuser_token: str) -> dict[str, str]:
"""Create superuser authorization headers."""
return {"Authorization": f"Bearer {superuser_token}"}
Test Factories
# tests/factories.py
import factory
from factory import Faker, SubFactory
from app.models.user import User
from app.models.post import Post
from app.core.security import get_password_hash
class UserFactory(factory.Factory):
"""Factory for User model."""
class Meta:
model = User
username = Faker('user_name')
email = Faker('email')
first_name = Faker('first_name')
last_name = Faker('last_name')
hashed_password = factory.LazyAttribute(lambda obj: get_password_hash('testpass123'))
is_active = True
is_superuser = False
bio = Faker('text', max_nb_chars=200)
class SuperUserFactory(UserFactory):
"""Factory for superuser."""
username = 'admin'
email = 'admin@example.com'
first_name = 'Admin'
last_name = 'User'
is_superuser = True
class PostFactory(factory.Factory):
"""Factory for Post model."""
class Meta:
model = Post
title = Faker('sentence', nb_words=4)
content = Faker('text', max_nb_chars=1000)
slug = Faker('slug')
is_published = True
author = SubFactory(UserFactory)
class InactiveUserFactory(UserFactory):
"""Factory for inactive user."""
is_active = False
API Testing
# tests/test_api/test_users.py
import pytest
from httpx import AsyncClient
from app.models.user import User
from tests.factories import UserFactory
class TestUserAPI:
"""Test User API endpoints."""
@pytest.mark.asyncio
async def test_create_user(
self,
async_client: AsyncClient,
superuser_headers: dict
):
"""Test POST /api/v1/users/."""
user_data = {
"username": "newuser",
"email": "new@example.com",
"password": "newpass123",
"first_name": "New",
"last_name": "User"
}
response = await async_client.post(
"/api/v1/users/",
json=user_data,
headers=superuser_headers
)
assert response.status_code == 201
data = response.json()
assert data["username"] == user_data["username"]
assert data["email"] == user_data["email"]
assert "password" not in data
assert "hashed_password" not in data
@pytest.mark.asyncio
async def test_get_users(
self,
async_client: AsyncClient,
test_user: User,
auth_headers: dict
):
"""Test GET /api/v1/users/."""
response = await async_client.get(
"/api/v1/users/",
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
assert "page" in data
assert "size" in data
assert len(data["items"]) >= 1
@pytest.mark.asyncio
async def test_get_user(
self,
async_client: AsyncClient,
test_user: User,
auth_headers: dict
):
"""Test GET /api/v1/users/{id}."""
response = await async_client.get(
f"/api/v1/users/{test_user.id}",
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["id"] == test_user.id
assert data["username"] == test_user.username
assert data["email"] == test_user.email
@pytest.mark.asyncio
async def test_update_user(
self,
async_client: AsyncClient,
test_user: User,
auth_headers: dict
):
"""Test PUT /api/v1/users/{id}."""
update_data = {
"first_name": "Updated",
"last_name": "Name",
"bio": "Updated bio"
}
response = await async_client.put(
f"/api/v1/users/{test_user.id}",
json=update_data,
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["first_name"] == update_data["first_name"]
assert data["last_name"] == update_data["last_name"]
assert data["bio"] == update_data["bio"]
@pytest.mark.asyncio
async def test_delete_user(
self,
async_client: AsyncClient,
test_user: User,
superuser_headers: dict
):
"""Test DELETE /api/v1/users/{id}."""
response = await async_client.delete(
f"/api/v1/users/{test_user.id}",
headers=superuser_headers
)
assert response.status_code == 204
# Verify user is deleted
get_response = await async_client.get(
f"/api/v1/users/{test_user.id}",
headers=superuser_headers
)
assert get_response.status_code == 404
@pytest.mark.asyncio
async def test_unauthorized_access(self, async_client: AsyncClient):
"""Test unauthorized access to protected endpoints."""
response = await async_client.get("/api/v1/users/")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_forbidden_access(
self,
async_client: AsyncClient,
auth_headers: dict
):
"""Test forbidden access to admin endpoints."""
user_data = {
"username": "unauthorized",
"email": "unauthorized@example.com",
"password": "pass123"
}
response = await async_client.post(
"/api/v1/users/",
json=user_data,
headers=auth_headers # Regular user, not superuser
)
assert response.status_code == 403
Authentication Testing
# tests/test_api/test_auth.py
import pytest
from httpx import AsyncClient
from app.models.user import User
from app.core.security import create_access_token, decode_token
class TestAuthAPI:
"""Test authentication API endpoints."""
@pytest.mark.asyncio
async def test_register(
self,
async_client: AsyncClient
):
"""Test user registration."""
user_data = {
"username": "newuser",
"email": "new@example.com",
"password": "newpass123",
"first_name": "New",
"last_name": "User"
}
response = await async_client.post(
"/api/v1/auth/register",
json=user_data
)
assert response.status_code == 201
data = response.json()
assert data["username"] == user_data["username"]
assert data["email"] == user_data["email"]
assert "password" not in data
@pytest.mark.asyncio
async def test_register_duplicate_email(
self,
async_client: AsyncClient,
test_user: User
):
"""Test registration with duplicate email."""
user_data = {
"username": "different",
"email": test_user.email, # Duplicate email
"password": "pass123",
"first_name": "Test",
"last_name": "User"
}
response = await async_client.post(
"/api/v1/auth/register",
json=user_data
)
assert response.status_code == 400
assert "Email already registered" in response.json()["detail"]
@pytest.mark.asyncio
async def test_login(
self,
async_client: AsyncClient,
test_user: User
):
"""Test user login."""
login_data = {
"username": test_user.username,
"password": "testpass123"
}
response = await async_client.post(
"/api/v1/auth/login",
data=login_data,
headers={"Content-Type": "application/x-www-form-urlencoded"}
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["token_type"] == "bearer"
assert "expires_in" in data
# Verify token is valid
payload = decode_token(data["access_token"])
assert payload is not None
assert payload["sub"] == str(test_user.id)
@pytest.mark.asyncio
async def test_login_invalid_credentials(
self,
async_client: AsyncClient,
test_user: User
):
"""Test login with invalid credentials."""
login_data = {
"username": test_user.username,
"password": "wrongpassword"
}
response = await async_client.post(
"/api/v1/auth/login",
data=login_data,
headers={"Content-Type": "application/x-www-form-urlencoded"}
)
assert response.status_code == 401
assert "Incorrect username or password" in response.json()["detail"]
@pytest.mark.asyncio
async def test_get_current_user(
self,
async_client: AsyncClient,
test_user: User,
auth_headers: dict
):
"""Test get current user endpoint."""
response = await async_client.get(
"/api/v1/auth/me",
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["id"] == test_user.id
assert data["username"] == test_user.username
assert data["email"] == test_user.email
@pytest.mark.asyncio
async def test_refresh_token(
self,
async_client: AsyncClient,
test_user: User
):
"""Test token refresh."""
from app.core.security import create_refresh_token
refresh_token = create_refresh_token(subject=test_user.id)
response = await async_client.post(
"/api/v1/auth/refresh",
json={"refresh_token": refresh_token}
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["token_type"] == "bearer"
@pytest.mark.asyncio
async def test_change_password(
self,
async_client: AsyncClient,
test_user: User,
auth_headers: dict
):
"""Test password change."""
password_data = {
"current_password": "testpass123",
"new_password": "newpass123"
}
response = await async_client.post(
"/api/v1/auth/change-password",
json=password_data,
headers=auth_headers
)
assert response.status_code == 200
assert "Password changed successfully" in response.json()["message"]
Model Testing
# tests/test_models/test_user.py
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.core.security import verify_password, get_password_hash
class TestUserModel:
"""Test User model."""
@pytest.mark.asyncio
async def test_create_user(self, db_session: AsyncSession):
"""Test user creation."""
user_data = {
"username": "testuser",
"email": "test@example.com",
"hashed_password": get_password_hash("password123"),
"first_name": "Test",
"last_name": "User"
}
user = User(**user_data)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
assert user.id is not None
assert user.username == "testuser"
assert user.email == "test@example.com"
assert user.full_name == "Test User"
assert user.is_active is True
assert user.is_superuser is False
assert user.created_at is not None
assert user.updated_at is not None
def test_password_verification(self):
"""Test password verification."""
password = "testpassword123"
hashed = get_password_hash(password)
user = User(
username="test",
email="test@example.com",
hashed_password=hashed,
first_name="Test",
last_name="User"
)
assert user.verify_password(password)
assert not user.verify_password("wrongpassword")
def test_user_dict_excludes_password(self):
"""Test that dict() method excludes password."""
user = User(
username="test",
email="test@example.com",
hashed_password="hashed_password",
first_name="Test",
last_name="User"
)
user_dict = user.dict()
assert "hashed_password" not in user_dict
assert "username" in user_dict
assert "email" in user_dict
def test_user_repr(self):
"""Test user string representation."""
user = User(
id=1,
username="test",
email="test@example.com",
hashed_password="hash",
first_name="Test",
last_name="User"
)
assert repr(user) == "<User(id=1)>"
Repository Testing
# tests/test_repositories/test_user.py
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.repositories.user import UserRepository
from app.core.security import get_password_hash
class TestUserRepository:
"""Test UserRepository."""
@pytest.mark.asyncio
async def test_create_user(self, db_session: AsyncSession):
"""Test user creation through repository."""
repo = UserRepository(User, db_session)
user_data = {
"username": "repouser",
"email": "repo@example.com",
"hashed_password": get_password_hash("password123"),
"first_name": "Repo",
"last_name": "User"
}
user = await repo.create(user_data)
assert user.id is not None
assert user.username == "repouser"
assert user.email == "repo@example.com"
@pytest.mark.asyncio
async def test_get_by_email(self, db_session: AsyncSession, test_user: User):
"""Test get user by email."""
repo = UserRepository(User, db_session)
found_user = await repo.get_by_email(test_user.email)
assert found_user is not None
assert found_user.id == test_user.id
assert found_user.email == test_user.email
@pytest.mark.asyncio
async def test_get_by_username(self, db_session: AsyncSession, test_user: User):
"""Test get user by username."""
repo = UserRepository(User, db_session)
found_user = await repo.get_by_username(test_user.username)
assert found_user is not None
assert found_user.id == test_user.id
assert found_user.username == test_user.username
@pytest.mark.asyncio
async def test_get_multi_with_pagination(
self,
db_session: AsyncSession,
test_user: User
):
"""Test get multiple users with pagination."""
repo = UserRepository(User, db_session)
# Create additional users
for i in range(5):
user_data = {
"username": f"user{i}",
"email": f"user{i}@example.com",
"hashed_password": get_password_hash("password123"),
"first_name": f"User{i}",
"last_name": "Test"
}
await repo.create(user_data)
# Test pagination
users = await repo.get_multi(skip=0, limit=3)
assert len(users) == 3
users_page_2 = await repo.get_multi(skip=3, limit=3)
assert len(users_page_2) >= 1 # At least test_user
@pytest.mark.asyncio
async def test_update_user(self, db_session: AsyncSession, test_user: User):
"""Test user update."""
repo = UserRepository(User, db_session)
updated_user = await repo.update(test_user.id, {
"first_name": "Updated",
"bio": "Updated bio"
})
assert updated_user is not None
assert updated_user.first_name == "Updated"
assert updated_user.bio == "Updated bio"
@pytest.mark.asyncio
async def test_delete_user(self, db_session: AsyncSession, test_user: User):
"""Test user deletion."""
repo = UserRepository(User, db_session)
result = await repo.delete(test_user.id)
assert result is True
# Verify user is deleted
deleted_user = await repo.get(test_user.id)
assert deleted_user is None
Performance Testing
# tests/test_performance.py
import pytest
import time
import asyncio
from httpx import AsyncClient
from app.models.user import User
from tests.factories import UserFactory
@pytest.mark.slow
class TestPerformance:
"""Test application performance."""
@pytest.mark.asyncio
async def test_concurrent_requests(
self,
async_client: AsyncClient,
auth_headers: dict
):
"""Test concurrent API requests."""
async def make_request():
response = await async_client.get(
"/api/v1/users/",
headers=auth_headers
)
return response.status_code
# Make 10 concurrent requests
start_time = time.time()
tasks = [make_request() for _ in range(10)]
results = await asyncio.gather(*tasks)
end_time = time.time()
# All requests should succeed
assert all(status == 200 for status in results)
# Should complete within reasonable time
assert (end_time - start_time) < 5.0
@pytest.mark.asyncio
async def test_large_dataset_pagination(
self,
async_client: AsyncClient,
db_session,
auth_headers: dict
):
"""Test pagination with large dataset."""
# Create 100 users
users = []
for i in range(100):
user = UserFactory.build()
users.append(user)
db_session.add_all(users)
await db_session.commit()
# Test pagination performance
start_time = time.time()
response = await async_client.get(
"/api/v1/users/?skip=0&limit=50",
headers=auth_headers
)
end_time = time.time()
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 50
# Should complete quickly
assert (end_time - start_time) < 1.0
Mocking External Services
# tests/test_external.py
import pytest
import respx
import httpx
from app.services.email import EmailService
class TestExternalServices:
"""Test external service integrations."""
@pytest.mark.asyncio
@respx.mock
async def test_email_service(
self,
async_client: AsyncClient
):
"""Test email service with mocked external API."""
# Mock email service API
respx.post("https://api.emailservice.com/send").mock(
return_value=httpx.Response(
200,
json={"message": "Email sent successfully"}
)
)
email_service = EmailService()
result = await email_service.send_email(
to="test@example.com",
subject="Test",
body="Test email"
)
assert result["success"] is True
Test Utilities
# tests/utils.py
from typing import Dict, Any
from httpx import Response
import json
def assert_response_status(response: Response, expected_status: int = 200):
"""Assert response status code."""
assert response.status_code == expected_status, f"Expected {expected_status}, got {response.status_code}. Response: {response.text}"
def assert_response_json(response: Response, expected_keys: list[str] = None):
"""Assert response is valid JSON with expected keys."""
assert response.headers.get("content-type") == "application/json"
data = response.json()
if expected_keys:
for key in expected_keys:
assert key in data, f"Missing key '{key}' in response"
return data
def create_auth_headers(token: str) -> Dict[str, str]:
"""Create authorization headers with token."""
return {"Authorization": f"Bearer {token}"}
async def create_test_users(db_session, count: int = 5) -> list:
"""Create multiple test users."""
from tests.factories import UserFactory
users = []
for i in range(count):
user = UserFactory.build()
users.append(user)
db_session.add_all(users)
await db_session.commit()
return users