API Testing Skill
Expert testing for FastAPI backends and React/Next.js frontends with unit, integration, and E2E test patterns.
Quick Reference
| Test Type | Tool | Purpose | Scope |
|---|---|---|---|
| Unit | pytest | Pure functions, services | Isolated |
| Integration | pytest + TestClient | DB + auth + routes | Combined |
| E2E | Playwright/Cypress | Browser flows | Full stack |
Project Structure
backend/
├── tests/
│ ├── __init__.py
│ ├── conftest.py # Shared fixtures
│ ├── unit/
│ │ ├── test_services.py # Business logic tests
│ │ └── test_utils.py # Utility function tests
│ ├── integration/
│ │ ├── test_students.py # Student API tests
│ │ ├── test_fees.py # Fee API tests
│ │ └── test_auth.py # Authentication tests
│ └── fixtures/
│ ├── students.json # Test data
│ └── users.json
frontend/
├── e2e/
│ ├── specs/
│ │ ├── student.spec.ts
│ │ └── fee.spec.ts
│ ├── pages/
│ │ ├── DashboardPage.ts
│ │ └── StudentPage.ts
│ └── utils/
│ └── test-data.ts
└── playwright.config.ts
Backend: Pytest Setup
conftest.py (Shared Fixtures)
# backend/tests/conftest.py
import pytest
from typing import Generator
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.main import app
from app.db.database import get_db, Base
from app.models import User, Student
from app.auth.jwt import create_access_token
from passlib.context import CryptContext
# Test database setup
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture(scope="function")
def db_session():
"""Create a fresh database for each test."""
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):
"""Create a test client with database override."""
def override_get_db():
try:
yield db_session
finally:
pass
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
@pytest.fixture
def test_user(db_session):
"""Create a test user."""
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
hashed_password = pwd_context.hash("testpassword123")
user = User(
email="test@example.com",
hashed_password=hashed_password,
full_name="Test User",
is_active=True,
)
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return user
@pytest.fixture
def auth_token(test_user):
"""Generate JWT token for test user."""
return create_access_token(data={"sub": test_user.email, "roles": ["admin"]})
@pytest.fixture
def auth_headers(auth_token):
"""Headers with authentication token."""
return {"Authorization": f"Bearer {auth_token}"}
Unit Tests (Pure Functions)
# backend/tests/unit/test_services.py
import pytest
from app.services.fee_calculator import calculate_fee, FeeCalculationError
class TestCalculateFee:
"""Unit tests for fee calculation logic."""
def test_basic_fee_calculation(self):
"""Test basic fee calculation without discounts."""
result = calculate_fee(
base_amount=1000.00,
grade_level=9,
has_sibling_discount=False,
is_new_student=False,
)
assert result == 1000.00
def test_sibling_discount(self):
"""Test 10% sibling discount."""
result = calculate_fee(
base_amount=1000.00,
grade_level=9,
has_sibling_discount=True,
is_new_student=False,
)
assert result == 900.00
def test_new_student_discount(self):
"""Test 15% new student discount."""
result = calculate_fee(
base_amount=1000.00,
grade_level=9,
has_sibling_discount=False,
is_new_student=True,
)
assert result == 850.00
def test_combined_discounts(self):
"""Test combined sibling and new student discounts."""
result = calculate_fee(
base_amount=1000.00,
grade_level=9,
has_sibling_discount=True,
is_new_student=True,
)
# 10% + 15% = 25% discount
assert result == 750.00
def test_invalid_base_amount(self):
"""Test that negative amounts raise error."""
with pytest.raises(FeeCalculationError):
calculate_fee(
base_amount=-100.00,
grade_level=9,
has_sibling_discount=False,
is_new_student=False,
)
def test_grade_level_multipliers(self):
"""Test different grade level multipliers."""
# Elementary (1-5): 1.0x
assert calculate_fee(1000.00, grade_level=3) == 1000.00
# Middle (6-8): 1.1x
assert calculate_fee(1000.00, grade_level=7) == 1100.00
# High (9-12): 1.2x
assert calculate_fee(1000.00, grade_level=10) == 1200.00
Integration Tests (API Endpoints)
# backend/tests/integration/test_students.py
import pytest
from fastapi import status
class TestStudentEndpoints:
"""Integration tests for student CRUD endpoints."""
@pytest.fixture
def create_student_payload(self):
"""Sample student creation payload."""
return {
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@test.edu",
"date_of_birth": "2008-05-15T00:00:00Z",
"grade_level": 9,
}
def test_create_student_success(self, client, auth_headers, create_student_payload):
"""Test successful student creation."""
response = client.post(
"/api/v1/students/",
json=create_student_payload,
headers=auth_headers,
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["first_name"] == "John"
assert data["last_name"] == "Doe"
assert "id" in data
assert data["is_active"] is True
def test_create_student_unauthorized(self, client, create_student_payload):
"""Test that unauthenticated requests are rejected."""
response = client.post(
"/api/v1/students/",
json=create_student_payload,
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_create_student_invalid_email(self, client, auth_headers, create_student_payload):
"""Test validation error for invalid email."""
payload = {**create_student_payload, "email": "invalid-email"}
response = client.post(
"/api/v1/students/",
json=payload,
headers=auth_headers,
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
def test_create_student_missing_required_field(self, client, auth_headers):
"""Test validation error for missing required field."""
payload = {
"first_name": "John",
# Missing last_name, email, date_of_birth, grade_level
}
response = client.post(
"/api/v1/students/",
json=payload,
headers=auth_headers,
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
def test_get_student_success(self, client, auth_headers, create_student_payload):
"""T