Skip to main content

Testing Guide

This guide covers testing practices and conventions for the SEO Platform.

Testing Strategy

The platform uses a multi-layer testing approach:

LayerToolFocus
Unit Testspytest / VitestIndividual functions and components
Integration TestspytestAPI endpoints with database
E2E TestsPlaywright (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

LayerTarget 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