This document defines our testing standards and patterns for ensuring code quality.

Test Structure

We follow a three-tier testing approach:

  1. Unit Tests - Test individual functions and methods in isolation
  2. Integration Tests - Test multiple components working together
  3. End-to-End Tests - Test complete user flows

Test Coverage Requirement: Minimum 80% code coverage


FastAPI Testing Patterns

Test File Structure

  tests/
├── __init__.py
├── conftest.py           # Shared fixtures
├── test_api/
│   ├── __init__.py
│   ├── test_users.py
│   ├── test_orders.py
│   └── test_auth.py
├── test_services/
│   ├── __init__.py
│   ├── test_user_service.py
│   └── test_order_service.py
└── test_models/
    ├── __init__.py
    └── test_user_model.py
  

Shared Fixtures (conftest.py)

  # tests/conftest.py
import pytest
from typing import Generator
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session

from app.main import app
from app.db.base_class import Base
from app.api import deps
from app.core.config import settings

# Use an in-memory SQLite database for tests
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"

engine = create_engine(
    SQLALCHEMY_DATABASE_URL,
    connect_args={"check_same_thread": False}
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)


@pytest.fixture(scope="function")
def db() -> Generator[Session, None, None]:
    """
    Create a fresh database for each test.

    Yields:
        Session: Test database session
    """
    Base.metadata.create_all(bind=engine)
    session = TestingSessionLocal()

    try:
        yield session
    finally:
        session.close()
        Base.metadata.drop_all(bind=engine)


@pytest.fixture(scope="function")
def client(db: Session) -> Generator[TestClient, None, None]:
    """
    Create a test client with database dependency override.

    Args:
        db: Test database session

    Yields:
        TestClient: FastAPI test client
    """
    def override_get_db():
        try:
            yield db
        finally:
            pass

    app.dependency_overrides[deps.get_db] = override_get_db
    yield TestClient(app)
    app.dependency_overrides.clear()


@pytest.fixture
def test_user(db: Session):
    """
    Create a test user.

    Args:
        db: Database session

    Returns:
        User: Test user object
    """
    from app.models.user import User
    from app.core.security import get_password_hash

    user = User(
        email="test@example.com",
        hashed_password=get_password_hash("testpassword123"),
        full_name="Test User",
        is_active=True,
        is_admin=False,
    )
    db.add(user)
    db.commit()
    db.refresh(user)
    return user


@pytest.fixture
def admin_user(db: Session):
    """
    Create a test admin user.

    Args:
        db: Database session

    Returns:
        User: Test admin user object
    """
    from app.models.user import User
    from app.core.security import get_password_hash

    user = User(
        email="admin@example.com",
        hashed_password=get_password_hash("adminpassword123"),
        full_name="Admin User",
        is_active=True,
        is_admin=True,
    )
    db.add(user)
    db.commit()
    db.refresh(user)
    return user


@pytest.fixture
def auth_headers(test_user) -> dict:
    """
    Generate authentication headers for test user.

    Args:
        test_user: Test user fixture

    Returns:
        dict: Headers with Bearer token
    """
    from app.core.security import create_access_token

    token = create_access_token(subject=test_user.id)
    return {"Authorization": f"Bearer {token}"}
  

Unit Test Example (Service Layer)

  # tests/test_services/test_user_service.py
import pytest
from sqlalchemy.orm import Session

from app.services.user_service import UserService
from app.schemas.user import UserCreate, UserUpdate


class TestUserService:
    """Tests for UserService."""

    def test_create_user(self, db: Session):
        """Test creating a new user."""
        user_in = UserCreate(
            email="newuser@example.com",
            full_name="New User",
            password="password123"
        )

        user = UserService.create_user(db, user_in)

        assert user.id is not None
        assert user.email == user_in.email
        assert user.full_name == user_in.full_name
        assert user.hashed_password != user_in.password  # Password should be hashed
        assert user.is_active is True
        assert user.is_admin is False

    def test_create_user_duplicate_email(self, db: Session, test_user):
        """Test creating user with duplicate email raises error."""
        user_in = UserCreate(
            email=test_user.email,  # Duplicate email
            full_name="Another User",
            password="password123"
        )

        with pytest.raises(ValueError, match="already exists"):
            UserService.create_user(db, user_in)

    def test_get_user_by_email(self, db: Session, test_user):
        """Test retrieving user by email."""
        user = UserService.get_user_by_email(db, test_user.email)

        assert user is not None
        assert user.id == test_user.id
        assert user.email == test_user.email

    def test_get_user_by_email_not_found(self, db: Session):
        """Test retrieving non-existent user returns None."""
        user = UserService.get_user_by_email(db, "nonexistent@example.com")

        assert user is None

    def test_update_user(self, db: Session, test_user):
        """Test updating user details."""
        update_data = UserUpdate(full_name="Updated Name")

        updated_user = UserService.update_user(db, test_user.id, update_data)

        assert updated_user.id == test_user.id
        assert updated_user.full_name == "Updated Name"
        assert updated_user.email == test_user.email  # Email unchanged

    def test_update_user_not_found(self, db: Session):
        """Test updating non-existent user raises error."""
        update_data = UserUpdate(full_name="Updated Name")

        with pytest.raises(ValueError, match="not found"):
            UserService.update_user(db, 99999, update_data)
  

