Testing Guide
This guide covers testing practices and conventions for the SEO Platform.
Testing Strategy
The platform uses a multi-layer testing approach:
| Layer | Tool | Focus |
|---|---|---|
| Unit Tests | pytest / Vitest | Individual functions and components |
| Integration Tests | pytest | API endpoints with database |
| E2E Tests | Playwright (optional) | Full user flows |
Backend Testing
Setup
Backend tests use pytest with async support:
cd backend
source ../.venv/bin/activate
pip install pytest pytest-asyncio httpx
Running Tests
# All tests
pytest
# Specific file
pytest tests/integration/test_jobs.py
# Specific test
pytest tests/integration/test_jobs.py::test_create_job
# With coverage
pytest --cov=app --cov-report=html
# Verbose output
pytest -v
Test Configuration
pytest.ini:
[pytest]
asyncio_mode = auto
testpaths = tests
python_files = test_*.py
python_functions = test_*
filterwarnings =
ignore::DeprecationWarning
Writing Tests
Test Fixtures
# tests/conftest.py
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from app.main import app
from app.core.database import get_db
@pytest.fixture
async def db_session():
"""Create a test database session."""
engine = create_async_engine(TEST_DATABASE_URL)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with AsyncSession(engine) as session:
yield session
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture
async def client(db_session: AsyncSession):
"""Create a test HTTP client."""
app.dependency_overrides[get_db] = lambda: db_session
async with AsyncClient(app=app, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
@pytest.fixture
async def auth_headers(client: AsyncClient):
"""Get authentication headers for a test user."""
# Create test user
await client.post("/api/v1/users/signup", json={
"email": "test@example.com",
"password": "testpassword123"
})
# Login
response = await client.post("/api/v1/auth/login", data={
"username": "test@example.com",
"password": "testpassword123"
})
token = response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
Integration Test Example
# tests/integration/test_jobs.py
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_upload_keywords(client: AsyncClient, auth_headers: dict):
"""Test keyword file upload."""
csv_content = b"keyword,search_volume\ntest keyword,1000"
response = await client.post(
"/api/v1/jobs/upload",
headers=auth_headers,
files={"file": ("keywords.csv", csv_content, "text/csv")},
)
assert response.status_code == 201
data = response.json()
assert "id" in data
assert data["status"] == "pending"
@pytest.mark.asyncio
async def test_list_jobs(client: AsyncClient, auth_headers: dict):
"""Test listing jobs."""
response = await client.get("/api/v1/jobs", headers=auth_headers)
assert response.status_code == 200
assert isinstance(response.json(), list)
@pytest.mark.asyncio
async def test_unauthorized_access(client: AsyncClient):
"""Test that unauthenticated requests are rejected."""
response = await client.get("/api/v1/jobs")
assert response.status_code == 401
Unit Test Example
# tests/unit/test_keyword_parser.py
import pytest
from app.services.ingest.keyword_parser import parse_keywords
def test_parse_simple_csv():
"""Test parsing a simple CSV file."""
csv_content = "keyword,search_volume\ntest,1000\nexample,500"
keywords = parse_keywords(csv_content.encode())
assert len(keywords) == 2
assert keywords[0].text == "test"
assert keywords[0].search_volume == 1000
def test_parse_handles_empty_values():
"""Test parsing handles empty values gracefully."""
csv_content = "keyword,search_volume\ntest,\nexample,500"
keywords = parse_keywords(csv_content.encode())
assert len(keywords) == 2
assert keywords[0].search_volume is None
Frontend Testing
Setup
Frontend tests use Vitest:
cd frontend
npm install -D vitest @testing-library/react @testing-library/user-event jsdom
Running Tests
# All tests
npm run test
# Watch mode
npm run test:watch
# Coverage
npm run test:coverage
# UI mode
npm run test:ui
Test Configuration
vitest.config.ts:
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
},
});
Writing Tests
Component Test Example
// src/pages/JobsPage.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { JobsPage } from './JobsPage';
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};
describe('JobsPage', () => {
it('renders loading state initially', () => {
render(<JobsPage />, { wrapper: createWrapper() });
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('renders jobs when loaded', async () => {
render(<JobsPage />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
});
});
Hook Test Example
// src/hooks/useJobs.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { useJobs } from './useJobs';
import { createWrapper } from '../test/utils';
describe('useJobs', () => {
it('fetches jobs successfully', async () => {
const { result } = renderHook(() => useJobs(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toBeDefined();
});
});
Test Coverage
Coverage Goals
| Layer | Target Coverage |
|---|---|
| Backend Services | >80% |
| Backend API | >70% |
| Frontend Components | >60% |
Viewing Coverage
# Backend
pytest --cov=app --cov-report=html
open htmlcov/index.html
# Frontend
npm run test:coverage
Continuous Integration
Tests run automatically on every push and PR:
# .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
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- run: pip install -r backend/requirements.txt
- run: pytest backend/tests
frontend-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: cd frontend && npm ci
- run: cd frontend && npm run test
Best Practices
Do
- Write tests for new features and bug fixes
- Use descriptive test names that explain what's being tested
- Test edge cases and error conditions
- Keep tests isolated and independent
- Use fixtures for common setup
Don't
- Test implementation details
- Mock too much (prefer integration tests)
- Write flaky tests that depend on timing
- Ignore test failures
Related Documentation
- Backend Development - Backend guide
- Frontend Development - Frontend guide
- Development Setup - Local setup