initial commit
This commit is contained in:
810
docs/user-guide/testing.md
Normal file
810
docs/user-guide/testing.md
Normal file
@ -0,0 +1,810 @@
|
||||
# Testing Guide
|
||||
|
||||
This guide covers comprehensive testing strategies for the FastAPI boilerplate, including unit tests, integration tests, and API testing.
|
||||
|
||||
## Test Setup
|
||||
|
||||
### Testing Dependencies
|
||||
|
||||
The boilerplate uses these testing libraries:
|
||||
|
||||
- **pytest** - Testing framework
|
||||
- **pytest-asyncio** - Async test support
|
||||
- **httpx** - Async HTTP client for API tests
|
||||
- **pytest-cov** - Coverage reporting
|
||||
- **faker** - Test data generation
|
||||
|
||||
### Test Configuration
|
||||
|
||||
#### pytest.ini
|
||||
|
||||
```ini
|
||||
[tool:pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts =
|
||||
-v
|
||||
--strict-markers
|
||||
--strict-config
|
||||
--cov=src
|
||||
--cov-report=term-missing
|
||||
--cov-report=html
|
||||
--cov-report=xml
|
||||
--cov-fail-under=80
|
||||
markers =
|
||||
unit: Unit tests
|
||||
integration: Integration tests
|
||||
api: API tests
|
||||
slow: Slow tests
|
||||
asyncio_mode = auto
|
||||
```
|
||||
|
||||
#### Test Database Setup
|
||||
|
||||
Create `tests/conftest.py`:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from typing import AsyncGenerator
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from faker import Faker
|
||||
|
||||
from src.app.core.config import settings
|
||||
from src.app.core.db.database import Base, async_get_db
|
||||
from src.app.main import app
|
||||
from src.app.models.user import User
|
||||
from src.app.models.post import Post
|
||||
from src.app.core.security import get_password_hash
|
||||
|
||||
# Test database configuration
|
||||
TEST_DATABASE_URL = "postgresql+asyncpg://test_user:test_pass@localhost:5432/test_db"
|
||||
|
||||
# Create test engine and session
|
||||
test_engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
TestSessionLocal = sessionmaker(
|
||||
test_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
fake = Faker()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def async_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Create a fresh database session for each test."""
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async with TestSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def async_client(async_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Create an async HTTP client for testing."""
|
||||
def get_test_db():
|
||||
return async_session
|
||||
|
||||
app.dependency_overrides[async_get_db] = get_test_db
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_user(async_session: AsyncSession) -> User:
|
||||
"""Create a test user."""
|
||||
user = User(
|
||||
name=fake.name(),
|
||||
username=fake.user_name(),
|
||||
email=fake.email(),
|
||||
hashed_password=get_password_hash("testpassword123"),
|
||||
is_superuser=False
|
||||
)
|
||||
async_session.add(user)
|
||||
await async_session.commit()
|
||||
await async_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_superuser(async_session: AsyncSession) -> User:
|
||||
"""Create a test superuser."""
|
||||
user = User(
|
||||
name="Super Admin",
|
||||
username="superadmin",
|
||||
email="admin@test.com",
|
||||
hashed_password=get_password_hash("superpassword123"),
|
||||
is_superuser=True
|
||||
)
|
||||
async_session.add(user)
|
||||
await async_session.commit()
|
||||
await async_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_post(async_session: AsyncSession, test_user: User) -> Post:
|
||||
"""Create a test post."""
|
||||
post = Post(
|
||||
title=fake.sentence(),
|
||||
content=fake.text(),
|
||||
created_by_user_id=test_user.id
|
||||
)
|
||||
async_session.add(post)
|
||||
await async_session.commit()
|
||||
await async_session.refresh(post)
|
||||
return post
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def auth_headers(async_client: AsyncClient, test_user: User) -> dict:
|
||||
"""Get authentication headers for a test user."""
|
||||
login_data = {
|
||||
"username": test_user.username,
|
||||
"password": "testpassword123"
|
||||
}
|
||||
|
||||
response = await async_client.post("/api/v1/auth/login", data=login_data)
|
||||
token = response.json()["access_token"]
|
||||
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def superuser_headers(async_client: AsyncClient, test_superuser: User) -> dict:
|
||||
"""Get authentication headers for a test superuser."""
|
||||
login_data = {
|
||||
"username": test_superuser.username,
|
||||
"password": "superpassword123"
|
||||
}
|
||||
|
||||
response = await async_client.post("/api/v1/auth/login", data=login_data)
|
||||
token = response.json()["access_token"]
|
||||
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
```
|
||||
|
||||
## Unit Tests
|
||||
|
||||
### Model Tests
|
||||
|
||||
```python
|
||||
# tests/test_models.py
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from src.app.models.user import User
|
||||
from src.app.models.post import Post
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUserModel:
|
||||
"""Test User model functionality."""
|
||||
|
||||
async def test_user_creation(self, async_session):
|
||||
"""Test creating a user."""
|
||||
user = User(
|
||||
name="Test User",
|
||||
username="testuser",
|
||||
email="test@example.com",
|
||||
hashed_password="hashed_password"
|
||||
)
|
||||
|
||||
async_session.add(user)
|
||||
await async_session.commit()
|
||||
await async_session.refresh(user)
|
||||
|
||||
assert user.id is not None
|
||||
assert user.name == "Test User"
|
||||
assert user.username == "testuser"
|
||||
assert user.email == "test@example.com"
|
||||
assert user.created_at is not None
|
||||
assert user.is_superuser is False
|
||||
assert user.is_deleted is False
|
||||
|
||||
async def test_user_relationships(self, async_session, test_user):
|
||||
"""Test user relationships."""
|
||||
post = Post(
|
||||
title="Test Post",
|
||||
content="Test content",
|
||||
created_by_user_id=test_user.id
|
||||
)
|
||||
|
||||
async_session.add(post)
|
||||
await async_session.commit()
|
||||
|
||||
# Test relationship
|
||||
await async_session.refresh(test_user)
|
||||
assert len(test_user.posts) == 1
|
||||
assert test_user.posts[0].title == "Test Post"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPostModel:
|
||||
"""Test Post model functionality."""
|
||||
|
||||
async def test_post_creation(self, async_session, test_user):
|
||||
"""Test creating a post."""
|
||||
post = Post(
|
||||
title="Test Post",
|
||||
content="This is test content",
|
||||
created_by_user_id=test_user.id
|
||||
)
|
||||
|
||||
async_session.add(post)
|
||||
await async_session.commit()
|
||||
await async_session.refresh(post)
|
||||
|
||||
assert post.id is not None
|
||||
assert post.title == "Test Post"
|
||||
assert post.content == "This is test content"
|
||||
assert post.created_by_user_id == test_user.id
|
||||
assert post.created_at is not None
|
||||
assert post.is_deleted is False
|
||||
```
|
||||
|
||||
### Schema Tests
|
||||
|
||||
```python
|
||||
# tests/test_schemas.py
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from src.app.schemas.user import UserCreate, UserRead, UserUpdate
|
||||
from src.app.schemas.post import PostCreate, PostRead, PostUpdate
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUserSchemas:
|
||||
"""Test User schema validation."""
|
||||
|
||||
def test_user_create_valid(self):
|
||||
"""Test valid user creation schema."""
|
||||
user_data = {
|
||||
"name": "John Doe",
|
||||
"username": "johndoe",
|
||||
"email": "john@example.com",
|
||||
"password": "SecurePass123!"
|
||||
}
|
||||
|
||||
user = UserCreate(**user_data)
|
||||
assert user.name == "John Doe"
|
||||
assert user.username == "johndoe"
|
||||
assert user.email == "john@example.com"
|
||||
assert user.password == "SecurePass123!"
|
||||
|
||||
def test_user_create_invalid_email(self):
|
||||
"""Test invalid email validation."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
UserCreate(
|
||||
name="John Doe",
|
||||
username="johndoe",
|
||||
email="invalid-email",
|
||||
password="SecurePass123!"
|
||||
)
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
assert any(error['type'] == 'value_error' for error in errors)
|
||||
|
||||
def test_user_create_short_password(self):
|
||||
"""Test password length validation."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
UserCreate(
|
||||
name="John Doe",
|
||||
username="johndoe",
|
||||
email="john@example.com",
|
||||
password="123"
|
||||
)
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
assert any(error['type'] == 'value_error' for error in errors)
|
||||
|
||||
def test_user_update_partial(self):
|
||||
"""Test partial user update."""
|
||||
update_data = {"name": "Jane Doe"}
|
||||
user_update = UserUpdate(**update_data)
|
||||
|
||||
assert user_update.name == "Jane Doe"
|
||||
assert user_update.username is None
|
||||
assert user_update.email is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPostSchemas:
|
||||
"""Test Post schema validation."""
|
||||
|
||||
def test_post_create_valid(self):
|
||||
"""Test valid post creation."""
|
||||
post_data = {
|
||||
"title": "Test Post",
|
||||
"content": "This is a test post content"
|
||||
}
|
||||
|
||||
post = PostCreate(**post_data)
|
||||
assert post.title == "Test Post"
|
||||
assert post.content == "This is a test post content"
|
||||
|
||||
def test_post_create_empty_title(self):
|
||||
"""Test empty title validation."""
|
||||
with pytest.raises(ValidationError):
|
||||
PostCreate(
|
||||
title="",
|
||||
content="This is a test post content"
|
||||
)
|
||||
|
||||
def test_post_create_long_title(self):
|
||||
"""Test title length validation."""
|
||||
with pytest.raises(ValidationError):
|
||||
PostCreate(
|
||||
title="x" * 101, # Exceeds max length
|
||||
content="This is a test post content"
|
||||
)
|
||||
```
|
||||
|
||||
### CRUD Tests
|
||||
|
||||
```python
|
||||
# tests/test_crud.py
|
||||
import pytest
|
||||
from src.app.crud.crud_users import crud_users
|
||||
from src.app.crud.crud_posts import crud_posts
|
||||
from src.app.schemas.user import UserCreate, UserUpdate
|
||||
from src.app.schemas.post import PostCreate, PostUpdate
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUserCRUD:
|
||||
"""Test User CRUD operations."""
|
||||
|
||||
async def test_create_user(self, async_session):
|
||||
"""Test creating a user."""
|
||||
user_data = UserCreate(
|
||||
name="CRUD User",
|
||||
username="cruduser",
|
||||
email="crud@example.com",
|
||||
password="password123"
|
||||
)
|
||||
|
||||
user = await crud_users.create(db=async_session, object=user_data)
|
||||
assert user["name"] == "CRUD User"
|
||||
assert user["username"] == "cruduser"
|
||||
assert user["email"] == "crud@example.com"
|
||||
assert "id" in user
|
||||
|
||||
async def test_get_user(self, async_session, test_user):
|
||||
"""Test getting a user."""
|
||||
retrieved_user = await crud_users.get(
|
||||
db=async_session,
|
||||
id=test_user.id
|
||||
)
|
||||
|
||||
assert retrieved_user is not None
|
||||
assert retrieved_user["id"] == test_user.id
|
||||
assert retrieved_user["name"] == test_user.name
|
||||
assert retrieved_user["username"] == test_user.username
|
||||
|
||||
async def test_get_user_by_email(self, async_session, test_user):
|
||||
"""Test getting a user by email."""
|
||||
retrieved_user = await crud_users.get(
|
||||
db=async_session,
|
||||
email=test_user.email
|
||||
)
|
||||
|
||||
assert retrieved_user is not None
|
||||
assert retrieved_user["email"] == test_user.email
|
||||
|
||||
async def test_update_user(self, async_session, test_user):
|
||||
"""Test updating a user."""
|
||||
update_data = UserUpdate(name="Updated Name")
|
||||
|
||||
updated_user = await crud_users.update(
|
||||
db=async_session,
|
||||
object=update_data,
|
||||
id=test_user.id
|
||||
)
|
||||
|
||||
assert updated_user["name"] == "Updated Name"
|
||||
assert updated_user["id"] == test_user.id
|
||||
|
||||
async def test_delete_user(self, async_session, test_user):
|
||||
"""Test soft deleting a user."""
|
||||
await crud_users.delete(db=async_session, id=test_user.id)
|
||||
|
||||
# User should be soft deleted
|
||||
deleted_user = await crud_users.get(
|
||||
db=async_session,
|
||||
id=test_user.id,
|
||||
is_deleted=True
|
||||
)
|
||||
|
||||
assert deleted_user is not None
|
||||
assert deleted_user["is_deleted"] is True
|
||||
|
||||
async def test_get_multi_users(self, async_session):
|
||||
"""Test getting multiple users."""
|
||||
# Create multiple users
|
||||
for i in range(5):
|
||||
user_data = UserCreate(
|
||||
name=f"User {i}",
|
||||
username=f"user{i}",
|
||||
email=f"user{i}@example.com",
|
||||
password="password123"
|
||||
)
|
||||
await crud_users.create(db=async_session, object=user_data)
|
||||
|
||||
# Get users with pagination
|
||||
result = await crud_users.get_multi(
|
||||
db=async_session,
|
||||
offset=0,
|
||||
limit=3
|
||||
)
|
||||
|
||||
assert len(result["data"]) == 3
|
||||
assert result["total_count"] == 5
|
||||
assert result["has_more"] is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPostCRUD:
|
||||
"""Test Post CRUD operations."""
|
||||
|
||||
async def test_create_post(self, async_session, test_user):
|
||||
"""Test creating a post."""
|
||||
post_data = PostCreate(
|
||||
title="Test Post",
|
||||
content="This is test content"
|
||||
)
|
||||
|
||||
post = await crud_posts.create(
|
||||
db=async_session,
|
||||
object=post_data,
|
||||
created_by_user_id=test_user.id
|
||||
)
|
||||
|
||||
assert post["title"] == "Test Post"
|
||||
assert post["content"] == "This is test content"
|
||||
assert post["created_by_user_id"] == test_user.id
|
||||
|
||||
async def test_get_posts_by_user(self, async_session, test_user):
|
||||
"""Test getting posts by user."""
|
||||
# Create multiple posts
|
||||
for i in range(3):
|
||||
post_data = PostCreate(
|
||||
title=f"Post {i}",
|
||||
content=f"Content {i}"
|
||||
)
|
||||
await crud_posts.create(
|
||||
db=async_session,
|
||||
object=post_data,
|
||||
created_by_user_id=test_user.id
|
||||
)
|
||||
|
||||
# Get posts by user
|
||||
result = await crud_posts.get_multi(
|
||||
db=async_session,
|
||||
created_by_user_id=test_user.id
|
||||
)
|
||||
|
||||
assert len(result["data"]) == 3
|
||||
assert result["total_count"] == 3
|
||||
```
|
||||
|
||||
## Integration Tests
|
||||
|
||||
### API Endpoint Tests
|
||||
|
||||
```python
|
||||
# tests/test_api_users.py
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestUserAPI:
|
||||
"""Test User API endpoints."""
|
||||
|
||||
async def test_create_user(self, async_client: AsyncClient):
|
||||
"""Test user creation endpoint."""
|
||||
user_data = {
|
||||
"name": "New User",
|
||||
"username": "newuser",
|
||||
"email": "new@example.com",
|
||||
"password": "SecurePass123!"
|
||||
}
|
||||
|
||||
response = await async_client.post("/api/v1/users", json=user_data)
|
||||
assert response.status_code == 201
|
||||
|
||||
data = response.json()
|
||||
assert data["name"] == "New User"
|
||||
assert data["username"] == "newuser"
|
||||
assert data["email"] == "new@example.com"
|
||||
assert "hashed_password" not in data
|
||||
assert "id" in data
|
||||
|
||||
async def test_create_user_duplicate_email(self, async_client: AsyncClient, test_user):
|
||||
"""Test creating user with duplicate email."""
|
||||
user_data = {
|
||||
"name": "Duplicate User",
|
||||
"username": "duplicateuser",
|
||||
"email": test_user.email, # Use existing email
|
||||
"password": "SecurePass123!"
|
||||
}
|
||||
|
||||
response = await async_client.post("/api/v1/users", json=user_data)
|
||||
assert response.status_code == 409 # Conflict
|
||||
|
||||
async def test_get_users(self, async_client: AsyncClient):
|
||||
"""Test getting users list."""
|
||||
response = await async_client.get("/api/v1/users")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert "data" in data
|
||||
assert "total_count" in data
|
||||
assert "has_more" in data
|
||||
assert isinstance(data["data"], list)
|
||||
|
||||
async def test_get_user_by_id(self, async_client: AsyncClient, test_user):
|
||||
"""Test getting specific user."""
|
||||
response = await async_client.get(f"/api/v1/users/{test_user.id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["id"] == test_user.id
|
||||
assert data["name"] == test_user.name
|
||||
assert data["username"] == test_user.username
|
||||
|
||||
async def test_get_user_not_found(self, async_client: AsyncClient):
|
||||
"""Test getting non-existent user."""
|
||||
response = await async_client.get("/api/v1/users/99999")
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_update_user_authorized(self, async_client: AsyncClient, test_user, auth_headers):
|
||||
"""Test updating user with proper authorization."""
|
||||
update_data = {"name": "Updated Name"}
|
||||
|
||||
response = await async_client.patch(
|
||||
f"/api/v1/users/{test_user.id}",
|
||||
json=update_data,
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["name"] == "Updated Name"
|
||||
assert data["id"] == test_user.id
|
||||
|
||||
async def test_update_user_unauthorized(self, async_client: AsyncClient, test_user):
|
||||
"""Test updating user without authorization."""
|
||||
update_data = {"name": "Updated Name"}
|
||||
|
||||
response = await async_client.patch(
|
||||
f"/api/v1/users/{test_user.id}",
|
||||
json=update_data
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_delete_user_superuser(self, async_client: AsyncClient, test_user, superuser_headers):
|
||||
"""Test deleting user as superuser."""
|
||||
response = await async_client.delete(
|
||||
f"/api/v1/users/{test_user.id}",
|
||||
headers=superuser_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_delete_user_forbidden(self, async_client: AsyncClient, test_user, auth_headers):
|
||||
"""Test deleting user without superuser privileges."""
|
||||
response = await async_client.delete(
|
||||
f"/api/v1/users/{test_user.id}",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestAuthAPI:
|
||||
"""Test Authentication API endpoints."""
|
||||
|
||||
async def test_login_success(self, async_client: AsyncClient, test_user):
|
||||
"""Test successful login."""
|
||||
login_data = {
|
||||
"username": test_user.username,
|
||||
"password": "testpassword123"
|
||||
}
|
||||
|
||||
response = await async_client.post("/api/v1/auth/login", data=login_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
async def test_login_invalid_credentials(self, async_client: AsyncClient, test_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)
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_get_current_user(self, async_client: AsyncClient, test_user, auth_headers):
|
||||
"""Test getting current user information."""
|
||||
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
|
||||
|
||||
async def test_refresh_token(self, async_client: AsyncClient, test_user):
|
||||
"""Test token refresh."""
|
||||
# First login to get refresh token
|
||||
login_data = {
|
||||
"username": test_user.username,
|
||||
"password": "testpassword123"
|
||||
}
|
||||
|
||||
login_response = await async_client.post("/api/v1/auth/login", data=login_data)
|
||||
refresh_token = login_response.json()["refresh_token"]
|
||||
|
||||
# Use refresh token to get new access token
|
||||
refresh_response = await async_client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
headers={"Authorization": f"Bearer {refresh_token}"}
|
||||
)
|
||||
|
||||
assert refresh_response.status_code == 200
|
||||
data = refresh_response.json()
|
||||
assert "access_token" in data
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Basic Test Commands
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
uv run pytest
|
||||
|
||||
# Run specific test categories
|
||||
uv run pytest -m unit
|
||||
uv run pytest -m integration
|
||||
uv run pytest -m api
|
||||
|
||||
# Run tests with coverage
|
||||
uv run pytest --cov=src --cov-report=html
|
||||
|
||||
# Run tests in parallel
|
||||
uv run pytest -n auto
|
||||
|
||||
# Run specific test file
|
||||
uv run pytest tests/test_api_users.py
|
||||
|
||||
# Run with verbose output
|
||||
uv run pytest -v
|
||||
|
||||
# Run tests matching pattern
|
||||
uv run pytest -k "test_user"
|
||||
|
||||
# Run tests and stop on first failure
|
||||
uv run pytest -x
|
||||
|
||||
# Run slow tests
|
||||
uv run pytest -m slow
|
||||
```
|
||||
|
||||
### Test Environment Setup
|
||||
|
||||
```bash
|
||||
# Set up test database
|
||||
createdb test_db
|
||||
|
||||
# Run tests with specific environment
|
||||
ENVIRONMENT=testing uv run pytest
|
||||
|
||||
# Run tests with debug output
|
||||
uv run pytest -s --log-cli-level=DEBUG
|
||||
```
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
### Test Organization
|
||||
|
||||
- **Separate concerns**: Unit tests for business logic, integration tests for API endpoints
|
||||
- **Use fixtures**: Create reusable test data and setup
|
||||
- **Test isolation**: Each test should be independent
|
||||
- **Clear naming**: Test names should describe what they're testing
|
||||
|
||||
### Test Data
|
||||
|
||||
- **Use factories**: Create test data programmatically
|
||||
- **Avoid hardcoded values**: Use variables and constants
|
||||
- **Clean up**: Ensure tests don't leave data behind
|
||||
- **Realistic data**: Use faker or similar libraries for realistic test data
|
||||
|
||||
### Assertions
|
||||
|
||||
- **Specific assertions**: Test specific behaviors, not just "it works"
|
||||
- **Multiple assertions**: Test all relevant aspects of the response
|
||||
- **Error cases**: Test error conditions and edge cases
|
||||
- **Performance**: Include performance tests for critical paths
|
||||
|
||||
### Mocking
|
||||
|
||||
```python
|
||||
# Example of mocking external dependencies
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_external_api_call():
|
||||
"""Test function that calls external API."""
|
||||
with patch('src.app.services.external_api.make_request') as mock_request:
|
||||
mock_request.return_value = {"status": "success"}
|
||||
|
||||
result = await some_function_that_calls_external_api()
|
||||
|
||||
assert result["status"] == "success"
|
||||
mock_request.assert_called_once()
|
||||
```
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_USER: test_user
|
||||
POSTGRES_PASSWORD: test_pass
|
||||
POSTGRES_DB: test_db
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.11
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install uv
|
||||
uv sync
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest --cov=src --cov-report=xml
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
```
|
||||
|
||||
This testing guide provides comprehensive coverage of testing strategies for the FastAPI boilerplate, ensuring reliable and maintainable code.
|
||||
Reference in New Issue
Block a user