Testing Guidelines¶
This document outlines testing strategies, best practices, and guidelines for the CulicidaeLab Server project, covering both backend and frontend testing approaches.
Testing Philosophy¶
The CulicidaeLab Server follows a comprehensive testing strategy that emphasizes:
- Test-Driven Development (TDD): Write tests before implementation when possible
- Pyramid Testing: More unit tests, fewer integration tests, minimal end-to-end tests
- Fast Feedback: Quick test execution for rapid development cycles
- Realistic Testing: Use real data and scenarios that mirror production usage
- Automated Testing: Continuous integration with automated test execution
Testing Stack¶
Backend Testing Tools¶
- pytest: Primary testing framework for Python
- pytest-asyncio: Async test support for FastAPI endpoints
- httpx: HTTP client for API testing
- pytest-cov: Code coverage reporting
- pytest-mock: Mocking and patching utilities
Frontend Testing Tools¶
- pytest: Component and integration testing
- Solara testing utilities: Component rendering and interaction testing
- Selenium (optional): End-to-end browser testing
Performance Testing¶
- pytest-benchmark: Performance benchmarking
- locust: Load testing for API endpoints
- psutil: System resource monitoring during tests
Test Organization¶
Directory Structure¶
tests/
├── conftest.py # Shared test configuration
├── backend/ # Backend tests
│ ├── test_api/ # API endpoint tests
│ │ ├── test_species.py # Species API tests
│ │ ├── test_diseases.py # Disease API tests
│ │ ├── test_prediction.py # Prediction API tests
│ │ └── test_geo.py # Geographic API tests
│ ├── test_services/ # Service layer tests
│ │ ├── test_species_service.py
│ │ ├── test_prediction_service.py
│ │ └── test_database.py
│ └── test_utils/ # Utility function tests
├── frontend/ # Frontend tests
│ ├── test_components/ # Component tests
│ ├── test_pages/ # Page tests
│ └── test_state/ # State management tests
├── load_tests/ # Performance tests
├── performance_tests/ # Benchmark tests
└── fixtures/ # Test data and fixtures
├── sample_images/ # Test images
├── sample_data.json # Test datasets
└── mock_responses/ # API response mocks
Backend Testing¶
Unit Testing¶
Service Layer Testing¶
Test business logic in isolation:
# tests/backend/test_services/test_species_service.py
import pytest
from unittest.mock import Mock, patch
from backend.services.species_service import SpeciesService
from backend.schemas.species_schemas import SpeciesResponse
class TestSpeciesService:
"""Test suite for SpeciesService."""
@pytest.fixture
def mock_db(self):
"""Mock database connection."""
return Mock()
@pytest.fixture
def species_service(self, mock_db):
"""Species service with mocked dependencies."""
with patch('backend.services.species_service.get_db', return_value=mock_db):
return SpeciesService()
@pytest.mark.asyncio
async def test_get_species_by_id_success(self, species_service, mock_db):
"""Test successful species retrieval by ID."""
# Arrange
expected_species = {
"id": "aedes_aegypti",
"scientific_name": "Aedes aegypti",
"common_names": {"en": "Yellow fever mosquito"}
}
mock_table = Mock()
mock_table.search.return_value.where.return_value.limit.return_value.to_list.return_value = [expected_species]
mock_db.open_table.return_value = mock_table
# Act
result = await species_service.get_by_id("aedes_aegypti")
# Assert
assert result is not None
assert result.scientific_name == "Aedes aegypti"
mock_table.search.assert_called_once()
@pytest.mark.asyncio
async def test_get_species_by_id_not_found(self, species_service, mock_db):
"""Test species not found scenario."""
# Arrange
mock_table = Mock()
mock_table.search.return_value.where.return_value.limit.return_value.to_list.return_value = []
mock_db.open_table.return_value = mock_table
# Act & Assert
with pytest.raises(ValueError, match="Species .* not found"):
await species_service.get_by_id("nonexistent_species")
@pytest.mark.asyncio
async def test_search_species_with_filters(self, species_service, mock_db):
"""Test species search with filters."""
# Arrange
mock_results = [
{"id": "aedes_aegypti", "scientific_name": "Aedes aegypti"},
{"id": "aedes_albopictus", "scientific_name": "Aedes albopictus"}
]
mock_table = Mock()
mock_table.search.return_value.where.return_value.limit.return_value.to_list.return_value = mock_results
mock_db.open_table.return_value = mock_table
# Act
results = await species_service.search(region="Europe", limit=10)
# Assert
assert len(results) == 2
assert all(isinstance(r, SpeciesResponse) for r in results)
Database Testing¶
Test database operations with real LanceDB:
# tests/backend/test_services/test_database.py
import pytest
import tempfile
import shutil
from pathlib import Path
import pandas as pd
from backend.services.database import get_db, get_table
class TestDatabase:
"""Test database operations."""
@pytest.fixture
def temp_db_path(self):
"""Create temporary database for testing."""
temp_dir = tempfile.mkdtemp()
yield temp_dir
shutil.rmtree(temp_dir)
@pytest.fixture
def test_db(self, temp_db_path):
"""Create test database with sample data."""
import lancedb
db = lancedb.connect(temp_db_path)
# Create test species table
species_data = pd.DataFrame([
{
"id": "aedes_aegypti",
"scientific_name": "Aedes aegypti",
"common_names": '{"en": "Yellow fever mosquito"}',
"region": "Global"
},
{
"id": "aedes_albopictus",
"scientific_name": "Aedes albopictus",
"common_names": '{"en": "Asian tiger mosquito"}',
"region": "Asia"
}
])
db.create_table("species", species_data, mode="overwrite")
return db
def test_get_table_success(self, test_db):
"""Test successful table retrieval."""
table = get_table(test_db, "species")
assert table is not None
# Verify table contents
results = table.search().to_list()
assert len(results) == 2
def test_get_table_not_found(self, test_db):
"""Test handling of non-existent table."""
with pytest.raises(ValueError, match="Table 'nonexistent' not found"):
get_table(test_db, "nonexistent")
def test_species_search_by_region(self, test_db):
"""Test region-based species search."""
table = get_table(test_db, "species")
# Search for Asian species
results = table.search().where("region = 'Asia'").to_list()
assert len(results) == 1
assert results[0]["scientific_name"] == "Aedes albopictus"
API Testing¶
Endpoint Testing¶
Test FastAPI endpoints with realistic scenarios:
# tests/backend/test_api/test_species.py
import pytest
from fastapi.testclient import TestClient
from backend.main import app
class TestSpeciesAPI:
"""Test species API endpoints."""
@pytest.fixture
def client(self):
"""Test client for API calls."""
return TestClient(app)
def test_get_species_list(self, client):
"""Test species list endpoint."""
response = client.get("/api/species")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
# Verify response structure
if data:
species = data[0]
assert "id" in species
assert "scientific_name" in species
assert "common_names" in species
def test_get_species_by_id(self, client):
"""Test species detail endpoint."""
# First get a valid species ID
list_response = client.get("/api/species")
species_list = list_response.json()
if species_list:
species_id = species_list[0]["id"]
# Test detail endpoint
response = client.get(f"/api/species/{species_id}")
assert response.status_code == 200
species = response.json()
assert species["id"] == species_id
def test_get_species_not_found(self, client):
"""Test species not found scenario."""
response = client.get("/api/species/nonexistent_species")
assert response.status_code == 404
error = response.json()
assert "detail" in error
def test_species_search_with_filters(self, client):
"""Test species search with query parameters."""
response = client.get("/api/species", params={
"region": "Europe",
"limit": 5
})
assert response.status_code == 200
data = response.json()
assert len(data) <= 5
def test_species_search_invalid_limit(self, client):
"""Test validation of search parameters."""
response = client.get("/api/species", params={"limit": -1})
assert response.status_code == 422 # Validation error
Prediction API Testing¶
Test AI prediction endpoints with image uploads:
# tests/backend/test_api/test_prediction.py
import pytest
from fastapi.testclient import TestClient
from backend.main import app
import io
from PIL import Image
class TestPredictionAPI:
"""Test prediction API endpoints."""
@pytest.fixture
def client(self):
return TestClient(app)
@pytest.fixture
def test_image(self):
"""Create a test image for upload."""
# Create a simple test image
img = Image.new('RGB', (224, 224), color='red')
img_bytes = io.BytesIO()
img.save(img_bytes, format='JPEG')
img_bytes.seek(0)
return img_bytes
def test_predict_species_success(self, client, test_image):
"""Test successful species prediction."""
response = client.post(
"/api/predict_species/",
files={"file": ("test.jpg", test_image, "image/jpeg")}
)
assert response.status_code == 200
data = response.json()
# Verify response structure
assert "species" in data
assert "confidence" in data
assert "alternatives" in data
assert isinstance(data["confidence"], float)
assert 0 <= data["confidence"] <= 1
def test_predict_species_invalid_file(self, client):
"""Test prediction with invalid file type."""
# Create a text file instead of image
text_file = io.BytesIO(b"This is not an image")
response = client.post(
"/api/predict_species/",
files={"file": ("test.txt", text_file, "text/plain")}
)
assert response.status_code == 400
error = response.json()
assert "Invalid file type" in error["detail"]
def test_predict_species_no_file(self, client):
"""Test prediction without file upload."""
response = client.post("/api/predict_species/")
assert response.status_code == 422 # Missing required field
Integration Testing¶
Test complete workflows across multiple components:
# tests/backend/test_integration/test_prediction_workflow.py
import pytest
from fastapi.testclient import TestClient
from backend.main import app
import tempfile
import shutil
from pathlib import Path
class TestPredictionWorkflow:
"""Test complete prediction workflow."""
@pytest.fixture
def client(self):
return TestClient(app)
@pytest.fixture
def sample_mosquito_image(self):
"""Load a real mosquito image for testing."""
# Use a sample image from test fixtures
image_path = Path("tests/fixtures/sample_images/aedes_aegypti.jpg")
if image_path.exists():
return image_path.open("rb")
else:
pytest.skip("Sample image not available")
def test_complete_prediction_and_observation_workflow(self, client, sample_mosquito_image):
"""Test prediction followed by observation storage."""
# Step 1: Predict species
prediction_response = client.post(
"/api/predict_species/",
files={"file": ("mosquito.jpg", sample_mosquito_image, "image/jpeg")}
)
assert prediction_response.status_code == 200
prediction_data = prediction_response.json()
# Step 2: Store observation based on prediction
observation_data = {
"species_id": prediction_data["species"],
"latitude": 40.4168,
"longitude": -3.7038,
"confidence": prediction_data["confidence"],
"notes": "Test observation from integration test"
}
observation_response = client.post("/api/observations/", json=observation_data)
assert observation_response.status_code == 201
stored_observation = observation_response.json()
assert stored_observation["species_id"] == prediction_data["species"]
# Step 3: Verify observation can be retrieved
observation_id = stored_observation["id"]
get_response = client.get(f"/api/observations/{observation_id}")
assert get_response.status_code == 200
retrieved_observation = get_response.json()
assert retrieved_observation["id"] == observation_id
Frontend Testing¶
Component Testing¶
Test Solara components in isolation:
# tests/frontend/test_components/test_species_card.py
import pytest
import solara
from frontend.components.species.species_card import SpeciesCard
class TestSpeciesCard:
"""Test SpeciesCard component."""
@pytest.fixture
def sample_species(self):
"""Sample species data for testing."""
return {
"id": "aedes_aegypti",
"scientific_name": "Aedes aegypti",
"common_names": {"en": "Yellow fever mosquito"},
"description": {"en": "A mosquito species..."},
"images": ["aedes_aegypti_1.jpg"]
}
def test_species_card_renders(self, sample_species):
"""Test that species card renders without errors."""
@solara.component
def TestApp():
SpeciesCard(species=sample_species)
# Render component
box, rc = solara.render(TestApp(), handle_error=False)
# Verify content is present
rendered_content = str(box)
assert "Aedes aegypti" in rendered_content
assert "Yellow fever mosquito" in rendered_content
def test_species_card_click_interaction(self, sample_species):
"""Test species card click handling."""
clicked_species = None
def on_species_click(species):
nonlocal clicked_species
clicked_species = species
@solara.component
def TestApp():
SpeciesCard(
species=sample_species,
on_click=on_species_click
)
box, rc = solara.render(TestApp(), handle_error=False)
# Simulate click (this would require more sophisticated testing setup)
# For now, verify the component structure supports interaction
assert "on_click" in str(box) or "click" in str(box).lower()
Page Testing¶
Test complete page components:
# tests/frontend/test_pages/test_prediction_page.py
import pytest
import solara
from unittest.mock import patch, AsyncMock
from frontend.pages.prediction import Page as PredictionPage
class TestPredictionPage:
"""Test prediction page functionality."""
def test_prediction_page_renders(self):
"""Test that prediction page renders without errors."""
@solara.component
def TestApp():
PredictionPage()
box, rc = solara.render(TestApp(), handle_error=False)
# Verify key elements are present
rendered_content = str(box)
assert "predict" in rendered_content.lower() or "upload" in rendered_content.lower()
@patch('frontend.state.fetch_api_data')
def test_prediction_page_with_mock_api(self, mock_fetch):
"""Test prediction page with mocked API calls."""
# Mock API response
mock_fetch.return_value = AsyncMock(return_value={
"species": "Aedes aegypti",
"confidence": 0.95,
"alternatives": []
})
@solara.component
def TestApp():
PredictionPage()
box, rc = solara.render(TestApp(), handle_error=False)
# Verify component handles API integration
assert mock_fetch.called or True # Basic structure test
State Management Testing¶
Test reactive state behavior:
# tests/frontend/test_state/test_species_state.py
import pytest
import solara
from frontend.state import selected_species_reactive, species_data_reactive
class TestSpeciesState:
"""Test species-related state management."""
def test_selected_species_reactive(self):
"""Test species selection state."""
# Initial state
assert selected_species_reactive.value == ["Aedes albopictus", "Anopheles gambiae"]
# Update state
new_selection = ["Aedes aegypti", "Culex pipiens"]
selected_species_reactive.value = new_selection
assert selected_species_reactive.value == new_selection
def test_species_data_caching(self):
"""Test species data caching behavior."""
# Initial empty state
species_data_reactive.value = []
assert len(species_data_reactive.value) == 0
# Add species data
test_species = [
{"id": "aedes_aegypti", "scientific_name": "Aedes aegypti"},
{"id": "culex_pipiens", "scientific_name": "Culex pipiens"}
]
species_data_reactive.value = test_species
assert len(species_data_reactive.value) == 2
assert species_data_reactive.value[0]["id"] == "aedes_aegypti"
Performance Testing¶
Backend Performance Tests¶
Test API performance and resource usage:
# tests/performance_tests/test_api_performance.py
import pytest
import time
import asyncio
from fastapi.testclient import TestClient
from backend.main import app
class TestAPIPerformance:
"""Test API performance characteristics."""
@pytest.fixture
def client(self):
return TestClient(app)
def test_species_list_performance(self, client):
"""Test species list endpoint performance."""
start_time = time.time()
response = client.get("/api/species?limit=100")
end_time = time.time()
response_time = end_time - start_time
assert response.status_code == 200
assert response_time < 2.0 # Should respond within 2 seconds
def test_prediction_performance(self, client):
"""Test prediction endpoint performance."""
# Create test image
import io
from PIL import Image
img = Image.new('RGB', (224, 224), color='red')
img_bytes = io.BytesIO()
img.save(img_bytes, format='JPEG')
img_bytes.seek(0)
start_time = time.time()
response = client.post(
"/api/predict_species/",
files={"file": ("test.jpg", img_bytes, "image/jpeg")}
)
end_time = time.time()
response_time = end_time - start_time
assert response.status_code == 200
assert response_time < 10.0 # Prediction should complete within 10 seconds
@pytest.mark.benchmark
def test_database_query_performance(self, benchmark):
"""Benchmark database query performance."""
from backend.services.database import get_db, get_table
def query_species():
db = get_db()
table = get_table(db, "species")
return table.search().limit(50).to_list()
result = benchmark(query_species)
assert len(result) <= 50
Load Testing¶
Use locust for load testing:
# tests/load_tests/locustfile.py
from locust import HttpUser, task, between
class CulicidaeLabUser(HttpUser):
"""Simulated user for load testing."""
wait_time = between(1, 3) # Wait 1-3 seconds between requests
def on_start(self):
"""Called when user starts."""
pass
@task(3)
def view_species_list(self):
"""Most common task - viewing species list."""
self.client.get("/api/species?limit=20")
@task(2)
def view_species_detail(self):
"""View individual species details."""
# Get species list first
response = self.client.get("/api/species?limit=5")
if response.status_code == 200:
species_list = response.json()
if species_list:
species_id = species_list[0]["id"]
self.client.get(f"/api/species/{species_id}")
@task(1)
def search_species(self):
"""Search species with filters."""
self.client.get("/api/species", params={
"region": "Europe",
"limit": 10
})
@task(1)
def get_filter_options(self):
"""Get filter options for UI."""
self.client.get("/api/filter_options")
# Run with: locust -f tests/load_tests/locustfile.py --host=http://localhost:8000
Test Data Management¶
Fixtures and Test Data¶
Organize test data effectively:
# tests/conftest.py
import pytest
import json
from pathlib import Path
@pytest.fixture(scope="session")
def test_data_dir():
"""Path to test data directory."""
return Path(__file__).parent / "fixtures"
@pytest.fixture(scope="session")
def sample_species_data(test_data_dir):
"""Load sample species data."""
data_file = test_data_dir / "sample_data.json"
if data_file.exists():
with open(data_file) as f:
return json.load(f)["species"]
return []
@pytest.fixture(scope="session")
def sample_images_dir(test_data_dir):
"""Path to sample images directory."""
return test_data_dir / "sample_images"
@pytest.fixture
def mock_prediction_response():
"""Mock prediction API response."""
return {
"species": "Aedes aegypti",
"confidence": 0.95,
"alternatives": [
{"species": "Aedes albopictus", "confidence": 0.03},
{"species": "Culex pipiens", "confidence": 0.02}
]
}
Database Test Setup¶
Set up test databases:
# tests/conftest.py (continued)
import tempfile
import shutil
import pandas as pd
import lancedb
@pytest.fixture(scope="function")
def test_database():
"""Create temporary test database."""
temp_dir = tempfile.mkdtemp()
try:
db = lancedb.connect(temp_dir)
# Create test tables with sample data
species_df = pd.DataFrame([
{
"id": "aedes_aegypti",
"scientific_name": "Aedes aegypti",
"common_names": '{"en": "Yellow fever mosquito"}',
"region": "Global"
}
])
db.create_table("species", species_df, mode="overwrite")
yield db
finally:
shutil.rmtree(temp_dir)
Continuous Integration¶
GitHub Actions Workflow¶
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.11, 3.12]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .
pip install pytest pytest-cov pytest-asyncio
- name: Run tests
run: |
pytest tests/ -v --cov=backend --cov=frontend --cov-report=xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
Pre-commit Hooks¶
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: pytest-check
name: pytest-check
entry: pytest
language: system
pass_filenames: false
always_run: true
args: [tests/backend/test_services/, -v, --tb=short]
Test Coverage and Quality¶
Coverage Requirements¶
Maintain high test coverage:
# pytest.ini
[tool:pytest]
addopts =
--cov=backend
--cov=frontend
--cov-report=html
--cov-report=term-missing
--cov-fail-under=80
-v
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
Quality Metrics¶
Monitor test quality:
- Coverage: Aim for >80% code coverage
- Performance: API responses <2s, predictions <10s
- Reliability: Tests should pass consistently
- Maintainability: Clear test names and documentation
Best Practices Summary¶
- Write Clear Test Names: Test names should describe what is being tested
- Use Fixtures: Reuse test setup code with pytest fixtures
- Mock External Dependencies: Isolate units under test
- Test Edge Cases: Include error conditions and boundary values
- Keep Tests Fast: Unit tests should run in milliseconds
- Test Behavior, Not Implementation: Focus on what the code does, not how
- Use Real Data: Test with realistic data when possible
- Automate Everything: Run tests automatically on every change
- Monitor Performance: Track test execution time and system performance
- Document Test Requirements: Clear setup instructions for new developers
This testing strategy ensures the CulicidaeLab Server maintains high quality, reliability, and performance as it evolves.