Integration Test Example (API Endpoint)

  # tests/test_api/test_users.py
import pytest
from fastapi.testclient import TestClient


class TestUserEndpoints:
    """Tests for user API endpoints."""

    def test_create_user_success(self, client: TestClient, admin_user, auth_headers):
        """Test creating a user as admin."""
        # Update headers to use admin token
        from app.core.security import create_access_token
        admin_token = create_access_token(subject=admin_user.id)
        headers = {"Authorization": f"Bearer {admin_token}"}

        response = client.post(
            "/api/v1/users",
            json={
                "email": "newuser@example.com",
                "full_name": "New User",
                "password": "securepassword123"
            },
            headers=headers
        )

        assert response.status_code == 201
        data = response.json()
        assert data["email"] == "newuser@example.com"
        assert data["full_name"] == "New User"
        assert "id" in data
        assert "password" not in data  # Password should not be in response

    def test_create_user_forbidden_non_admin(self, client: TestClient, auth_headers):
        """Test creating user as non-admin returns 403."""
        response = client.post(
            "/api/v1/users",
            json={
                "email": "newuser@example.com",
                "full_name": "New User",
                "password": "password123"
            },
            headers=auth_headers
        )

        assert response.status_code == 403
        assert "Only administrators" in response.json()["detail"]

    def test_create_user_duplicate_email(self, client: TestClient, admin_user, test_user):
        """Test creating user with duplicate email returns 400."""
        from app.core.security import create_access_token
        admin_token = create_access_token(subject=admin_user.id)
        headers = {"Authorization": f"Bearer {admin_token}"}

        response = client.post(
            "/api/v1/users",
            json={
                "email": test_user.email,  # Duplicate
                "full_name": "New User",
                "password": "password123"
            },
            headers=headers
        )

        assert response.status_code == 400
        assert "already exists" in response.json()["detail"]

    def test_create_user_unauthorized(self, client: TestClient):
        """Test creating user without authentication returns 401."""
        response = client.post(
            "/api/v1/users",
            json={
                "email": "newuser@example.com",
                "full_name": "New User",
                "password": "password123"
            }
        )

        assert response.status_code == 401

    def test_get_user_profile(self, client: TestClient, test_user, auth_headers):
        """Test getting current user profile."""
        response = client.get("/api/v1/users/me", headers=auth_headers)

        assert response.status_code == 200
        data = response.json()
        assert data["id"] == test_user.id
        assert data["email"] == test_user.email
  

Testing Background Tasks

  # tests/test_tasks/test_order_tasks.py
import pytest
from unittest.mock import patch, MagicMock

from app.tasks.order_tasks import process_order_task
from app.models.order import Order


class TestOrderTasks:
    """Tests for order background tasks."""

    @patch('app.services.payment_service.PaymentService.charge_order')
    @patch('app.services.inventory_service.InventoryService.reserve_inventory')
    def test_process_order_success(
        self,
        mock_reserve_inventory,
        mock_charge_order,
        db,
        test_user
    ):
        """Test successful order processing."""
        # Create test order
        order = Order(user_id=test_user.id, status="pending")
        db.add(order)
        db.commit()

        # Mock external calls
        mock_reserve_inventory.return_value = True
        mock_charge_order.return_value = MagicMock(id=123)

        # Run task
        process_order_task(order.id)

        # Verify order status updated
        db.refresh(order)
        assert order.status == "completed"
        assert order.payment_id == 123

        # Verify services were called
        mock_reserve_inventory.assert_called_once()
        mock_charge_order.assert_called_once()

    @patch('app.services.inventory_service.InventoryService.reserve_inventory')
    def test_process_order_insufficient_inventory(
        self,
        mock_reserve_inventory,
        db,
        test_user
    ):
        """Test order processing with insufficient inventory."""
        order = Order(user_id=test_user.id, status="pending")
        db.add(order)
        db.commit()

        # Mock inventory failure
        mock_reserve_inventory.side_effect = ValueError("Insufficient inventory")

        # Run task
        process_order_task(order.id)

        # Verify order status updated to failed
        db.refresh(order)
        assert order.status == "failed"
        assert "Insufficient inventory" in order.failure_reason
  

