Overview
SnackBase uses pytest as its testing framework with comprehensive support for async operations and database testing.Test Stack
| Component | Purpose |
|---|---|
| pytest | Test framework and runner |
| pytest-asyncio | Async test support |
| httpx | Async HTTP client for testing |
| ASGITransport | Test FastAPI without network |
| pytest-cov | Code coverage reporting |
Test Coverage Goals
| Area | Target Coverage |
|---|---|
| Core Logic (domain/) | 90%+ |
| Repositories (infrastructure/persistence/) | 85%+ |
| API Routes (infrastructure/api/routes/) | 80%+ |
| Services (infrastructure/services/) | 85%+ |
| Hooks (infrastructure/hooks/) | 75%+ |
| Overall | 85%+ |
Testing Philosophy
Test Pyramid
/\
/ \
/ E2E \ ← Few (manual/expensive)
/--------\
/Integration \ ← More (API/database)
/--------------\
/ Unit Tests \ ← Most (fast/isolated)
/------------------\
Testing Principles
| Principle | Description |
|---|---|
| Fast | Tests should run quickly (unit tests < 1 second each) |
| Isolated | Tests shouldn’t depend on each other |
| Deterministic | Same input should always produce same output |
| Readable | Test names should describe what they test |
| Maintainable | Tests should be easy to update when code changes |
Test Structure
Directory Layout
tests/
├── unit/ # Unit tests (isolated)
│ ├── test_config.py # Configuration tests
│ ├── test_id_generator.py # ID generation tests
│ ├── test_rules_lexer.py # Rule lexer tests
│ ├── test_rules_parser.py # Rule parser tests
│ └── test_password_hasher.py # Password hasher tests
│
├── integration/ # Integration tests (API + DB)
│ ├── test_auth_endpoints.py # Authentication API tests
│ ├── test_collections.py # Collection CRUD tests
│ ├── test_records.py # Record CRUD tests
│ ├── test_permissions.py # Permission tests
│ └── test_hooks.py # Hook integration tests
│
├── security/ # Security-specific tests
│ ├── test_authentication.py # Auth security tests
│ ├── test_authorization.py # Permission security tests
│ └── test_injection.py # SQL injection tests
│
├── conftest.py # Shared fixtures and configuration
└── pytest.ini # Pytest configuration
Test Naming Convention
Use descriptive test names that explain what is being tested:# BAD: Vague
def test_create():
pass
def test_update():
pass
# GOOD: Descriptive
def test_create_post_returns_201_with_valid_data():
pass
def test_update_post_fails_with_invalid_id():
pass
def test_update_post_requires_authentication():
pass
Running Tests
Basic Commands
# Run all tests
uv run pytest
# Run unit tests only
uv run pytest tests/unit/
# Run integration tests only
uv run pytest tests/integration/
# Run specific test file
uv run pytest tests/unit/test_id_generator.py
# Run specific test
uv run pytest tests/unit/test_id_generator.py::test_generate_id_format
# Run with verbose output
uv run pytest -v
# Run with coverage
uv run pytest --cov=snackbase --cov-report=html
Coverage Report
# Generate HTML coverage report
uv run pytest --cov=snackbase --cov-report=html
# Open report
open htmlcov/index.html
Test Discovery
Pytest automatically discovers tests:Tests are discovered in files matching:
- test_*.py
- *_test.py
Test functions must:
- Start with "test_"
- Be in a discovered file
Test classes must:
- Start with "Test"
- Have methods starting with "test_"
Writing Unit Tests
What to Unit Test
Unit tests should cover:- Business logic (domain layer)
- Pure functions (no side effects)
- Data transformations
- Validation logic
Example: Testing ID Generator
# tests/unit/test_id_generator.py
import pytest
from src.snackbase.core.id_generator import generate_id
class TestIDGenerator:
"""Test ID generation functionality."""
def test_generate_id_format(self):
"""Generated ID should match XX#### format."""
id = generate_id("test")
assert isinstance(id, str)
assert len(id) == 9 # XX + ####
assert id[:2].isalpha()
assert id[2:].isdigit()
assert id[2:].isnumeric()
def test_generate_id_prefix(self):
"""Generated ID should use correct prefix."""
id = generate_id("user")
assert id.startswith("user_")
assert len(id) == 14 # user_ + XX####
def test_generate_ids_are_unique(self):
"""Each generated ID should be unique."""
ids = [generate_id("test") for _ in range(100)]
assert len(set(ids)) == 100 # All unique
def test_generate_id_deterministic_prefix(self):
"""Prefix should be consistent."""
id1 = generate_id("test")
id2 = generate_id("test")
assert id1.startswith("test_")
assert id2.startswith("test_")
Example: Testing Rule Parser
# tests/unit/test_rules_parser.py
import pytest
from src.snackbase.core.rules.parser import Parser
from src.snackbase.core.rules.ast import *
class TestRuleParser:
"""Test rule parsing functionality."""
def test_parse_simple_comparison(self):
"""Parser should handle simple comparisons."""
parser = Parser()
ast = parser.parse("user.id == 'user_123'")
assert isinstance(ast, BinaryOpNode)
assert ast.operator == "=="
assert isinstance(ast.left, FieldAccessNode)
assert ast.left.field == "user.id"
def test_parse_logical_and(self):
"""Parser should handle AND operations."""
parser = Parser()
ast = parser.parse("@has_role('admin') and @owns_record()")
assert isinstance(ast, BinaryOpNode)
assert ast.operator == "and"
assert isinstance(ast.left, FunctionCallNode)
def test_parse_grouping(self):
"""Parser should handle parentheses grouping."""
parser = Parser()
ast = parser.parse("(@has_role('a') or @has_role('b')) and status == 'draft'")
assert isinstance(ast, BinaryOpNode)
assert ast.operator == "and"
assert isinstance(ast.left, BinaryOpNode) # (a or b)
Writing Integration Tests
What to Integration Test
Integration tests should cover:- API endpoints (request/response)
- Database operations (CRUD)
- Authentication flows
- Permission enforcement
- Hook execution
Example: Testing POST Endpoint
# tests/integration/test_posts.py
import pytest
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import AsyncSession
from src.snackbase.infrastructure.api.app import app
from src.snackbase.infrastructure.persistence.models.post import Post
from src.snackbase.core.id_generator import generate_id
@pytest.mark.asyncio
class TestPostsAPI:
"""Test posts API endpoints."""
async def test_create_post_success(
self,
client: AsyncClient,
superadmin_token: str
):
"""Creating a post with valid data should return 201."""
response = await client.post(
"/api/v1/posts",
headers={"Authorization": f"Bearer {superadmin_token}"},
json={
"title": "Test Post",
"content": "This is test content",
"status": "draft"
}
)
assert response.status_code == 201
data = response.json()
assert data["title"] == "Test Post"
assert data["content"] == "This is test content"
assert data["status"] == "draft"
assert "id" in data
assert "created_at" in data
async def test_create_post_requires_auth(
self,
client: AsyncClient
):
"""Creating a post without auth should return 401."""
response = await client.post(
"/api/v1/posts",
json={"title": "Test Post"}
)
assert response.status_code == 401
async def test_create_post_validates_required_fields(
self,
client: AsyncClient,
superadmin_token: str
):
"""Creating a post without required fields should return 422."""
response = await client.post(
"/api/v1/posts",
headers={"Authorization": f"Bearer {superadmin_token}"},
json={} # Missing required fields
)
assert response.status_code == 422
data = response.json()
assert "detail" in data
Example: Testing Permissions
# tests/integration/test_permissions.py
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
class TestPermissions:
"""Test permission enforcement."""
async def test_viewer_cannot_delete_posts(
self,
client: AsyncClient,
viewer_token: str,
test_post: dict
):
"""Viewer role should not be able to delete posts."""
response = await client.delete(
f"/api/v1/posts/{test_post['id']}",
headers={"Authorization": f"Bearer {viewer_token}"}
)
assert response.status_code == 403
async def test_editor_can_update_own_drafts(
self,
client: AsyncClient,
editor_token: str,
test_post: dict
):
"""Editor should be able to update their own draft posts."""
response = await client.put(
f"/api/v1/posts/{test_post['id']}",
headers={"Authorization": f"Bearer {editor_token}"},
json={"title": "Updated Title"}
)
assert response.status_code == 200
assert response.json()["title"] == "Updated Title"
Test Fixtures
Fixtures provide reusable test setup.Available Fixtures
# tests/conftest.py
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from httpx import AsyncClient, ASGITransport
from src.snackbase.infrastructure.api.app import app
from src.snackbase.infrastructure.persistence.database import get_db
from src.snackbase.core.config import settings
# Test database (in-memory SQLite)
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest.fixture(scope="session")
def engine():
"""Create test database engine."""
engine = create_async_engine(TEST_DATABASE_URL)
yield engine
engine.dispose()
@pytest.fixture
async def db_session(engine):
"""Create test database session."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async_session = sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
async with async_session() as session:
yield session
await session.rollback()
@pytest.fixture
async def client(db_session):
"""Create test HTTP client."""
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test"
) as ac:
yield ac
app.dependency_overrides.clear()
@pytest.fixture
async def superadmin_token(client: AsyncClient):
"""Create and return superadmin auth token."""
response = await client.post(
"/api/v1/auth/login",
json={
"account": "system",
"email": "[email protected]",
"password": "SuperAdmin123!"
}
)
return response.json()["access_token"]
Using Fixtures
@pytest.mark.asyncio
async def test_with_fixtures(
client: AsyncClient, # HTTP client fixture
db_session: AsyncSession, # Database session fixture
superadmin_token: str # Auth token fixture
):
"""Test using fixtures."""
# Use client for API calls
response = await client.get(
"/api/v1/posts",
headers={"Authorization": f"Bearer {superadmin_token}"}
)
# Use db_session for direct database access
result = await db_session.execute(select(Post).limit(1))
post = result.scalar_one_or_none()
assert response.status_code == 200
assert post is not None
Async Testing
Marking Async Tests
Use@pytest.mark.asyncio for async test functions:
import pytest
@pytest.mark.asyncio
async def test_async_function():
"""Async test function."""
result = await async_function()
assert result is not None
Async Test Classes
@pytest.mark.asyncio
class TestAsyncOperations:
"""Class containing async tests."""
async def setup_method(self):
"""Run before each test method."""
self.data = await load_test_data()
async def test_async_operation(self):
"""Test async operation."""
result = await async_operation(self.data)
assert result.success
async def teardown_method(self):
"""Run after each test method."""
await cleanup(self.data)
Best Practices
1. Arrange-Act-Assert Pattern
Structure tests clearly:def test_user_can_login():
# Arrange: Set up test data
user = create_test_user(email="[email protected]", password="password123")
# Act: Execute the function being tested
result = auth_service.login("[email protected]", "password123")
# Assert: Verify the result
assert result.success is True
assert result.token is not None
2. Use Descriptive Assertions
# BAD: Generic assertion
assert result is not None
# GOOD: Descriptive assertion
assert result.id == "user_123"
assert result.email == "[email protected]"
assert result.is_active is True
3. Test Edge Cases
@pytest.mark.parametrize("input,expected", [
("", False), # Empty string
("a", True), # Single character
("a" * 1000, True), # Long string
(" spaces ", True), # Whitespace
(None, False), # None value
])
def test_validate_email(input, expected):
"""Test email validation with various inputs."""
result = validate_email(input)
assert result == expected
4. Mock External Dependencies
from unittest.mock import AsyncMock, patch
@pytest.mark.asyncio
async def test_send_notification():
"""Test notification sending with mocked external API."""
# Mock the external service
with patch("src.snackbase.services.notification_service.send_email") as mock_send:
mock_send.return_value = AsyncMock(return_value=True)
# Call the function
await send_user_notification("user_123", "Test message")
# Verify the mock was called
mock_send.assert_called_once_with(
email="[email protected]",
subject="Notification",
body="Test message"
)
5. Use Factory Boy for Test Data
# tests/factories.py
import factory
from factory.alchemy import SQLAlchemyModelFactory
from src.snackbase.infrastructure.persistence.models.user import User
from src.snackbase.infrastructure.persistence.models.post import Post
class UserFactory(SQLAlchemyModelFactory):
"""Factory for creating test users."""
class Meta:
model = User
sqlalchemy_session_persistence = "commit"
id = factory.LazyFunction(lambda: generate_id("user"))
email = factory.Sequence(lambda n: f"user{n}@example.com")
account_id = "AB1001"
password_hash = "$argon2id$v=19$m=65536,t=3,p=4$test"
class PostFactory(SQLAlchemyModelFactory):
"""Factory for creating test posts."""
class Meta:
model = Post
sqlalchemy_session_persistence = "commit"
id = factory.LazyFunction(lambda: generate_id("post"))
account_id = "AB1001"
title = factory.Faker("sentence")
content = factory.Faker("paragraph")
status = "draft"
# Use in tests
@pytest.mark.asyncio
async def test_with_factory(db_session: AsyncSession):
"""Test using factory for test data."""
post = await PostFactory.create_async(
title="Custom Title",
status="published"
)
assert post.title == "Custom Title"
assert post.status == "published"
6. Run Tests Frequently
# Run tests on file save (using pytest-watch)
uv pip install pytest-watch
ptw
# Or use pytest-xdist for parallel runs
uv run pytest -n auto
Summary
| Concept | Key Takeaway |
|---|---|
| Test Framework | pytest with pytest-asyncio for async tests |
| Test Structure | tests/unit/, tests/integration/, tests/security/ |
| Unit Tests | Test business logic, pure functions, validation |
| Integration Tests | Test API endpoints, database operations, permissions |
| Fixtures | Reusable test setup (db_session, client, tokens) |
| Async Testing | Use @pytest.mark.asyncio decorator |
| Best Practices | Arrange-Act-Assert, descriptive assertions, edge cases, mocks |