Testing Best Practices¶
This document establishes comprehensive testing strategies for geospatial systems, covering unit testing, integration testing, performance testing, and test automation.
Comprehensive Test Suite¶
Unit Testing¶
import pytest
import geopandas as gpd
from shapely.geometry import Point, Polygon
import pandas as pd
from unittest.mock import Mock, patch
class TestGeospatialProcessor:
"""Test suite for geospatial processing functions"""
def setup_method(self):
"""Setup test data"""
self.test_points = gpd.GeoDataFrame({
'id': [1, 2, 3],
'geometry': [
Point(0, 0),
Point(1, 1),
Point(2, 2)
]
})
self.test_polygons = gpd.GeoDataFrame({
'id': [1, 2],
'geometry': [
Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]),
Polygon([(2, 2), (3, 2), (3, 3), (2, 3)])
]
})
def test_spatial_join(self):
"""Test spatial join functionality"""
result = spatial_join_optimized(self.test_points, self.test_polygons)
assert len(result) > 0
assert all('left_idx' in item for item in result)
assert all('right_idx' in item for item in result)
def test_buffer_analysis(self):
"""Test buffer analysis"""
point = Point(0, 0)
buffer = point.buffer(1.0)
assert buffer.area > 0
assert buffer.contains(point)
@patch('geopandas.read_file')
def test_load_geospatial_data(self, mock_read_file):
"""Test loading geospatial data with mocking"""
mock_read_file.return_value = self.test_points
result = load_geospatial_data('test.geojson')
assert len(result) == 3
mock_read_file.assert_called_once_with('test.geojson')
def test_crs_transformation(self):
"""Test coordinate reference system transformation"""
gdf = self.test_points.copy()
gdf.crs = 'EPSG:4326'
transformed = gdf.to_crs('EPSG:3857')
assert transformed.crs == 'EPSG:3857'
assert len(transformed) == len(gdf)
def test_spatial_index_creation(self):
"""Test spatial index creation"""
index = create_spatial_index(self.test_points)
assert index is not None
assert len(list(index.intersection((0, 0, 1, 1)))) > 0
class TestAPIIntegration:
"""Integration tests for API endpoints"""
def test_spatial_query_endpoint(self, client):
"""Test spatial query API endpoint"""
query_data = {
"geometry": {
"type": "Point",
"coordinates": [0, 0]
},
"buffer_distance": 1000
}
response = client.post("/spatial/query", json=query_data)
assert response.status_code == 200
data = response.json()
assert "results" in data
assert "total_count" in data
assert "processing_time" in data
def test_health_check_endpoint(self, client):
"""Test health check endpoint"""
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert "timestamp" in data
assert "version" in data
# Pytest fixtures
@pytest.fixture
def client():
"""Test client fixture"""
from fastapi.testclient import TestClient
from main import app
return TestClient(app)
@pytest.fixture
def sample_geospatial_data():
"""Sample geospatial data fixture"""
return gpd.GeoDataFrame({
'id': [1, 2, 3],
'name': ['Point A', 'Point B', 'Point C'],
'geometry': [
Point(0, 0),
Point(1, 1),
Point(2, 2)
]
})
Why: Unit tests ensure individual functions work correctly. Integration tests verify API endpoints and data flow. Fixtures provide reusable test data and reduce test setup complexity.
Test Data Management¶
import pytest
import tempfile
import os
from pathlib import Path
class TestDataManager:
"""Manages test data for geospatial testing"""
def __init__(self):
self.temp_dir = None
self.test_files = {}
def setup_test_data(self):
"""Setup test data files"""
self.temp_dir = tempfile.mkdtemp()
# Create test GeoJSON
test_geojson = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [0, 0]
},
"properties": {"id": 1, "name": "Test Point"}
}
]
}
geojson_path = os.path.join(self.temp_dir, "test.geojson")
with open(geojson_path, 'w') as f:
json.dump(test_geojson, f)
self.test_files['geojson'] = geojson_path
# Create test Parquet
test_gdf = gpd.GeoDataFrame({
'id': [1, 2, 3],
'geometry': [Point(0, 0), Point(1, 1), Point(2, 2)]
})
parquet_path = os.path.join(self.temp_dir, "test.parquet")
test_gdf.to_parquet(parquet_path)
self.test_files['parquet'] = parquet_path
def cleanup_test_data(self):
"""Cleanup test data"""
if self.temp_dir and os.path.exists(self.temp_dir):
import shutil
shutil.rmtree(self.temp_dir)
@pytest.fixture
def test_data_manager():
"""Test data manager fixture"""
manager = TestDataManager()
manager.setup_test_data()
yield manager
manager.cleanup_test_data()
def test_geospatial_data_loading(test_data_manager):
"""Test loading geospatial data from files"""
geojson_path = test_data_manager.test_files['geojson']
gdf = gpd.read_file(geojson_path)
assert len(gdf) == 1
assert gdf.geometry.iloc[0].geom_type == 'Point'
assert gdf.iloc[0]['name'] == 'Test Point'
Why: Test data management ensures consistent test environments. Temporary directories prevent test data pollution. Fixtures provide automatic cleanup.
Performance Testing¶
Load Testing¶
import asyncio
import aiohttp
import time
from concurrent.futures import ThreadPoolExecutor
import statistics
class LoadTester:
"""Load testing for geospatial APIs"""
def __init__(self, base_url: str):
self.base_url = base_url
self.results = []
async def single_request(self, session: aiohttp.ClientSession, endpoint: str, payload: dict = None):
"""Execute a single request"""
start_time = time.time()
try:
if payload:
async with session.post(f"{self.base_url}{endpoint}", json=payload) as response:
await response.text()
else:
async with session.get(f"{self.base_url}{endpoint}") as response:
await response.text()
end_time = time.time()
return {
'status': 'success',
'response_time': end_time - start_time,
'status_code': response.status
}
except Exception as e:
end_time = time.time()
return {
'status': 'error',
'response_time': end_time - start_time,
'error': str(e)
}
async def run_load_test(self, endpoint: str, concurrent_users: int, requests_per_user: int, payload: dict = None):
"""Run load test with specified parameters"""
async with aiohttp.ClientSession() as session:
tasks = []
for _ in range(concurrent_users):
for _ in range(requests_per_user):
task = self.single_request(session, endpoint, payload)
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
self.results.extend(results)
def analyze_results(self):
"""Analyze load test results"""
successful_requests = [r for r in self.results if r.get('status') == 'success']
failed_requests = [r for r in self.results if r.get('status') == 'error']
if not successful_requests:
return {'error': 'No successful requests'}
response_times = [r['response_time'] for r in successful_requests]
return {
'total_requests': len(self.results),
'successful_requests': len(successful_requests),
'failed_requests': len(failed_requests),
'success_rate': len(successful_requests) / len(self.results),
'avg_response_time': statistics.mean(response_times),
'median_response_time': statistics.median(response_times),
'p95_response_time': sorted(response_times)[int(len(response_times) * 0.95)],
'p99_response_time': sorted(response_times)[int(len(response_times) * 0.99)],
'min_response_time': min(response_times),
'max_response_time': max(response_times)
}
# Usage example
async def run_spatial_api_load_test():
"""Run load test on spatial API"""
tester = LoadTester("http://localhost:8000")
# Test spatial query endpoint
spatial_query_payload = {
"geometry": {
"type": "Point",
"coordinates": [0, 0]
},
"buffer_distance": 1000
}
await tester.run_load_test(
endpoint="/spatial/query",
concurrent_users=10,
requests_per_user=100,
payload=spatial_query_payload
)
results = tester.analyze_results()
print(f"Load test results: {results}")
# Run load test
asyncio.run(run_spatial_api_load_test())
Why: Load testing identifies performance bottlenecks and capacity limits. Concurrent testing simulates real-world usage patterns.
Stress Testing¶
import psutil
import time
from threading import Thread
import requests
class StressTester:
"""Stress testing for geospatial systems"""
def __init__(self, base_url: str):
self.base_url = base_url
self.monitoring = False
self.system_metrics = []
def monitor_system_resources(self):
"""Monitor system resources during stress test"""
while self.monitoring:
metrics = {
'timestamp': time.time(),
'cpu_percent': psutil.cpu_percent(),
'memory_percent': psutil.virtual_memory().percent,
'disk_io': psutil.disk_io_counters()._asdict(),
'network_io': psutil.net_io_counters()._asdict()
}
self.system_metrics.append(metrics)
time.sleep(1)
def run_stress_test(self, duration_minutes: int, requests_per_second: int):
"""Run stress test for specified duration"""
self.monitoring = True
# Start system monitoring
monitor_thread = Thread(target=self.monitor_system_resources)
monitor_thread.start()
start_time = time.time()
end_time = start_time + (duration_minutes * 60)
request_interval = 1.0 / requests_per_second
request_count = 0
while time.time() < end_time:
request_start = time.time()
try:
response = requests.get(f"{self.base_url}/health", timeout=5)
request_count += 1
if response.status_code != 200:
print(f"Request {request_count} failed with status {response.status_code}")
except Exception as e:
print(f"Request {request_count} failed with error: {e}")
# Maintain request rate
elapsed = time.time() - request_start
sleep_time = max(0, request_interval - elapsed)
time.sleep(sleep_time)
self.monitoring = False
monitor_thread.join()
return {
'total_requests': request_count,
'duration': time.time() - start_time,
'system_metrics': self.system_metrics
}
# Usage example
def run_stress_test():
"""Run stress test on geospatial API"""
tester = StressTester("http://localhost:8000")
results = tester.run_stress_test(
duration_minutes=10,
requests_per_second=50
)
print(f"Stress test completed: {results['total_requests']} requests in {results['duration']:.2f}s")
# Analyze system metrics
if results['system_metrics']:
cpu_usage = [m['cpu_percent'] for m in results['system_metrics']]
memory_usage = [m['memory_percent'] for m in results['system_metrics']]
print(f"Average CPU usage: {statistics.mean(cpu_usage):.2f}%")
print(f"Average memory usage: {statistics.mean(memory_usage):.2f}%")
print(f"Peak CPU usage: {max(cpu_usage):.2f}%")
print(f"Peak memory usage: {max(memory_usage):.2f}%")
run_stress_test()
Why: Stress testing identifies system limits and failure points. System monitoring provides insights into resource utilization patterns.
Test Automation¶
Continuous Integration Testing¶
# .github/workflows/test.yml
name: Test Suite
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgis/postgis:15-3.3
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_geospatial
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y gdal-bin libgdal-dev
- name: Install Python dependencies
run: |
pip install -r requirements.txt
pip install -r requirements-test.txt
- name: Run unit tests
run: |
pytest tests/unit/ -v --cov=src --cov-report=xml
- name: Run integration tests
run: |
pytest tests/integration/ -v
- name: Run performance tests
run: |
pytest tests/performance/ -v --durations=10
- name: Upload coverage reports
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
Why: CI/CD ensures consistent testing across environments. Service containers provide isolated test environments.
Test Data Generation¶
import geopandas as gpd
from shapely.geometry import Point, Polygon, LineString
import numpy as np
import random
class TestDataGenerator:
"""Generate test data for geospatial testing"""
def __init__(self, seed: int = 42):
random.seed(seed)
np.random.seed(seed)
def generate_random_points(self, count: int, bounds: tuple = (-180, -90, 180, 90)) -> gpd.GeoDataFrame:
"""Generate random points within bounds"""
minx, miny, maxx, maxy = bounds
points = []
for _ in range(count):
x = random.uniform(minx, maxx)
y = random.uniform(miny, maxy)
points.append(Point(x, y))
return gpd.GeoDataFrame({
'id': range(count),
'geometry': points,
'value': np.random.random(count)
})
def generate_random_polygons(self, count: int, bounds: tuple = (-180, -90, 180, 90)) -> gpd.GeoDataFrame:
"""Generate random polygons within bounds"""
minx, miny, maxx, maxy = bounds
polygons = []
for _ in range(count):
# Generate random polygon
center_x = random.uniform(minx, maxx)
center_y = random.uniform(miny, maxy)
size = random.uniform(0.1, 1.0)
coords = []
for _ in range(4): # Square polygon
x = center_x + random.uniform(-size, size)
y = center_y + random.uniform(-size, size)
coords.append((x, y))
# Close polygon
coords.append(coords[0])
polygons.append(Polygon(coords))
return gpd.GeoDataFrame({
'id': range(count),
'geometry': polygons,
'area': [p.area for p in polygons]
})
def generate_test_dataset(self, points_count: int = 1000, polygons_count: int = 100) -> dict:
"""Generate comprehensive test dataset"""
return {
'points': self.generate_random_points(points_count),
'polygons': self.generate_random_polygons(polygons_count),
'lines': self.generate_random_lines(50)
}
def generate_random_lines(self, count: int, bounds: tuple = (-180, -90, 180, 90)) -> gpd.GeoDataFrame:
"""Generate random lines within bounds"""
minx, miny, maxx, maxy = bounds
lines = []
for _ in range(count):
# Generate random line
start_x = random.uniform(minx, maxx)
start_y = random.uniform(miny, maxy)
end_x = random.uniform(minx, maxx)
end_y = random.uniform(miny, maxy)
lines.append(LineString([(start_x, start_y), (end_x, end_y)]))
return gpd.GeoDataFrame({
'id': range(count),
'geometry': lines,
'length': [l.length for l in lines]
})
# Usage in tests
@pytest.fixture
def test_data_generator():
"""Test data generator fixture"""
return TestDataGenerator(seed=42)
@pytest.fixture
def sample_test_data(test_data_generator):
"""Sample test data fixture"""
return test_data_generator.generate_test_dataset(
points_count=100,
polygons_count=10
)
def test_spatial_operations_with_generated_data(sample_test_data):
"""Test spatial operations with generated data"""
points = sample_test_data['points']
polygons = sample_test_data['polygons']
# Test spatial join
result = gpd.sjoin(points, polygons, how='inner', predicate='intersects')
assert len(result) > 0
assert 'index_right' in result.columns
Why: Automated test data generation ensures consistent test environments. Configurable data generation enables testing edge cases and boundary conditions.
Test Reporting and Metrics¶
Test Coverage Analysis¶
import coverage
import pytest
from pathlib import Path
class TestCoverageAnalyzer:
"""Analyze test coverage for geospatial code"""
def __init__(self, source_dir: str, test_dir: str):
self.source_dir = Path(source_dir)
self.test_dir = Path(test_dir)
self.coverage_data = {}
def run_coverage_analysis(self):
"""Run coverage analysis on test suite"""
cov = coverage.Coverage(source=[str(self.source_dir)])
cov.start()
# Run tests
pytest.main([str(self.test_dir), '-v'])
cov.stop()
cov.save()
# Generate coverage report
self.coverage_data = cov.get_data()
return {
'coverage_percent': cov.report(),
'missing_lines': self.get_missing_lines(),
'branch_coverage': self.get_branch_coverage()
}
def get_missing_lines(self):
"""Get lines not covered by tests"""
missing_lines = {}
for filename in self.coverage_data.measured_files():
if str(self.source_dir) in filename:
relative_path = Path(filename).relative_to(self.source_dir)
missing_lines[str(relative_path)] = self.coverage_data.lines_not_covered(filename)
return missing_lines
def get_branch_coverage(self):
"""Get branch coverage information"""
branch_data = {}
for filename in self.coverage_data.measured_files():
if str(self.source_dir) in filename:
relative_path = Path(filename).relative_to(self.source_dir)
branch_data[str(relative_path)] = {
'total_branches': len(self.coverage_data.branches(filename)),
'covered_branches': len(self.coverage_data.branches(filename)) - len(self.coverage_data.branches_not_covered(filename))
}
return branch_data
# Usage
analyzer = TestCoverageAnalyzer('src/', 'tests/')
coverage_results = analyzer.run_coverage_analysis()
print(f"Coverage: {coverage_results['coverage_percent']:.2f}%")
Why: Coverage analysis identifies untested code paths. Branch coverage ensures conditional logic is properly tested.
Test Performance Metrics¶
import time
import statistics
from typing import List, Dict, Any
class TestPerformanceMetrics:
"""Collect and analyze test performance metrics"""
def __init__(self):
self.test_results = []
self.performance_data = {}
def record_test_execution(self, test_name: str, execution_time: float, status: str, memory_usage: float = None):
"""Record test execution metrics"""
self.test_results.append({
'test_name': test_name,
'execution_time': execution_time,
'status': status,
'memory_usage': memory_usage,
'timestamp': time.time()
})
def analyze_performance(self) -> Dict[str, Any]:
"""Analyze test performance"""
if not self.test_results:
return {}
execution_times = [r['execution_time'] for r in self.test_results]
successful_tests = [r for r in self.test_results if r['status'] == 'passed']
failed_tests = [r for r in self.test_results if r['status'] == 'failed']
return {
'total_tests': len(self.test_results),
'successful_tests': len(successful_tests),
'failed_tests': len(failed_tests),
'success_rate': len(successful_tests) / len(self.test_results),
'avg_execution_time': statistics.mean(execution_times),
'median_execution_time': statistics.median(execution_times),
'slowest_tests': sorted(self.test_results, key=lambda x: x['execution_time'], reverse=True)[:5],
'fastest_tests': sorted(self.test_results, key=lambda x: x['execution_time'])[:5]
}
def identify_slow_tests(self, threshold_seconds: float = 5.0) -> List[Dict[str, Any]]:
"""Identify tests that exceed execution time threshold"""
return [r for r in self.test_results if r['execution_time'] > threshold_seconds]
def generate_performance_report(self) -> str:
"""Generate performance report"""
analysis = self.analyze_performance()
report = f"""
Test Performance Report
======================
Total Tests: {analysis['total_tests']}
Successful: {analysis['successful_tests']}
Failed: {analysis['failed_tests']}
Success Rate: {analysis['success_rate']:.2%}
Execution Time Statistics:
- Average: {analysis['avg_execution_time']:.3f}s
- Median: {analysis['median_execution_time']:.3f}s
Slowest Tests:
"""
for test in analysis['slowest_tests']:
report += f"- {test['test_name']}: {test['execution_time']:.3f}s\n"
return report
# Usage in tests
performance_metrics = TestPerformanceMetrics()
@pytest.fixture(autouse=True)
def record_test_performance(request):
"""Record test performance automatically"""
start_time = time.time()
start_memory = psutil.Process().memory_info().rss
yield
end_time = time.time()
end_memory = psutil.Process().memory_info().rss
performance_metrics.record_test_execution(
test_name=request.node.name,
execution_time=end_time - start_time,
status='passed' if request.node.rep_call.passed else 'failed',
memory_usage=end_memory - start_memory
)
Why: Performance metrics identify slow tests and resource usage patterns. Automated performance tracking enables continuous optimization.