React Testing Patterns

Test Setup (setupTests.ts)

  // src/setupTests.ts
import '@testing-library/jest-dom';
import { server } from './mocks/server';

// Establish API mocking before all tests
beforeAll(() => server.listen());

// Reset any request handlers that we may add during the tests,
// so they don't affect other tests
afterEach(() => server.resetHandlers());

// Clean up after the tests are finished
afterAll(() => server.close());
  

Mock Service Worker (MSW) Setup

  // src/mocks/handlers.ts
import { rest } from 'msw';

export const handlers = [
  // Mock user profile endpoint
  rest.get('/api/v1/users/me', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({
        id: 1,
        email: 'test@example.com',
        full_name: 'Test User',
        is_active: true,
        is_admin: false,
      })
    );
  }),

  // Mock login endpoint
  rest.post('/api/v1/auth/login', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({
        access_token: 'fake-token',
        refresh_token: 'fake-refresh-token',
        token_type: 'bearer',
      })
    );
  }),

  // Mock orders list
  rest.get('/api/v1/orders', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({
        items: [
          { id: 1, status: 'completed', total_amount: 99.99 },
          { id: 2, status: 'pending', total_amount: 149.99 },
        ],
        total: 2,
        page: 1,
        page_size: 20,
        has_next: false,
      })
    );
  }),
];

// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
  

Component Test Example

  // src/components/__tests__/UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { UserProfile } from '../UserProfile';

// Create a test QueryClient
const createTestQueryClient = () =>
  new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false },
    },
  });

// Wrapper component for tests
const wrapper = ({ children }: { children: React.ReactNode }) => {
  const queryClient = createTestQueryClient();
  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
};

describe('UserProfile', () => {
  it('renders user profile data', async () => {
    render(<UserProfile userId={1} />, { wrapper });

    // Wait for data to load
    await waitFor(() => {
      expect(screen.getByText('Test User')).toBeInTheDocument();
    });

    expect(screen.getByText('test@example.com')).toBeInTheDocument();
    expect(screen.getByText('Active')).toBeInTheDocument();
  });

  it('shows loading state initially', () => {
    render(<UserProfile userId={1} />, { wrapper });

    expect(screen.getByRole('status')).toBeInTheDocument(); // Loading spinner
  });

  it('allows editing profile', async () => {
    const user = userEvent.setup();
    render(<UserProfile userId={1} />, { wrapper });

    // Wait for data to load
    await waitFor(() => {
      expect(screen.getByText('Test User')).toBeInTheDocument();
    });

    // Click edit button
    await user.click(screen.getByRole('button', { name: /edit profile/i }));

    // Input should be editable
    const nameInput = screen.getByLabelText(/full name/i);
    expect(nameInput).toBeInTheDocument();

    // Change name
    await user.clear(nameInput);
    await user.type(nameInput, 'Updated Name');

    // Submit form
    await user.click(screen.getByRole('button', { name: /save changes/i }));

    // Verify updated (would need to mock mutation response)
    await waitFor(() => {
      expect(screen.queryByLabelText(/full name/i)).not.toBeInTheDocument();
    });
  });

  it('handles error state', async () => {
    // Override handler to return error
    server.use(
      rest.get('/api/v1/users/:userId', (req, res, ctx) => {
        return res(ctx.status(500), ctx.json({ detail: 'Server error' }));
      })
    );

    render(<UserProfile userId={1} />, { wrapper });

    await waitFor(() => {
      expect(screen.getByText(/failed to load user/i)).toBeInTheDocument();
    });
  });
});
  

Hook Test Example

  // src/hooks/__tests__/useOrders.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useOrders } from '../useOrders';

