Skip to main content
This guide explains how to write and run tests for SnackBase, covering unit tests, integration tests, and best practices.

Overview

SnackBase uses pytest as its testing framework with comprehensive support for async operations and database testing.

Test Stack

ComponentPurpose
pytestTest framework and runner
pytest-asyncioAsync test support
httpxAsync HTTP client for testing
ASGITransportTest FastAPI without network
pytest-covCode coverage reporting

Test Coverage Goals

AreaTarget Coverage
Core Logic (domain/)90%+
Repositories (infrastructure/persistence/)85%+
API Routes (infrastructure/api/routes/)80%+
Services (infrastructure/services/)85%+
Hooks (infrastructure/hooks/)75%+
Overall85%+

Testing Philosophy

Test Pyramid

        /\
       /  \
      / E2E \        ← Few (manual/expensive)
     /--------\
    /Integration \    ← More (API/database)
   /--------------\
  /    Unit Tests   \  ← Most (fast/isolated)
 /------------------\

Testing Principles

PrincipleDescription
FastTests should run quickly (unit tests < 1 second each)
IsolatedTests shouldn’t depend on each other
DeterministicSame input should always produce same output
ReadableTest names should describe what they test
MaintainableTests 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

ConceptKey Takeaway
Test Frameworkpytest with pytest-asyncio for async tests
Test Structuretests/unit/, tests/integration/, tests/security/
Unit TestsTest business logic, pure functions, validation
Integration TestsTest API endpoints, database operations, permissions
FixturesReusable test setup (db_session, client, tokens)
Async TestingUse @pytest.mark.asyncio decorator
Best PracticesArrange-Act-Assert, descriptive assertions, edge cases, mocks