On this page
Testing Standards
This document defines our testing standards and patterns for ensuring code quality.
Test Structure
We follow a three-tier testing approach:
- Unit Tests - Test individual functions and methods in isolation
- Integration Tests - Test multiple components working together
- 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
- Review
../06-tooling/api-testing.mdfor Postman testing - Check
../03-workflows/feature-development.mdfor testing in the development workflow - See
code-standards.mdfor code patterns to test