const createWrapper = () => {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });

  return ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
};

describe('useOrders', () => {
  it('fetches orders successfully', async () => {
    const { result } = renderHook(() => useOrders(), {
      wrapper: createWrapper(),
    });

    await waitFor(() => expect(result.current.isSuccess).toBe(true));

    expect(result.current.data?.items).toHaveLength(2);
    expect(result.current.data?.items[0].id).toBe(1);
  });
});
  

Database Test Fixtures

  # tests/fixtures/order_fixtures.py
import pytest
from sqlalchemy.orm import Session

from app.models.order import Order, OrderItem
from app.models.product import Product


@pytest.fixture
def test_product(db: Session):
    """Create a test product."""
    product = Product(
        name="Test Product",
        sku="TEST-001",
        price=29.99,
        inventory_count=100,
        is_active=True,
    )
    db.add(product)
    db.commit()
    db.refresh(product)
    return product


@pytest.fixture
def test_order(db: Session, test_user, test_product):
    """Create a test order with items."""
    order = Order(
        user_id=test_user.id,
        status="pending",
        total_amount=59.98,
    )
    db.add(order)
    db.commit()
    db.refresh(order)

    # Add order items
    item1 = OrderItem(
        order_id=order.id,
        product_id=test_product.id,
        quantity=2,
        unit_price=29.99,
    )
    db.add(item1)
    db.commit()

    return order
  

Mocking External APIs

  # tests/test_services/test_shopify_service.py
import pytest
from unittest.mock import patch, MagicMock

from app.services.shopify_service import ShopifyService


class TestShopifyService:
    """Tests for Shopify integration."""

    @patch('app.services.shopify_service.requests.get')
    def test_get_order(self, mock_get):
        """Test fetching order from Shopify API."""
        # Mock API response
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "order": {
                "id": 12345,
                "email": "customer@example.com",
                "total_price": "99.99"
            }
        }
        mock_get.return_value = mock_response

        # Call service
        order = ShopifyService.get_order("12345")

        # Verify
        assert order["id"] == 12345
        assert order["email"] == "customer@example.com"
        mock_get.assert_called_once()

    @patch('app.services.shopify_service.requests.get')
    def test_get_order_not_found(self, mock_get):
        """Test handling of 404 from Shopify API."""
        mock_response = MagicMock()
        mock_response.status_code = 404
        mock_get.return_value = mock_response

        with pytest.raises(ValueError, match="Order not found"):
            ShopifyService.get_order("99999")
  

What to Test vs What Not to Test

✅ DO Test

  • Business logic - Core functionality and rules
  • Edge cases - Boundary conditions, null values, empty lists
  • Error handling - Exceptions, validation errors
  • Authentication/authorisation - Access control
  • Database operations - CRUD operations, transactions
  • API endpoints - Request/response, status codes
  • Integrations - External API calls (mocked)

❌ DON’T Test

  • Framework internals - FastAPI, React, SQLAlchemy internals
  • Third-party libraries - Trust they’re tested
  • Getters/setters - Simple property access
  • Constants - Static values
  • Generated code - Alembic migrations (test manually)

Running Tests

Backend Tests (pytest)

  # Run all tests
pytest

# Run specific file
pytest tests/test_api/test_users.py

# Run specific test
pytest tests/test_api/test_users.py::TestUserEndpoints::test_create_user_success

# Run with coverage
pytest --cov=app --cov-report=html

# Run in watch mode (requires pytest-watch)
ptw

# Run with verbose output
pytest -v

# Run only failed tests
pytest --lf
  

Frontend Tests (Jest/Vitest)

  # Run all tests
npm test

# Run in watch mode
npm test -- --watch

# Run with coverage
npm run test:coverage

# Run specific file
npm test UserProfile.test.tsx

# Update snapshots
npm test -- -u
  

API Testing with Postman

See ../06-tooling/api-testing.md for complete Postman guide including:

  • Collection organization
  • Environment variables
  • Writing test scripts
  • Postman Bot integration for automated testing
  • Newman for CI/CD

CI/CD Testing

  # .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  backend-tests:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: postgres
        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 -r requirements.txt
          pip install -r requirements-dev.txt

      - name: Run tests
        run: pytest --cov=app --cov-report=xml

      - name: Upload coverage
        uses: codecov/codecov-action@v3

  frontend-tests:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Set up Node
        uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test -- --coverage
  

Next Steps