15. Chapter 15: Testing and Quality Assurance#
15.1. Learning Objectives#
By the end of this chapter, you will understand:
Comprehensive testing strategies for Web GIS applications
Unit testing for geospatial functions and components
Integration testing for spatial APIs and database operations
End-to-end testing for complex mapping workflows
Visual testing and cross-browser compatibility for maps
Performance testing and load testing for spatial applications
Quality assurance processes and continuous testing
15.2. Testing Challenges in Web GIS#
Web GIS applications present unique testing challenges that extend beyond traditional web application testing. Geospatial functionality involves complex coordinate systems, spatial relationships, map rendering, and user interactions that require specialized testing approaches.
15.2.1. Understanding Geospatial Testing Complexity#
Coordinate System and Projection Testing: Geospatial applications work with multiple coordinate systems and projections, each requiring validation of transformation accuracy. A point’s coordinates may appear correct in one projection but be significantly inaccurate in another, making comprehensive coordinate validation essential across all supported projections and zoom levels.
Spatial Relationship Validation: Testing spatial relationships such as contains, intersects, overlaps, and distance calculations requires understanding geometric algorithms and their edge cases. Floating-point precision issues, coordinate boundary conditions, and geometric degeneracies can cause subtle bugs that only manifest under specific spatial conditions.
Map Rendering and Visual Consistency: Map rendering involves complex graphics operations that can vary across browsers, devices, and graphics hardware. Visual testing must account for rendering differences while ensuring maps remain functional and accessible across all target platforms.
Performance at Scale: Web GIS applications must handle datasets ranging from hundreds to millions of features while maintaining interactive performance. Testing must validate performance characteristics across different data sizes, user loads, and interaction patterns.
Real-world Data Challenges: Geospatial data often contains inconsistencies, invalid geometries, and edge cases that don’t appear in synthetic test data. Testing strategies must incorporate real-world data scenarios including malformed geometries, missing attributes, and boundary conditions.
15.2.2. Testing Strategy Framework#
Multi-Layer Testing Approach: Effective Web GIS testing requires coordination across multiple layers including spatial algorithms, data processing, API endpoints, frontend components, and end-to-end user workflows. Each layer has specific testing requirements and tools, but integration between layers is equally important.
Data-Driven Testing: Geospatial testing benefits significantly from data-driven approaches using both synthetic datasets with known properties and real-world datasets with documented characteristics. Test data should include edge cases, boundary conditions, and representative samples of production data.
Cross-Platform Validation: Map rendering and spatial calculations can vary across platforms, browsers, and devices. Testing strategies must validate functionality across the target platform matrix while identifying platform-specific optimizations and workarounds.
15.3. Unit Testing for Geospatial Functions#
15.3.1. Spatial Algorithm Testing#
// tests/unit/spatial/geometryUtils.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import {
calculateDistance,
pointInPolygon,
bufferGeometry,
simplifyLineString,
calculateArea,
intersectGeometries,
transformCoordinates
} from '../../../src/utils/geometryUtils';
describe('GeometryUtils', () => {
describe('calculateDistance', () => {
it('should calculate correct distance between two points', () => {
const point1 = { lng: -122.4194, lat: 37.7749 }; // San Francisco
const point2 = { lng: -74.0060, lat: 40.7128 }; // New York
const distance = calculateDistance(point1, point2);
// Expected distance ~2570 miles = ~4137 km
expect(distance).toBeCloseTo(4137000, -3); // Within 1km tolerance
});
it('should return 0 for identical points', () => {
const point = { lng: 0, lat: 0 };
expect(calculateDistance(point, point)).toBe(0);
});
it('should handle antimeridian crossing', () => {
const point1 = { lng: 179.5, lat: 0 };
const point2 = { lng: -179.5, lat: 0 };
const distance = calculateDistance(point1, point2);
// Should be approximately 111km (1 degree at equator)
expect(distance).toBeCloseTo(111000, -3);
});
it('should handle pole proximity correctly', () => {
const point1 = { lng: 0, lat: 89.9 };
const point2 = { lng: 180, lat: 89.9 };
const distance = calculateDistance(point1, point2);
// Points near pole should have small distance despite longitude difference
expect(distance).toBeLessThan(50000); // Less than 50km
});
it('should throw error for invalid coordinates', () => {
const validPoint = { lng: 0, lat: 0 };
const invalidLat = { lng: 0, lat: 95 }; // Invalid latitude
const invalidLng = { lng: 185, lat: 0 }; // Invalid longitude
expect(() => calculateDistance(validPoint, invalidLat)).toThrow();
expect(() => calculateDistance(invalidLng, validPoint)).toThrow();
});
});
describe('pointInPolygon', () => {
const squarePolygon = {
type: 'Polygon',
coordinates: [[
[0, 0], [0, 10], [10, 10], [10, 0], [0, 0]
]]
};
const complexPolygon = {
type: 'Polygon',
coordinates: [[
[0, 0], [0, 10], [5, 5], [10, 10], [10, 0], [0, 0]
]]
};
it('should correctly identify points inside simple polygon', () => {
expect(pointInPolygon([5, 5], squarePolygon)).toBe(true);
expect(pointInPolygon([1, 1], squarePolygon)).toBe(true);
expect(pointInPolygon([9, 9], squarePolygon)).toBe(true);
});
it('should correctly identify points outside polygon', () => {
expect(pointInPolygon([-1, 5], squarePolygon)).toBe(false);
expect(pointInPolygon([5, -1], squarePolygon)).toBe(false);
expect(pointInPolygon([15, 5], squarePolygon)).toBe(false);
expect(pointInPolygon([5, 15], squarePolygon)).toBe(false);
});
it('should handle edge cases for points on boundary', () => {
expect(pointInPolygon([0, 5], squarePolygon)).toBe(true); // On edge
expect(pointInPolygon([5, 0], squarePolygon)).toBe(true); // On edge
expect(pointInPolygon([0, 0], squarePolygon)).toBe(true); // On vertex
});
it('should handle complex polygon shapes', () => {
expect(pointInPolygon([2, 7], complexPolygon)).toBe(true);
expect(pointInPolygon([8, 7], complexPolygon)).toBe(true);
expect(pointInPolygon([5, 8], complexPolygon)).toBe(false); // In the "notch"
});
it('should handle polygon with holes', () => {
const polygonWithHole = {
type: 'Polygon',
coordinates: [
// Outer ring
[[0, 0], [0, 10], [10, 10], [10, 0], [0, 0]],
// Inner ring (hole)
[[3, 3], [3, 7], [7, 7], [7, 3], [3, 3]]
]
};
expect(pointInPolygon([1, 1], polygonWithHole)).toBe(true); // Outside hole
expect(pointInPolygon([5, 5], polygonWithHole)).toBe(false); // Inside hole
expect(pointInPolygon([8, 8], polygonWithHole)).toBe(true); // Outside hole
});
});
describe('bufferGeometry', () => {
const point = {
type: 'Point',
coordinates: [0, 0]
};
const lineString = {
type: 'LineString',
coordinates: [[0, 0], [10, 0]]
};
it('should create circular buffer around point', () => {
const buffered = bufferGeometry(point, 1000); // 1km buffer
expect(buffered.type).toBe('Polygon');
expect(buffered.coordinates[0]).toHaveLength(65); // Default circle resolution
// Check that buffer is approximately circular
const center = [0, 0];
buffered.coordinates[0].slice(0, -1).forEach(coord => {
const distance = calculateDistance(
{ lng: center[0], lat: center[1] },
{ lng: coord[0], lat: coord[1] }
);
expect(distance).toBeCloseTo(1000, 0); // Within 1m tolerance
});
});
it('should create buffer around line', () => {
const buffered = bufferGeometry(lineString, 500); // 500m buffer
expect(buffered.type).toBe('Polygon');
expect(buffered.coordinates[0].length).toBeGreaterThan(10);
// Buffer should contain original line points
expect(pointInPolygon([0, 0], buffered)).toBe(true);
expect(pointInPolygon([10, 0], buffered)).toBe(true);
expect(pointInPolygon([5, 0], buffered)).toBe(true);
});
it('should handle different buffer distances', () => {
const buffer1km = bufferGeometry(point, 1000);
const buffer2km = bufferGeometry(point, 2000);
// Larger buffer should contain smaller buffer
const sample1kmPoint = buffer1km.coordinates[0][0];
expect(pointInPolygon(sample1kmPoint, buffer2km)).toBe(true);
});
it('should throw error for invalid buffer distance', () => {
expect(() => bufferGeometry(point, -100)).toThrow();
expect(() => bufferGeometry(point, 0)).toThrow();
});
});
describe('coordinate transformation', () => {
it('should transform between WGS84 and Web Mercator', () => {
const wgs84Point = { lng: -122.4194, lat: 37.7749 };
const webMercator = transformCoordinates(wgs84Point, 'EPSG:4326', 'EPSG:3857');
expect(webMercator.x).toBeCloseTo(-13627812, 0);
expect(webMercator.y).toBeCloseTo(4544699, 0);
// Transform back should be close to original
const backToWgs84 = transformCoordinates(webMercator, 'EPSG:3857', 'EPSG:4326');
expect(backToWgs84.lng).toBeCloseTo(wgs84Point.lng, 6);
expect(backToWgs84.lat).toBeCloseTo(wgs84Point.lat, 6);
});
it('should handle coordinate boundary conditions', () => {
// Test coordinates at projection boundaries
const boundaries = [
{ lng: -180, lat: 0 },
{ lng: 180, lat: 0 },
{ lng: 0, lat: 85.0511 }, // Web Mercator max latitude
{ lng: 0, lat: -85.0511 } // Web Mercator min latitude
];
boundaries.forEach(point => {
expect(() => {
transformCoordinates(point, 'EPSG:4326', 'EPSG:3857');
}).not.toThrow();
});
});
it('should throw error for invalid projections', () => {
const point = { lng: 0, lat: 0 };
expect(() => {
transformCoordinates(point, 'INVALID:1234', 'EPSG:4326');
}).toThrow();
});
});
describe('geometry simplification', () => {
const complexLine = {
type: 'LineString',
coordinates: [
[0, 0], [1, 0.1], [2, 0], [3, 0.1], [4, 0], [5, 0]
]
};
it('should reduce vertex count with appropriate tolerance', () => {
const simplified = simplifyLineString(complexLine, 0.15);
expect(simplified.coordinates.length).toBeLessThan(complexLine.coordinates.length);
expect(simplified.coordinates.length).toBeGreaterThanOrEqual(2); // Minimum for line
});
it('should preserve line endpoints', () => {
const simplified = simplifyLineString(complexLine, 0.15);
expect(simplified.coordinates[0]).toEqual(complexLine.coordinates[0]);
expect(simplified.coordinates[simplified.coordinates.length - 1])
.toEqual(complexLine.coordinates[complexLine.coordinates.length - 1]);
});
it('should handle single point and two point lines', () => {
const singlePoint = { type: 'LineString', coordinates: [[0, 0]] };
const twoPoints = { type: 'LineString', coordinates: [[0, 0], [1, 1]] };
expect(simplifyLineString(singlePoint, 0.1).coordinates).toEqual(singlePoint.coordinates);
expect(simplifyLineString(twoPoints, 0.1).coordinates).toEqual(twoPoints.coordinates);
});
it('should adapt simplification to tolerance', () => {
const lowTolerance = simplifyLineString(complexLine, 0.05);
const highTolerance = simplifyLineString(complexLine, 0.2);
expect(highTolerance.coordinates.length).toBeLessThanOrEqual(lowTolerance.coordinates.length);
});
});
});
// tests/unit/spatial/spatialQueries.test.ts
describe('SpatialQueries', () => {
let mockDatabase: any;
beforeEach(() => {
mockDatabase = {
query: jest.fn(),
transaction: jest.fn()
};
});
describe('spatial bounding box queries', () => {
it('should generate correct PostGIS bounding box query', async () => {
const spatialService = new SpatialQueryService(mockDatabase);
const bounds = [-122.5, 37.7, -122.3, 37.8]; // SF area
mockDatabase.query.mockResolvedValue({
rows: [
{ id: 1, geom: 'POINT(-122.4 37.75)', properties: { name: 'Test Point' } }
]
});
await spatialService.getFeaturesInBounds(bounds);
expect(mockDatabase.query).toHaveBeenCalledWith(
expect.stringContaining('ST_Intersects'),
expect.arrayContaining(bounds)
);
});
it('should handle empty results gracefully', async () => {
const spatialService = new SpatialQueryService(mockDatabase);
mockDatabase.query.mockResolvedValue({ rows: [] });
const result = await spatialService.getFeaturesInBounds([-1, -1, 1, 1]);
expect(result).toEqual([]);
});
it('should validate bounding box parameters', async () => {
const spatialService = new SpatialQueryService(mockDatabase);
const invalidBounds = [
[-200, 37.7, -122.3, 37.8], // Invalid longitude
[-122.5, 100, -122.3, 37.8], // Invalid latitude
[-122.3, 37.7, -122.5, 37.8], // Swapped bounds
[-122.5, 37.8, -122.3, 37.7] // Swapped bounds
];
for (const bounds of invalidBounds) {
await expect(spatialService.getFeaturesInBounds(bounds)).rejects.toThrow();
}
});
});
describe('nearest neighbor queries', () => {
it('should find nearest features correctly', async () => {
const spatialService = new SpatialQueryService(mockDatabase);
const center = { lng: -122.4, lat: 37.75 };
mockDatabase.query.mockResolvedValue({
rows: [
{ id: 1, distance: 100, geom: 'POINT(-122.401 37.751)' },
{ id: 2, distance: 200, geom: 'POINT(-122.402 37.752)' }
]
});
const results = await spatialService.findNearestFeatures(center, 1000, 5);
expect(mockDatabase.query).toHaveBeenCalledWith(
expect.stringContaining('ORDER BY geom <->'),
expect.arrayContaining([center.lng, center.lat])
);
expect(results).toHaveLength(2);
expect(results[0].distance).toBeLessThanOrEqual(results[1].distance);
});
it('should respect distance limits', async () => {
const spatialService = new SpatialQueryService(mockDatabase);
mockDatabase.query.mockResolvedValue({
rows: [
{ id: 1, distance: 50 },
{ id: 2, distance: 150 },
{ id: 3, distance: 300 }
]
});
const results = await spatialService.findNearestFeatures(
{ lng: 0, lat: 0 },
200, // Max distance
10 // Max results
);
// Should only include features within distance limit
results.forEach(feature => {
expect(feature.distance).toBeLessThanOrEqual(200);
});
});
});
});
15.3.2. Component Testing for Map Features#
// tests/unit/components/MapComponent.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { vi } from 'vitest';
import { MapComponent } from '../../../src/components/MapComponent';
import { MapProvider } from '../../../src/contexts/MapContext';
// Mock MapLibre GL JS
vi.mock('maplibre-gl', () => ({
Map: vi.fn().mockImplementation(() => ({
on: vi.fn(),
off: vi.fn(),
remove: vi.fn(),
getSource: vi.fn(),
addSource: vi.fn(),
removeSource: vi.fn(),
addLayer: vi.fn(),
removeLayer: vi.fn(),
setLayoutProperty: vi.fn(),
setPaintProperty: vi.fn(),
queryRenderedFeatures: vi.fn().mockReturnValue([]),
getBounds: vi.fn().mockReturnValue({
getWest: () => -122.5,
getSouth: () => 37.7,
getEast: () => -122.3,
getNorth: () => 37.8
}),
getZoom: vi.fn().mockReturnValue(10),
getCenter: vi.fn().mockReturnValue({ lng: -122.4, lat: 37.75 }),
flyTo: vi.fn(),
easeTo: vi.fn(),
fitBounds: vi.fn()
})),
NavigationControl: vi.fn(),
ScaleControl: vi.fn(),
GeolocateControl: vi.fn()
}));
describe('MapComponent', () => {
const defaultProps = {
initialViewport: {
longitude: -122.4,
latitude: 37.75,
zoom: 10
},
onViewportChange: vi.fn(),
style: { width: '100%', height: '400px' }
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should render map container', () => {
render(
<MapProvider>
<MapComponent {...defaultProps} />
</MapProvider>
);
const mapContainer = screen.getByTestId('map-container');
expect(mapContainer).toBeInTheDocument();
expect(mapContainer).toHaveStyle({
width: '100%',
height: '400px'
});
});
it('should initialize map with correct viewport', async () => {
const { Map } = await import('maplibre-gl');
render(
<MapProvider>
<MapComponent {...defaultProps} />
</MapProvider>
);
await waitFor(() => {
expect(Map).toHaveBeenCalledWith(
expect.objectContaining({
center: [defaultProps.initialViewport.longitude, defaultProps.initialViewport.latitude],
zoom: defaultProps.initialViewport.zoom
})
);
});
});
it('should handle viewport changes', async () => {
const onViewportChange = vi.fn();
const { Map } = await import('maplibre-gl');
let moveHandler: Function = () => {};
// Mock map instance to capture event handlers
const mockMapInstance = {
on: vi.fn().mockImplementation((event: string, handler: Function) => {
if (event === 'move') {
moveHandler = handler;
}
}),
off: vi.fn(),
remove: vi.fn(),
getCenter: vi.fn().mockReturnValue({ lng: -122.5, lat: 37.8 }),
getZoom: vi.fn().mockReturnValue(11),
getBounds: vi.fn().mockReturnValue({
getWest: () => -122.6,
getSouth: () => 37.7,
getEast: () => -122.4,
getNorth: () => 37.9
})
};
(Map as any).mockImplementation(() => mockMapInstance);
render(
<MapProvider>
<MapComponent {...defaultProps} onViewportChange={onViewportChange} />
</MapProvider>
);
// Simulate map move event
await waitFor(() => {
moveHandler();
});
expect(onViewportChange).toHaveBeenCalledWith({
longitude: -122.5,
latitude: 37.8,
zoom: 11,
bounds: {
west: -122.6,
south: 37.7,
east: -122.4,
north: 37.9
}
});
});
it('should add and remove layers correctly', async () => {
const mockMapInstance = {
on: vi.fn(),
off: vi.fn(),
remove: vi.fn(),
getSource: vi.fn().mockReturnValue(null),
addSource: vi.fn(),
removeSource: vi.fn(),
addLayer: vi.fn(),
removeLayer: vi.fn(),
queryRenderedFeatures: vi.fn().mockReturnValue([])
};
const { Map } = await import('maplibre-gl');
(Map as any).mockImplementation(() => mockMapInstance);
const layers = [
{
id: 'test-layer',
type: 'circle',
source: {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: []
}
}
}
];
const { rerender } = render(
<MapProvider>
<MapComponent {...defaultProps} layers={layers} />
</MapProvider>
);
await waitFor(() => {
expect(mockMapInstance.addSource).toHaveBeenCalledWith(
'test-layer-source',
layers[0].source
);
expect(mockMapInstance.addLayer).toHaveBeenCalledWith(
expect.objectContaining({
id: 'test-layer',
source: 'test-layer-source'
})
);
});
// Test layer removal
rerender(
<MapProvider>
<MapComponent {...defaultProps} layers={[]} />
</MapProvider>
);
await waitFor(() => {
expect(mockMapInstance.removeLayer).toHaveBeenCalledWith('test-layer');
expect(mockMapInstance.removeSource).toHaveBeenCalledWith('test-layer-source');
});
});
it('should handle click events and feature selection', async () => {
const onFeatureClick = vi.fn();
const mockFeatures = [
{
id: 1,
properties: { name: 'Test Feature' },
geometry: { type: 'Point', coordinates: [-122.4, 37.75] }
}
];
const mockMapInstance = {
on: vi.fn(),
off: vi.fn(),
remove: vi.fn(),
queryRenderedFeatures: vi.fn().mockReturnValue(mockFeatures),
getCanvas: vi.fn().mockReturnValue({
style: { cursor: 'pointer' }
})
};
const { Map } = await import('maplibre-gl');
(Map as any).mockImplementation(() => mockMapInstance);
render(
<MapProvider>
<MapComponent {...defaultProps} onFeatureClick={onFeatureClick} />
</MapProvider>
);
// Find and trigger the click handler
const clickHandler = mockMapInstance.on.mock.calls.find(
call => call[0] === 'click'
)?.[1];
expect(clickHandler).toBeDefined();
// Simulate click event
await waitFor(() => {
clickHandler?.({
point: { x: 100, y: 100 },
lngLat: { lng: -122.4, lat: 37.75 }
});
});
expect(mockMapInstance.queryRenderedFeatures).toHaveBeenCalledWith(
{ x: 100, y: 100 }
);
expect(onFeatureClick).toHaveBeenCalledWith(mockFeatures[0], {
lng: -122.4,
lat: 37.75
});
});
it('should cleanup resources on unmount', async () => {
const mockMapInstance = {
on: vi.fn(),
off: vi.fn(),
remove: vi.fn()
};
const { Map } = await import('maplibre-gl');
(Map as any).mockImplementation(() => mockMapInstance);
const { unmount } = render(
<MapProvider>
<MapComponent {...defaultProps} />
</MapProvider>
);
unmount();
expect(mockMapInstance.remove).toHaveBeenCalled();
});
it('should handle loading states', async () => {
const mockMapInstance = {
on: vi.fn(),
off: vi.fn(),
remove: vi.fn(),
loaded: vi.fn().mockReturnValue(false)
};
const { Map } = await import('maplibre-gl');
(Map as any).mockImplementation(() => mockMapInstance);
render(
<MapProvider>
<MapComponent {...defaultProps} showLoadingIndicator />
</MapProvider>
);
// Should show loading indicator initially
expect(screen.getByTestId('map-loading')).toBeInTheDocument();
// Simulate map load
const loadHandler = mockMapInstance.on.mock.calls.find(
call => call[0] === 'load'
)?.[1];
mockMapInstance.loaded.mockReturnValue(true);
loadHandler?.();
await waitFor(() => {
expect(screen.queryByTestId('map-loading')).not.toBeInTheDocument();
});
});
it('should handle accessibility requirements', () => {
render(
<MapProvider>
<MapComponent {...defaultProps} />
</MapProvider>
);
const mapContainer = screen.getByTestId('map-container');
expect(mapContainer).toHaveAttribute('role', 'application');
expect(mapContainer).toHaveAttribute('aria-label', 'Interactive map');
expect(mapContainer).toHaveAttribute('tabIndex', '0');
});
it('should handle error states gracefully', async () => {
const { Map } = await import('maplibre-gl');
// Mock map initialization failure
(Map as any).mockImplementation(() => {
throw new Error('Failed to initialize map');
});
const onError = vi.fn();
render(
<MapProvider>
<MapComponent {...defaultProps} onError={onError} />
</MapProvider>
);
await waitFor(() => {
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Failed to initialize map'
})
);
});
expect(screen.getByTestId('map-error')).toBeInTheDocument();
});
});
// tests/unit/components/LayerControl.test.tsx
describe('LayerControl', () => {
const mockLayers = [
{
id: 'layer1',
name: 'Roads',
type: 'line',
visible: true,
opacity: 1
},
{
id: 'layer2',
name: 'Buildings',
type: 'fill',
visible: false,
opacity: 0.8
}
];
it('should render layer list correctly', () => {
const onLayerToggle = vi.fn();
const onOpacityChange = vi.fn();
render(
<LayerControl
layers={mockLayers}
onLayerToggle={onLayerToggle}
onOpacityChange={onOpacityChange}
/>
);
expect(screen.getByText('Roads')).toBeInTheDocument();
expect(screen.getByText('Buildings')).toBeInTheDocument();
const roadsCheckbox = screen.getByLabelText('Roads');
const buildingsCheckbox = screen.getByLabelText('Buildings');
expect(roadsCheckbox).toBeChecked();
expect(buildingsCheckbox).not.toBeChecked();
});
it('should handle layer visibility toggle', () => {
const onLayerToggle = vi.fn();
render(
<LayerControl
layers={mockLayers}
onLayerToggle={onLayerToggle}
onOpacityChange={vi.fn()}
/>
);
const buildingsCheckbox = screen.getByLabelText('Buildings');
fireEvent.click(buildingsCheckbox);
expect(onLayerToggle).toHaveBeenCalledWith('layer2', true);
});
it('should handle opacity changes', () => {
const onOpacityChange = vi.fn();
render(
<LayerControl
layers={mockLayers}
onLayerToggle={vi.fn()}
onOpacityChange={onOpacityChange}
/>
);
const opacitySlider = screen.getByDisplayValue('100'); // Roads opacity slider
fireEvent.change(opacitySlider, { target: { value: '50' } });
expect(onOpacityChange).toHaveBeenCalledWith('layer1', 0.5);
});
});
15.4. Integration Testing for Spatial APIs#
15.4.1. API Endpoint Testing#
// tests/integration/api/spatialEndpoints.test.ts
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { createTestApp } from '../../utils/testApp';
import { seedTestData, cleanupTestData } from '../../utils/testData';
import { createTestUser, getAuthToken } from '../../utils/testAuth';
describe('Spatial API Endpoints', () => {
let app: any;
let authToken: string;
let testUserId: string;
beforeAll(async () => {
app = await createTestApp();
const testUser = await createTestUser();
testUserId = testUser.id;
authToken = await getAuthToken(testUser);
});
afterAll(async () => {
await cleanupTestData();
});
beforeEach(async () => {
await seedTestData();
});
describe('GET /api/features', () => {
it('should return features within bounding box', async () => {
const bounds = [-122.5, 37.7, -122.3, 37.8]; // San Francisco area
const response = await request(app)
.get('/api/features')
.query({
bbox: bounds.join(','),
limit: 100
})
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body).toHaveProperty('type', 'FeatureCollection');
expect(response.body).toHaveProperty('features');
expect(Array.isArray(response.body.features)).toBe(true);
// Verify all features are within bounding box
response.body.features.forEach((feature: any) => {
expect(feature).toHaveProperty('geometry');
expect(feature).toHaveProperty('properties');
if (feature.geometry.type === 'Point') {
const [lng, lat] = feature.geometry.coordinates;
expect(lng).toBeGreaterThanOrEqual(bounds[0]);
expect(lng).toBeLessThanOrEqual(bounds[2]);
expect(lat).toBeGreaterThanOrEqual(bounds[1]);
expect(lat).toBeLessThanOrEqual(bounds[3]);
}
});
});
it('should handle invalid bounding box parameters', async () => {
const invalidBounds = [
'invalid,bbox,format', // Invalid format
'-200,37.7,-122.3,37.8', // Invalid longitude
'-122.5,100,-122.3,37.8', // Invalid latitude
'-122.3,37.7,-122.5,37.8' // Swapped bounds
];
for (const bbox of invalidBounds) {
await request(app)
.get('/api/features')
.query({ bbox })
.set('Authorization', `Bearer ${authToken}`)
.expect(400);
}
});
it('should respect limit parameter', async () => {
const response = await request(app)
.get('/api/features')
.query({
bbox: '-180,-90,180,90', // World bounds
limit: 5
})
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.features.length).toBeLessThanOrEqual(5);
});
it('should filter by feature type', async () => {
const response = await request(app)
.get('/api/features')
.query({
bbox: '-122.5,37.7,-122.3,37.8',
type: 'restaurant'
})
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
response.body.features.forEach((feature: any) => {
expect(feature.properties.type).toBe('restaurant');
});
});
it('should return empty collection for area with no features', async () => {
const response = await request(app)
.get('/api/features')
.query({
bbox: '0,0,0.001,0.001' // Small area in Atlantic Ocean
})
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.features).toEqual([]);
});
});
describe('POST /api/features', () => {
const validFeature = {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [-122.4, 37.75]
},
properties: {
name: 'Test Location',
type: 'test',
description: 'A test feature'
}
};
it('should create new feature with valid data', async () => {
const response = await request(app)
.post('/api/features')
.send(validFeature)
.set('Authorization', `Bearer ${authToken}`)
.expect(201);
expect(response.body).toHaveProperty('id');
expect(response.body.geometry).toEqual(validFeature.geometry);
expect(response.body.properties).toMatchObject(validFeature.properties);
expect(response.body.properties).toHaveProperty('createdAt');
expect(response.body.properties).toHaveProperty('updatedAt');
});
it('should validate geometry format', async () => {
const invalidGeometries = [
{ type: 'Point' }, // Missing coordinates
{ type: 'Point', coordinates: [100] }, // Invalid coordinates
{ type: 'Point', coordinates: [200, 100] }, // Out of bounds
{ type: 'InvalidType', coordinates: [0, 0] } // Invalid type
];
for (const geometry of invalidGeometries) {
await request(app)
.post('/api/features')
.send({ ...validFeature, geometry })
.set('Authorization', `Bearer ${authToken}`)
.expect(400);
}
});
it('should validate required properties', async () => {
const featureWithoutName = {
...validFeature,
properties: { type: 'test' }
};
await request(app)
.post('/api/features')
.send(featureWithoutName)
.set('Authorization', `Bearer ${authToken}`)
.expect(400);
});
it('should handle complex geometries', async () => {
const polygonFeature = {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [[
[-122.5, 37.7],
[-122.3, 37.7],
[-122.3, 37.8],
[-122.5, 37.8],
[-122.5, 37.7]
]]
},
properties: {
name: 'Test Area',
type: 'area'
}
};
const response = await request(app)
.post('/api/features')
.send(polygonFeature)
.set('Authorization', `Bearer ${authToken}`)
.expect(201);
expect(response.body.geometry.type).toBe('Polygon');
expect(response.body.geometry.coordinates[0]).toHaveLength(5); // Closed ring
});
});
describe('GET /api/features/nearby', () => {
it('should find features near a point', async () => {
const response = await request(app)
.get('/api/features/nearby')
.query({
lng: -122.4,
lat: 37.75,
radius: 1000, // 1km
limit: 10
})
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body).toHaveProperty('features');
// Verify distance calculation
response.body.features.forEach((feature: any) => {
expect(feature.properties).toHaveProperty('distance');
expect(feature.properties.distance).toBeLessThanOrEqual(1000);
});
// Verify ordering by distance
for (let i = 1; i < response.body.features.length; i++) {
expect(response.body.features[i].properties.distance)
.toBeGreaterThanOrEqual(response.body.features[i - 1].properties.distance);
}
});
it('should handle invalid coordinates', async () => {
const invalidCoords = [
{ lng: 200, lat: 37.75 }, // Invalid longitude
{ lng: -122.4, lat: 100 }, // Invalid latitude
{ lng: 'invalid', lat: 37.75 }, // Non-numeric
];
for (const coords of invalidCoords) {
await request(app)
.get('/api/features/nearby')
.query({ ...coords, radius: 1000 })
.set('Authorization', `Bearer ${authToken}`)
.expect(400);
}
});
it('should validate radius parameter', async () => {
await request(app)
.get('/api/features/nearby')
.query({
lng: -122.4,
lat: 37.75,
radius: -100 // Negative radius
})
.set('Authorization', `Bearer ${authToken}`)
.expect(400);
await request(app)
.get('/api/features/nearby')
.query({
lng: -122.4,
lat: 37.75,
radius: 100000 // Too large radius
})
.set('Authorization', `Bearer ${authToken}`)
.expect(400);
});
});
describe('GET /api/features/:id', () => {
let testFeatureId: string;
beforeEach(async () => {
const createResponse = await request(app)
.post('/api/features')
.send({
type: 'Feature',
geometry: { type: 'Point', coordinates: [-122.4, 37.75] },
properties: { name: 'Test Feature', type: 'test' }
})
.set('Authorization', `Bearer ${authToken}`);
testFeatureId = createResponse.body.id;
});
it('should return feature by ID', async () => {
const response = await request(app)
.get(`/api/features/${testFeatureId}`)
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.id).toBe(testFeatureId);
expect(response.body.properties.name).toBe('Test Feature');
});
it('should return 404 for non-existent feature', async () => {
await request(app)
.get('/api/features/non-existent-id')
.set('Authorization', `Bearer ${authToken}`)
.expect(404);
});
});
describe('PUT /api/features/:id', () => {
let testFeatureId: string;
beforeEach(async () => {
const createResponse = await request(app)
.post('/api/features')
.send({
type: 'Feature',
geometry: { type: 'Point', coordinates: [-122.4, 37.75] },
properties: { name: 'Original Name', type: 'test' }
})
.set('Authorization', `Bearer ${authToken}`);
testFeatureId = createResponse.body.id;
});
it('should update feature properties', async () => {
const updatedFeature = {
type: 'Feature',
geometry: { type: 'Point', coordinates: [-122.4, 37.75] },
properties: { name: 'Updated Name', type: 'test', description: 'Updated' }
};
const response = await request(app)
.put(`/api/features/${testFeatureId}`)
.send(updatedFeature)
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.properties.name).toBe('Updated Name');
expect(response.body.properties.description).toBe('Updated');
expect(response.body.properties).toHaveProperty('updatedAt');
});
it('should update feature geometry', async () => {
const updatedFeature = {
type: 'Feature',
geometry: { type: 'Point', coordinates: [-122.5, 37.8] },
properties: { name: 'Original Name', type: 'test' }
};
const response = await request(app)
.put(`/api/features/${testFeatureId}`)
.send(updatedFeature)
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.geometry.coordinates).toEqual([-122.5, 37.8]);
});
it('should validate updated data', async () => {
const invalidUpdate = {
type: 'Feature',
geometry: { type: 'Point', coordinates: [200, 100] }, // Invalid coordinates
properties: { name: 'Updated', type: 'test' }
};
await request(app)
.put(`/api/features/${testFeatureId}`)
.send(invalidUpdate)
.set('Authorization', `Bearer ${authToken}`)
.expect(400);
});
});
describe('DELETE /api/features/:id', () => {
let testFeatureId: string;
beforeEach(async () => {
const createResponse = await request(app)
.post('/api/features')
.send({
type: 'Feature',
geometry: { type: 'Point', coordinates: [-122.4, 37.75] },
properties: { name: 'To Delete', type: 'test' }
})
.set('Authorization', `Bearer ${authToken}`);
testFeatureId = createResponse.body.id;
});
it('should delete feature', async () => {
await request(app)
.delete(`/api/features/${testFeatureId}`)
.set('Authorization', `Bearer ${authToken}`)
.expect(204);
// Verify feature is deleted
await request(app)
.get(`/api/features/${testFeatureId}`)
.set('Authorization', `Bearer ${authToken}`)
.expect(404);
});
it('should return 404 for non-existent feature', async () => {
await request(app)
.delete('/api/features/non-existent-id')
.set('Authorization', `Bearer ${authToken}`)
.expect(404);
});
});
describe('Authentication and Authorization', () => {
it('should require authentication for all endpoints', async () => {
const endpoints = [
{ method: 'get', path: '/api/features' },
{ method: 'post', path: '/api/features' },
{ method: 'get', path: '/api/features/test-id' },
{ method: 'put', path: '/api/features/test-id' },
{ method: 'delete', path: '/api/features/test-id' }
];
for (const endpoint of endpoints) {
await request(app)[endpoint.method](endpoint.path)
.expect(401);
}
});
it('should handle invalid tokens', async () => {
await request(app)
.get('/api/features')
.set('Authorization', 'Bearer invalid-token')
.expect(401);
});
it('should handle expired tokens', async () => {
const expiredToken = 'expired.jwt.token'; // Mock expired token
await request(app)
.get('/api/features')
.set('Authorization', `Bearer ${expiredToken}`)
.expect(401);
});
});
describe('Rate Limiting', () => {
it('should enforce rate limits', async () => {
// Make multiple rapid requests
const requests = Array(101).fill(null).map(() =>
request(app)
.get('/api/features')
.query({ bbox: '-1,-1,1,1' })
.set('Authorization', `Bearer ${authToken}`)
);
const responses = await Promise.all(requests);
// Some requests should be rate limited
const rateLimitedResponses = responses.filter(r => r.status === 429);
expect(rateLimitedResponses.length).toBeGreaterThan(0);
});
});
describe('Error Handling', () => {
it('should handle database connection errors gracefully', async () => {
// Mock database connection failure
// This would require mocking the database service
const response = await request(app)
.get('/api/features')
.query({ bbox: '-1,-1,1,1' })
.set('Authorization', `Bearer ${authToken}`);
if (response.status === 500) {
expect(response.body).toHaveProperty('error');
expect(response.body.error).not.toContain('database'); // No sensitive info
}
});
it('should handle malformed JSON in request body', async () => {
await request(app)
.post('/api/features')
.set('Content-Type', 'application/json')
.set('Authorization', `Bearer ${authToken}`)
.send('invalid json')
.expect(400);
});
});
});
15.4.2. Database Integration Testing#
// tests/integration/database/spatialQueries.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { Pool } from 'pg';
import { SpatialQueryService } from '../../../src/services/spatialQueryService';
import { createTestDatabase, dropTestDatabase } from '../../utils/testDatabase';
describe('Database Spatial Queries Integration', () => {
let db: Pool;
let spatialService: SpatialQueryService;
beforeAll(async () => {
db = await createTestDatabase();
spatialService = new SpatialQueryService(db);
// Create test tables with PostGIS extensions
await setupSpatialTables(db);
});
afterAll(async () => {
await dropTestDatabase(db);
});
beforeEach(async () => {
await seedSpatialTestData(db);
});
describe('Spatial Indexing Performance', () => {
it('should use spatial index for bounding box queries', async () => {
const bounds = [-122.5, 37.7, -122.3, 37.8];
// Execute query with EXPLAIN ANALYZE
const explainResult = await db.query(`
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, ST_AsGeoJSON(geom) as geometry, properties
FROM features
WHERE geom && ST_MakeEnvelope($1, $2, $3, $4, 4326)
`, bounds);
const queryPlan = explainResult.rows.map(row => row['QUERY PLAN']).join('\n');
// Should use the spatial index
expect(queryPlan).toContain('Index Scan');
expect(queryPlan).toContain('idx_features_geom');
// Should not be a sequential scan for large datasets
expect(queryPlan).not.toContain('Seq Scan on features');
});
it('should efficiently handle nearest neighbor queries', async () => {
const point = { lng: -122.4, lat: 37.75 };
const maxDistance = 1000;
const limit = 10;
const startTime = process.hrtime.bigint();
const results = await spatialService.findNearestFeatures(
point,
maxDistance,
limit
);
const endTime = process.hrtime.bigint();
const duration = Number(endTime - startTime) / 1_000_000; // Convert to milliseconds
expect(results).toHaveLength(limit);
expect(duration).toBeLessThan(100); // Should complete in under 100ms
// Verify results are ordered by distance
for (let i = 1; i < results.length; i++) {
expect(results[i].distance).toBeGreaterThanOrEqual(results[i - 1].distance);
}
});
it('should handle large dataset queries efficiently', async () => {
// Insert a large number of test features
await insertLargeDataset(db, 10000);
const bounds = [-180, -90, 180, 90]; // World bounds
const startTime = process.hrtime.bigint();
const results = await spatialService.getFeaturesInBounds(bounds, { limit: 100 });
const endTime = process.hrtime.bigint();
const duration = Number(endTime - startTime) / 1_000_000;
expect(results).toHaveLength(100);
expect(duration).toBeLessThan(500); // Should complete in under 500ms
});
});
describe('Spatial Accuracy', () => {
it('should accurately calculate distances', async () => {
// Test known distances between major cities
const sanFrancisco = { lng: -122.4194, lat: 37.7749 };
const newYork = { lng: -74.0060, lat: 40.7128 };
const distance = await spatialService.calculateDistance(sanFrancisco, newYork);
// Expected distance approximately 4135 km
expect(distance).toBeCloseTo(4135000, -3); // Within 1km tolerance
});
it('should correctly identify point-in-polygon relationships', async () => {
// Create a test polygon (rectangle around San Francisco)
const polygon = {
type: 'Polygon',
coordinates: [[
[-122.5, 37.7],
[-122.3, 37.7],
[-122.3, 37.8],
[-122.5, 37.8],
[-122.5, 37.7]
]]
};
await db.query(`
INSERT INTO features (geom, properties)
VALUES (ST_GeomFromGeoJSON($1), $2)
`, [JSON.stringify(polygon), { name: 'Test Area', type: 'polygon' }]);
// Test points inside and outside
const insidePoint = { lng: -122.4, lat: 37.75 };
const outsidePoint = { lng: -122.2, lat: 37.75 };
const insideResult = await spatialService.findFeaturesContainingPoint(insidePoint);
const outsideResult = await spatialService.findFeaturesContainingPoint(outsidePoint);
expect(insideResult.length).toBeGreaterThan(0);
expect(outsideResult.length).toBe(0);
});
it('should handle coordinate transformations correctly', async () => {
// Test transformation between WGS84 and Web Mercator
const wgs84Point = { lng: -122.4194, lat: 37.7749 };
const result = await db.query(`
SELECT
ST_X(ST_Transform(ST_SetSRID(ST_Point($1, $2), 4326), 3857)) as x,
ST_Y(ST_Transform(ST_SetSRID(ST_Point($1, $2), 4326), 3857)) as y
`, [wgs84Point.lng, wgs84Point.lat]);
const { x, y } = result.rows[0];
// Expected Web Mercator coordinates for San Francisco
expect(x).toBeCloseTo(-13627812, 0);
expect(y).toBeCloseTo(4544699, 0);
});
it('should handle edge cases in geometry operations', async () => {
// Test with geometries at projection boundaries
const dateLine = { lng: 180, lat: 0 };
const nearDateLine = { lng: 179, lat: 0 };
const distance = await spatialService.calculateDistance(dateLine, nearDateLine);
expect(distance).toBeCloseTo(111320, 0); // ~1 degree at equator
// Test with polar coordinates
const northPole = { lng: 0, lat: 90 };
const nearPole = { lng: 0, lat: 89 };
const polarDistance = await spatialService.calculateDistance(northPole, nearPole);
expect(polarDistance).toBeCloseTo(111320, 0); // ~1 degree
});
});
describe('Data Integrity', () => {
it('should validate geometry before insertion', async () => {
const invalidGeometries = [
{ type: 'Point', coordinates: [200, 100] }, // Out of bounds
{ type: 'Polygon', coordinates: [[[0, 0], [1, 1], [0, 1]]] }, // Not closed
{ type: 'LineString', coordinates: [[0, 0]] } // Insufficient points
];
for (const geom of invalidGeometries) {
await expect(
db.query(`
INSERT INTO features (geom, properties)
VALUES (ST_GeomFromGeoJSON($1), $2)
`, [JSON.stringify(geom), { name: 'Invalid' }])
).rejects.toThrow();
}
});
it('should handle self-intersecting polygons', async () => {
const selfIntersecting = {
type: 'Polygon',
coordinates: [[
[0, 0], [2, 2], [2, 0], [0, 2], [0, 0] // Bowtie shape
]]
};
// Should either reject or auto-fix the geometry
try {
await db.query(`
INSERT INTO features (geom, properties)
VALUES (ST_GeomFromGeoJSON($1), $2)
`, [JSON.stringify(selfIntersecting), { name: 'Self-intersecting' }]);
// If accepted, verify it was fixed
const result = await db.query(`
SELECT ST_IsValid(geom) as valid FROM features
WHERE properties->>'name' = 'Self-intersecting'
`);
expect(result.rows[0].valid).toBe(true);
} catch (error) {
// Rejection is also acceptable
expect(error).toBeDefined();
}
});
it('should maintain referential integrity with cascading deletes', async () => {
// This test would depend on your specific schema relationships
// Example: deleting a user should delete their features
const userId = 'test-user-id';
// Create user and features
await db.query(`
INSERT INTO users (id, email) VALUES ($1, $2)
`, [userId, 'test@example.com']);
await db.query(`
INSERT INTO features (geom, properties, user_id)
VALUES (ST_Point(-122.4, 37.75), $1, $2)
`, [{ name: 'User Feature' }, userId]);
// Delete user
await db.query('DELETE FROM users WHERE id = $1', [userId]);
// Verify features are also deleted (if cascade is set up)
const result = await db.query(
'SELECT COUNT(*) as count FROM features WHERE user_id = $1',
[userId]
);
expect(parseInt(result.rows[0].count)).toBe(0);
});
});
describe('Concurrent Access', () => {
it('should handle concurrent reads without blocking', async () => {
const bounds = [-122.5, 37.7, -122.3, 37.8];
// Execute multiple concurrent queries
const queries = Array(10).fill(null).map(() =>
spatialService.getFeaturesInBounds(bounds, { limit: 100 })
);
const startTime = Date.now();
const results = await Promise.all(queries);
const duration = Date.now() - startTime;
// All queries should succeed
expect(results).toHaveLength(10);
results.forEach(result => {
expect(Array.isArray(result)).toBe(true);
});
// Concurrent reads shouldn't take much longer than single read
expect(duration).toBeLessThan(1000);
});
it('should handle concurrent writes safely', async () => {
const features = Array(5).fill(null).map((_, i) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [-122.4 + i * 0.01, 37.75] },
properties: { name: `Concurrent Feature ${i}`, type: 'test' }
}));
// Insert features concurrently
const insertPromises = features.map(feature =>
spatialService.createFeature(feature)
);
const results = await Promise.allSettled(insertPromises);
// All inserts should succeed
const successful = results.filter(r => r.status === 'fulfilled');
expect(successful).toHaveLength(5);
// Verify all features were actually inserted
const count = await db.query(
"SELECT COUNT(*) as count FROM features WHERE properties->>'type' = 'test'"
);
expect(parseInt(count.rows[0].count)).toBeGreaterThanOrEqual(5);
});
});
});
// Helper functions
async function setupSpatialTables(db: Pool): Promise<void> {
await db.query(`
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE IF NOT EXISTS features (
id SERIAL PRIMARY KEY,
geom GEOMETRY(GEOMETRY, 4326) NOT NULL,
properties JSONB DEFAULT '{}',
user_id VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_features_geom ON features USING GIST(geom);
CREATE INDEX IF NOT EXISTS idx_features_properties ON features USING GIN(properties);
CREATE INDEX IF NOT EXISTS idx_features_user_id ON features(user_id);
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(255) PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
ALTER TABLE features
ADD CONSTRAINT fk_features_user_id
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
`);
}
async function seedSpatialTestData(db: Pool): Promise<void> {
// Clear existing test data
await db.query("DELETE FROM features WHERE properties->>'test' = 'true'");
// Insert test features around San Francisco
const testFeatures = [
{ type: 'Point', coordinates: [-122.4194, 37.7749] }, // Downtown SF
{ type: 'Point', coordinates: [-122.4683, 37.8199] }, // Golden Gate Bridge
{ type: 'Point', coordinates: [-122.3959, 37.7912] }, // Alcatraz
{ type: 'Point', coordinates: [-122.4089, 37.7835] }, // Fisherman's Wharf
{ type: 'Point', coordinates: [-122.4469, 37.7648] } // Twin Peaks
];
for (let i = 0; i < testFeatures.length; i++) {
await db.query(`
INSERT INTO features (geom, properties)
VALUES (ST_GeomFromGeoJSON($1), $2)
`, [
JSON.stringify(testFeatures[i]),
{ name: `Test Feature ${i + 1}`, type: 'test', test: 'true' }
]);
}
}
async function insertLargeDataset(db: Pool, count: number): Promise<void> {
const batchSize = 1000;
for (let i = 0; i < count; i += batchSize) {
const values: string[] = [];
const params: any[] = [];
for (let j = 0; j < Math.min(batchSize, count - i); j++) {
const idx = i + j;
const lng = -122.5 + Math.random() * 0.2; // Random in SF area
const lat = 37.7 + Math.random() * 0.1;
values.push(`(ST_Point($${params.length + 1}, $${params.length + 2}), $${params.length + 3})`);
params.push(lng, lat, { name: `Generated Feature ${idx}`, type: 'generated' });
}
await db.query(`
INSERT INTO features (geom, properties) VALUES ${values.join(', ')}
`, params);
}
}
15.5. End-to-End Testing for Map Workflows#
15.5.1. Comprehensive E2E Testing#
// tests/e2e/mapWorkflows.test.ts
import { test, expect, Page, BrowserContext } from '@playwright/test';
import { setupTestUser, cleanupTestUser } from '../utils/e2eTestUser';
test.describe('Map Workflows', () => {
let context: BrowserContext;
let page: Page;
let testUser: any;
test.beforeAll(async ({ browser }) => {
context = await browser.newContext({
// Grant geolocation permission
permissions: ['geolocation'],
geolocation: { latitude: 37.7749, longitude: -122.4194 } // San Francisco
});
page = await context.newPage();
testUser = await setupTestUser();
});
test.afterAll(async () => {
await cleanupTestUser(testUser.id);
await context.close();
});
test.beforeEach(async () => {
// Login before each test
await page.goto('/login');
await page.fill('[data-testid="email-input"]', testUser.email);
await page.fill('[data-testid="password-input"]', testUser.password);
await page.click('[data-testid="login-button"]');
// Wait for redirect to map
await page.waitForURL('/map');
// Wait for map to load
await page.waitForSelector('[data-testid="map-container"]');
await page.waitForFunction(() => {
const mapContainer = document.querySelector('[data-testid="map-container"]');
return mapContainer && !mapContainer.classList.contains('loading');
});
});
test('should load map with initial viewport', async () => {
// Check map container is visible
const mapContainer = page.locator('[data-testid="map-container"]');
await expect(mapContainer).toBeVisible();
// Check zoom controls are present
await expect(page.locator('.maplibregl-ctrl-zoom-in')).toBeVisible();
await expect(page.locator('.maplibregl-ctrl-zoom-out')).toBeVisible();
// Check initial viewport is approximately correct
const center = await page.evaluate(() => {
const map = window.map; // Assuming map instance is available globally
return map.getCenter();
});
expect(center.lng).toBeCloseTo(-122.4, 1);
expect(center.lat).toBeCloseTo(37.7, 1);
});
test('should navigate map using controls', async () => {
// Get initial center
const initialCenter = await page.evaluate(() => window.map.getCenter());
// Zoom in using control
await page.click('.maplibregl-ctrl-zoom-in');
await page.waitForTimeout(500); // Wait for animation
const zoomedInLevel = await page.evaluate(() => window.map.getZoom());
expect(zoomedInLevel).toBeGreaterThan(10);
// Pan map by dragging
const mapCanvas = page.locator('.maplibregl-canvas');
await mapCanvas.hover();
await page.mouse.down();
await page.mouse.move(100, 0); // Drag 100px to the right
await page.mouse.up();
await page.waitForTimeout(500);
const newCenter = await page.evaluate(() => window.map.getCenter());
expect(newCenter.lng).not.toBeCloseTo(initialCenter.lng, 5);
});
test('should search for locations', async () => {
// Find and use search box
const searchInput = page.locator('[data-testid="location-search"]');
await expect(searchInput).toBeVisible();
await searchInput.fill('Golden Gate Bridge, San Francisco');
await page.keyboard.press('Enter');
// Wait for search results
await page.waitForSelector('[data-testid="search-results"]');
// Click first result
await page.click('[data-testid="search-result"]:first-child');
// Verify map moved to the location
await page.waitForTimeout(2000); // Wait for fly-to animation
const center = await page.evaluate(() => window.map.getCenter());
expect(center.lng).toBeCloseTo(-122.478, 1); // Golden Gate Bridge coordinates
expect(center.lat).toBeCloseTo(37.819, 1);
});
test('should add and edit features', async () => {
// Enter drawing mode
await page.click('[data-testid="add-feature-button"]');
await page.click('[data-testid="draw-point-tool"]');
// Click on map to add point
const mapCanvas = page.locator('.maplibregl-canvas');
await mapCanvas.click({ position: { x: 400, y: 300 } });
// Fill feature form
await page.waitForSelector('[data-testid="feature-form"]');
await page.fill('[data-testid="feature-name"]', 'Test Location');
await page.fill('[data-testid="feature-description"]', 'A test location added via E2E test');
await page.selectOption('[data-testid="feature-type"]', 'restaurant');
// Save feature
await page.click('[data-testid="save-feature-button"]');
// Verify feature appears on map
await page.waitForSelector('[data-testid="feature-marker"]');
// Click on feature to select it
await page.click('[data-testid="feature-marker"]');
// Verify popup shows correct information
await page.waitForSelector('[data-testid="feature-popup"]');
await expect(page.locator('[data-testid="popup-title"]')).toHaveText('Test Location');
await expect(page.locator('[data-testid="popup-description"]')).toHaveText('A test location added via E2E test');
// Edit feature
await page.click('[data-testid="edit-feature-button"]');
await page.fill('[data-testid="feature-name"]', 'Updated Test Location');
await page.click('[data-testid="save-feature-button"]');
// Verify update
await page.click('[data-testid="feature-marker"]');
await expect(page.locator('[data-testid="popup-title"]')).toHaveText('Updated Test Location');
});
test('should manage map layers', async () => {
// Open layer control
await page.click('[data-testid="layer-control-button"]');
await page.waitForSelector('[data-testid="layer-control-panel"]');
// Verify default layers are shown
await expect(page.locator('[data-testid="layer-roads"]')).toBeVisible();
await expect(page.locator('[data-testid="layer-buildings"]')).toBeVisible();
// Toggle layer visibility
const buildingsToggle = page.locator('[data-testid="layer-buildings"] input[type="checkbox"]');
const initialChecked = await buildingsToggle.isChecked();
await buildingsToggle.click();
await page.waitForTimeout(500);
// Verify layer visibility changed
const newChecked = await buildingsToggle.isChecked();
expect(newChecked).toBe(!initialChecked);
// Adjust layer opacity
const opacitySlider = page.locator('[data-testid="layer-roads"] input[type="range"]');
await opacitySlider.fill('50');
await page.waitForTimeout(500);
// Verify opacity was applied (this would require checking map layer properties)
const roadLayerOpacity = await page.evaluate(() => {
const map = window.map;
return map.getPaintProperty('roads', 'line-opacity');
});
expect(roadLayerOpacity).toBe(0.5);
});
test('should handle real-time data updates', async () => {
// This test assumes your app has real-time features
// Navigate to area with real-time data
await page.goto('/map?lat=37.7749&lng=-122.4194&zoom=12');
await page.waitForSelector('[data-testid="map-container"]');
// Enable real-time layer
await page.click('[data-testid="layer-control-button"]');
await page.click('[data-testid="layer-realtime"] input[type="checkbox"]');
// Wait for initial data load
await page.waitForSelector('[data-testid="realtime-feature"]');
const initialCount = await page.locator('[data-testid="realtime-feature"]').count();
// Simulate waiting for real-time update
await page.waitForTimeout(5000);
const updatedCount = await page.locator('[data-testid="realtime-feature"]').count();
// Real-time data might change (this is environment dependent)
// At minimum, verify the system handles updates without crashing
expect(typeof updatedCount).toBe('number');
});
test('should work across different screen sizes', async () => {
// Test mobile viewport
await page.setViewportSize({ width: 375, height: 667 }); // iPhone SE
// Verify map adjusts to mobile
const mapContainer = page.locator('[data-testid="map-container"]');
const containerSize = await mapContainer.boundingBox();
expect(containerSize?.width).toBeLessThanOrEqual(375);
// Mobile-specific controls should be visible
await expect(page.locator('[data-testid="mobile-menu-button"]')).toBeVisible();
// Test tablet viewport
await page.setViewportSize({ width: 768, height: 1024 }); // iPad
await page.waitForTimeout(500);
const tabletSize = await mapContainer.boundingBox();
expect(tabletSize?.width).toBeLessThanOrEqual(768);
// Test desktop viewport
await page.setViewportSize({ width: 1920, height: 1080 });
await page.waitForTimeout(500);
const desktopSize = await mapContainer.boundingBox();
expect(desktopSize?.width).toBeGreaterThan(1000);
// Desktop sidebar should be visible
await expect(page.locator('[data-testid="desktop-sidebar"]')).toBeVisible();
});
test('should handle offline scenarios', async () => {
// Go offline
await context.setOffline(true);
// Try to load new data
await page.click('.maplibregl-ctrl-zoom-in');
await page.waitForTimeout(1000);
// Should show offline indicator
await expect(page.locator('[data-testid="offline-indicator"]')).toBeVisible();
// Cached features should still be visible
const visibleFeatures = await page.locator('[data-testid="feature-marker"]').count();
expect(visibleFeatures).toBeGreaterThan(0);
// Go back online
await context.setOffline(false);
await page.waitForTimeout(2000);
// Offline indicator should disappear
await expect(page.locator('[data-testid="offline-indicator"]')).not.toBeVisible();
});
test('should handle geolocation', async () => {
// Click geolocation button
await page.click('[data-testid="geolocate-button"]');
// Wait for map to move to user location
await page.waitForTimeout(2000);
const center = await page.evaluate(() => window.map.getCenter());
// Should be near the mocked San Francisco location
expect(center.lng).toBeCloseTo(-122.4194, 1);
expect(center.lat).toBeCloseTo(37.7749, 1);
// User location marker should be visible
await expect(page.locator('[data-testid="user-location-marker"]')).toBeVisible();
});
test('should export map data', async () => {
// Add some features first
await page.click('[data-testid="add-feature-button"]');
await page.click('[data-testid="draw-point-tool"]');
const mapCanvas = page.locator('.maplibregl-canvas');
await mapCanvas.click({ position: { x: 400, y: 300 } });
await page.fill('[data-testid="feature-name"]', 'Export Test Feature');
await page.click('[data-testid="save-feature-button"]');
// Start download
const downloadPromise = page.waitForEvent('download');
await page.click('[data-testid="export-button"]');
await page.click('[data-testid="export-geojson"]');
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain('.geojson');
// Verify download contains data
const path = await download.path();
expect(path).toBeTruthy();
});
test('should handle error states gracefully', async () => {
// Simulate network error by intercepting requests
await page.route('**/api/features**', route => {
route.abort('failed');
});
// Try to load features
await page.reload();
await page.waitForSelector('[data-testid="map-container"]');
// Should show error message
await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
// Error message should be helpful
const errorText = await page.locator('[data-testid="error-message"]').textContent();
expect(errorText?.toLowerCase()).toContain('unable to load');
// Retry button should be available
await expect(page.locator('[data-testid="retry-button"]')).toBeVisible();
// Remove route interception
await page.unroute('**/api/features**');
// Click retry
await page.click('[data-testid="retry-button"]');
await page.waitForTimeout(2000);
// Error should disappear
await expect(page.locator('[data-testid="error-message"]')).not.toBeVisible();
});
test('should maintain performance under load', async () => {
// Navigate to area with many features
await page.goto('/map?lat=37.7749&lng=-122.4194&zoom=15');
// Measure initial load time
const startTime = Date.now();
await page.waitForSelector('[data-testid="map-container"]');
await page.waitForFunction(() => !document.querySelector('[data-testid="map-container"]')?.classList.contains('loading'));
const loadTime = Date.now() - startTime;
expect(loadTime).toBeLessThan(5000); // Should load within 5 seconds
// Rapid zoom changes (stress test)
for (let i = 0; i < 5; i++) {
await page.click('.maplibregl-ctrl-zoom-in');
await page.waitForTimeout(100);
await page.click('.maplibregl-ctrl-zoom-out');
await page.waitForTimeout(100);
}
// Map should still be responsive
const finalCenter = await page.evaluate(() => window.map.getCenter());
expect(typeof finalCenter.lng).toBe('number');
expect(typeof finalCenter.lat).toBe('number');
});
});
// Additional test utilities
test.describe('Accessibility Tests', () => {
test('should meet accessibility standards', async ({ page }) => {
await page.goto('/map');
await page.waitForSelector('[data-testid="map-container"]');
// Check for proper ARIA labels
const mapContainer = page.locator('[data-testid="map-container"]');
await expect(mapContainer).toHaveAttribute('role', 'application');
await expect(mapContainer).toHaveAttribute('aria-label');
// Check keyboard navigation
await page.keyboard.press('Tab');
const focusedElement = await page.evaluate(() => document.activeElement?.tagName);
expect(focusedElement).toBeTruthy();
// Check color contrast (would require additional accessibility testing library)
// This is a placeholder for more comprehensive accessibility testing
});
test('should support screen readers', async ({ page }) => {
await page.goto('/map');
// Check for screen reader announcements
const announcements = page.locator('[aria-live="polite"]');
await expect(announcements).toBeAttached();
// Verify important actions trigger announcements
await page.click('[data-testid="add-feature-button"]');
// Implementation would check for aria-live updates
});
});
15.6. Visual Testing and Cross-Browser Compatibility#
15.6.1. Visual Regression Testing#
// tests/visual/mapRendering.test.ts
import { test, expect } from '@playwright/test';
test.describe('Visual Regression Tests', () => {
test.beforeEach(async ({ page }) => {
// Set consistent viewport for visual tests
await page.setViewportSize({ width: 1200, height: 800 });
// Navigate to map with predictable data
await page.goto('/map?lat=37.7749&lng=-122.4194&zoom=12&theme=light');
await page.waitForSelector('[data-testid="map-container"]');
// Wait for map to fully load
await page.waitForFunction(() => {
const map = window.map;
return map && map.loaded() && map.areTilesLoaded();
});
// Wait for all network requests to settle
await page.waitForLoadState('networkidle');
});
test('should render base map consistently', async ({ page }) => {
const mapContainer = page.locator('[data-testid="map-container"]');
// Take screenshot of the map
await expect(mapContainer).toHaveScreenshot('base-map.png', {
threshold: 0.2, // Allow for minor rendering differences
maxDiffPixels: 1000
});
});
test('should render different zoom levels consistently', async ({ page }) => {
const zoomLevels = [8, 10, 12, 14, 16];
for (const zoom of zoomLevels) {
await page.evaluate((z) => {
window.map.setZoom(z);
}, zoom);
// Wait for tiles to load
await page.waitForFunction(() => window.map.areTilesLoaded());
await page.waitForTimeout(1000); // Additional wait for rendering
const mapContainer = page.locator('[data-testid="map-container"]');
await expect(mapContainer).toHaveScreenshot(`zoom-level-${zoom}.png`, {
threshold: 0.3,
maxDiffPixels: 1500
});
}
});
test('should render feature layers consistently', async ({ page }) => {
// Enable specific layers
await page.click('[data-testid="layer-control-button"]');
await page.check('[data-testid="layer-restaurants"]');
await page.check('[data-testid="layer-parks"]');
// Wait for layers to load
await page.waitForTimeout(2000);
const mapContainer = page.locator('[data-testid="map-container"]');
await expect(mapContainer).toHaveScreenshot('feature-layers.png', {
threshold: 0.2,
maxDiffPixels: 1000
});
});
test('should render dark theme consistently', async ({ page }) => {
// Switch to dark theme
await page.click('[data-testid="theme-toggle"]');
await page.waitForTimeout(1000);
const mapContainer = page.locator('[data-testid="map-container"]');
await expect(mapContainer).toHaveScreenshot('dark-theme.png', {
threshold: 0.2,
maxDiffPixels: 1000
});
});
test('should render popup and controls consistently', async ({ page }) => {
// Add a feature and open its popup
await page.click('[data-testid="add-feature-button"]');
await page.click('[data-testid="draw-point-tool"]');
const mapCanvas = page.locator('.maplibregl-canvas');
await mapCanvas.click({ position: { x: 600, y: 400 } });
await page.fill('[data-testid="feature-name"]', 'Visual Test Feature');
await page.click('[data-testid="save-feature-button"]');
// Click on the feature to open popup
await page.click('[data-testid="feature-marker"]');
await page.waitForSelector('[data-testid="feature-popup"]');
const mapContainer = page.locator('[data-testid="map-container"]');
await expect(mapContainer).toHaveScreenshot('popup-and-controls.png', {
threshold: 0.2,
maxDiffPixels: 1000
});
});
test('should render mobile layout consistently', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
// Wait for layout to adjust
await page.waitForTimeout(1000);
await expect(page).toHaveScreenshot('mobile-layout.png', {
fullPage: true,
threshold: 0.2,
maxDiffPixels: 1000
});
});
});
// Cross-browser compatibility tests
test.describe('Cross-Browser Compatibility', () => {
['chromium', 'firefox', 'webkit'].forEach(browserName => {
test.describe(`${browserName} Browser Tests`, () => {
test.use({
browserName: browserName as 'chromium' | 'firefox' | 'webkit'
});
test('should render map correctly', async ({ page }) => {
await page.goto('/map?lat=37.7749&lng=-122.4194&zoom=12');
await page.waitForSelector('[data-testid="map-container"]');
// Wait for map to load
await page.waitForFunction(() => {
const map = window.map;
return map && map.loaded();
});
// Verify map is interactive
const center = await page.evaluate(() => window.map.getCenter());
expect(center.lng).toBeCloseTo(-122.4194, 1);
expect(center.lat).toBeCloseTo(37.7749, 1);
// Test basic interaction
await page.click('.maplibregl-ctrl-zoom-in');
await page.waitForTimeout(500);
const newZoom = await page.evaluate(() => window.map.getZoom());
expect(newZoom).toBeGreaterThan(12);
});
test('should handle WebGL support', async ({ page }) => {
await page.goto('/map');
// Check WebGL support
const hasWebGL = await page.evaluate(() => {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
return !!gl;
});
if (hasWebGL) {
// Should use WebGL for rendering
await page.waitForSelector('[data-testid="map-container"]');
const canvas = page.locator('.maplibregl-canvas');
await expect(canvas).toBeVisible();
} else {
// Should fall back gracefully
await expect(page.locator('[data-testid="webgl-fallback"]')).toBeVisible();
}
});
test('should handle touch events on mobile', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/map');
await page.waitForSelector('[data-testid="map-container"]');
const mapCanvas = page.locator('.maplibregl-canvas');
// Test touch pan
await mapCanvas.touchscreen.tap(200, 300);
await page.waitForTimeout(100);
// Test pinch zoom (simulate with multiple touch points)
// Note: This is simplified - real pinch testing is more complex
const initialZoom = await page.evaluate(() => window.map.getZoom());
// Simulate double tap to zoom
await mapCanvas.touchscreen.tap(200, 300);
await mapCanvas.touchscreen.tap(200, 300);
await page.waitForTimeout(500);
const newZoom = await page.evaluate(() => window.map.getZoom());
expect(newZoom).toBeGreaterThan(initialZoom);
});
test('should handle different pixel ratios', async ({ page }) => {
// Test high DPI display
await page.emulateMedia({ colorScheme: 'light' });
await page.goto('/map');
const pixelRatio = await page.evaluate(() => window.devicePixelRatio);
// Map should adapt to pixel ratio
const canvas = page.locator('.maplibregl-canvas');
const canvasSize = await canvas.getAttribute('style');
expect(canvasSize).toBeTruthy();
// Should render crisp on high DPI
await page.waitForSelector('[data-testid="map-container"]');
const mapContainer = page.locator('[data-testid="map-container"]');
await expect(mapContainer).toBeVisible();
});
});
});
});
// Performance testing across browsers
test.describe('Performance Testing', () => {
test('should load within performance budget', async ({ page }) => {
// Start measuring performance
await page.goto('/map', { waitUntil: 'networkidle' });
const performanceMetrics = await page.evaluate(() => {
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
return {
domContentLoaded: navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart,
loadComplete: navigation.loadEventEnd - navigation.loadEventStart,
firstContentfulPaint: performance.getEntriesByName('first-contentful-paint')[0]?.startTime || 0,
largestContentfulPaint: performance.getEntriesByName('largest-contentful-paint')[0]?.startTime || 0
};
});
// Performance budgets (in milliseconds)
expect(performanceMetrics.domContentLoaded).toBeLessThan(2000);
expect(performanceMetrics.firstContentfulPaint).toBeLessThan(1500);
expect(performanceMetrics.largestContentfulPaint).toBeLessThan(3000);
});
test('should maintain frame rate during interactions', async ({ page }) => {
await page.goto('/map');
await page.waitForSelector('[data-testid="map-container"]');
// Start measuring frame rate
await page.evaluate(() => {
window.frameCount = 0;
window.startTime = performance.now();
function countFrame() {
window.frameCount++;
requestAnimationFrame(countFrame);
}
requestAnimationFrame(countFrame);
});
// Perform intensive map operations
for (let i = 0; i < 10; i++) {
await page.click('.maplibregl-ctrl-zoom-in');
await page.waitForTimeout(100);
}
await page.waitForTimeout(2000);
// Check frame rate
const frameRate = await page.evaluate(() => {
const duration = (performance.now() - window.startTime) / 1000;
return window.frameCount / duration;
});
// Should maintain at least 30 FPS
expect(frameRate).toBeGreaterThan(30);
});
test('should handle memory efficiently', async ({ page }) => {
await page.goto('/map');
const initialMemory = await page.evaluate(() => {
return (performance as any).memory?.usedJSHeapSize || 0;
});
// Perform memory-intensive operations
for (let i = 0; i < 20; i++) {
// Add and remove features
await page.click('[data-testid="add-feature-button"]');
await page.click('[data-testid="draw-point-tool"]');
const mapCanvas = page.locator('.maplibregl-canvas');
await mapCanvas.click({ position: { x: 400 + i * 10, y: 300 } });
await page.fill('[data-testid="feature-name"]', `Test ${i}`);
await page.click('[data-testid="save-feature-button"]');
// Delete the feature
await page.click('[data-testid="feature-marker"]:last-child');
await page.click('[data-testid="delete-feature-button"]');
await page.click('[data-testid="confirm-delete"]');
}
// Force garbage collection if available
await page.evaluate(() => {
if (window.gc) {
window.gc();
}
});
const finalMemory = await page.evaluate(() => {
return (performance as any).memory?.usedJSHeapSize || 0;
});
// Memory usage shouldn't grow excessively
const memoryIncrease = finalMemory - initialMemory;
expect(memoryIncrease).toBeLessThan(50 * 1024 * 1024); // Less than 50MB increase
});
});
15.7. Quality Assurance Processes#
15.7.1. Automated QA Pipeline#
// scripts/qa-pipeline.ts
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
interface QAReport {
timestamp: string;
version: string;
environment: string;
results: {
unitTests: TestResult;
integrationTests: TestResult;
e2eTests: TestResult;
visualTests: TestResult;
performanceTests: TestResult;
securityScan: SecurityResult;
accessibility: AccessibilityResult;
codeQuality: CodeQualityResult;
};
overallStatus: 'passed' | 'failed' | 'warning';
recommendations: string[];
}
interface TestResult {
passed: number;
failed: number;
skipped: number;
coverage?: number;
duration: number;
details: any[];
}
interface SecurityResult {
vulnerabilities: {
critical: number;
high: number;
medium: number;
low: number;
};
details: any[];
}
interface AccessibilityResult {
violations: number;
warnings: number;
score: number;
details: any[];
}
interface CodeQualityResult {
score: number;
issues: {
bugs: number;
vulnerabilities: number;
codeSmells: number;
duplications: number;
};
coverage: number;
}
export class QAPipeline {
private report: QAReport;
private config: any;
constructor(config: any) {
this.config = config;
this.report = {
timestamp: new Date().toISOString(),
version: this.getVersion(),
environment: process.env.NODE_ENV || 'development',
results: {} as any,
overallStatus: 'passed',
recommendations: []
};
}
async runFullPipeline(): Promise<QAReport> {
console.log('Starting QA Pipeline...');
try {
// Run tests in parallel where possible
await Promise.all([
this.runUnitTests(),
this.runLinting(),
this.runSecurityScan()
]);
// Run integration tests
await this.runIntegrationTests();
// Run E2E tests
await this.runE2ETests();
// Run visual tests
await this.runVisualTests();
// Run performance tests
await this.runPerformanceTests();
// Run accessibility tests
await this.runAccessibilityTests();
// Generate code quality report
await this.runCodeQualityAnalysis();
// Determine overall status
this.calculateOverallStatus();
// Generate recommendations
this.generateRecommendations();
// Save report
await this.saveReport();
console.log(`QA Pipeline completed with status: ${this.report.overallStatus}`);
} catch (error) {
console.error('QA Pipeline failed:', error);
this.report.overallStatus = 'failed';
throw error;
}
return this.report;
}
private async runUnitTests(): Promise<void> {
console.log('Running unit tests...');
try {
const startTime = Date.now();
// Run Vitest with coverage
const output = execSync('npm run test:unit -- --coverage --reporter=json', {
encoding: 'utf8',
cwd: process.cwd()
});
const duration = Date.now() - startTime;
const results = JSON.parse(output);
this.report.results.unitTests = {
passed: results.numPassedTests,
failed: results.numFailedTests,
skipped: results.numPendingTests,
coverage: this.extractCoverage(),
duration,
details: results.testResults
};
} catch (error) {
this.report.results.unitTests = {
passed: 0,
failed: 1,
skipped: 0,
duration: 0,
details: [{ error: error.message }]
};
}
}
private async runIntegrationTests(): Promise<void> {
console.log('Running integration tests...');
try {
const startTime = Date.now();
const output = execSync('npm run test:integration -- --reporter=json', {
encoding: 'utf8',
cwd: process.cwd()
});
const duration = Date.now() - startTime;
const results = JSON.parse(output);
this.report.results.integrationTests = {
passed: results.numPassedTests,
failed: results.numFailedTests,
skipped: results.numPendingTests,
duration,
details: results.testResults
};
} catch (error) {
this.report.results.integrationTests = {
passed: 0,
failed: 1,
skipped: 0,
duration: 0,
details: [{ error: error.message }]
};
}
}
private async runE2ETests(): Promise<void> {
console.log('Running E2E tests...');
try {
const startTime = Date.now();
const output = execSync('npx playwright test --reporter=json', {
encoding: 'utf8',
cwd: process.cwd()
});
const duration = Date.now() - startTime;
const results = JSON.parse(output);
this.report.results.e2eTests = {
passed: results.stats.passed,
failed: results.stats.failed,
skipped: results.stats.skipped,
duration,
details: results.suites
};
} catch (error) {
this.report.results.e2eTests = {
passed: 0,
failed: 1,
skipped: 0,
duration: 0,
details: [{ error: error.message }]
};
}
}
private async runVisualTests(): Promise<void> {
console.log('Running visual regression tests...');
try {
const startTime = Date.now();
const output = execSync('npx playwright test tests/visual --reporter=json', {
encoding: 'utf8',
cwd: process.cwd()
});
const duration = Date.now() - startTime;
const results = JSON.parse(output);
this.report.results.visualTests = {
passed: results.stats.passed,
failed: results.stats.failed,
skipped: results.stats.skipped,
duration,
details: results.suites
};
} catch (error) {
this.report.results.visualTests = {
passed: 0,
failed: 1,
skipped: 0,
duration: 0,
details: [{ error: error.message }]
};
}
}
private async runPerformanceTests(): Promise<void> {
console.log('Running performance tests...');
try {
const startTime = Date.now();
// Run Lighthouse CI
const lighthouseOutput = execSync('npx lhci autorun --collect.numberOfRuns=3', {
encoding: 'utf8',
cwd: process.cwd()
});
const duration = Date.now() - startTime;
// Parse Lighthouse results
const lighthouseResults = this.parseLighthouseResults();
this.report.results.performanceTests = {
passed: lighthouseResults.passed,
failed: lighthouseResults.failed,
skipped: 0,
duration,
details: lighthouseResults.details
};
} catch (error) {
this.report.results.performanceTests = {
passed: 0,
failed: 1,
skipped: 0,
duration: 0,
details: [{ error: error.message }]
};
}
}
private async runSecurityScan(): Promise<void> {
console.log('Running security scan...');
try {
// Run npm audit
const auditOutput = execSync('npm audit --json', {
encoding: 'utf8',
cwd: process.cwd()
});
const auditResults = JSON.parse(auditOutput);
// Run additional security tools if configured
let snykResults = null;
if (this.config.security.runSnyk) {
try {
const snykOutput = execSync('npx snyk test --json', {
encoding: 'utf8',
cwd: process.cwd()
});
snykResults = JSON.parse(snykOutput);
} catch (snykError) {
console.warn('Snyk scan failed:', snykError.message);
}
}
this.report.results.securityScan = {
vulnerabilities: {
critical: auditResults.metadata?.vulnerabilities?.critical || 0,
high: auditResults.metadata?.vulnerabilities?.high || 0,
medium: auditResults.metadata?.vulnerabilities?.moderate || 0,
low: auditResults.metadata?.vulnerabilities?.low || 0
},
details: {
npm: auditResults,
snyk: snykResults
}
};
} catch (error) {
this.report.results.securityScan = {
vulnerabilities: { critical: 0, high: 0, medium: 0, low: 0 },
details: [{ error: error.message }]
};
}
}
private async runAccessibilityTests(): Promise<void> {
console.log('Running accessibility tests...');
try {
// Run axe-core tests
const axeOutput = execSync('npx @axe-core/cli http://localhost:3000 --json', {
encoding: 'utf8',
cwd: process.cwd()
});
const axeResults = JSON.parse(axeOutput);
this.report.results.accessibility = {
violations: axeResults.violations?.length || 0,
warnings: axeResults.incomplete?.length || 0,
score: this.calculateAccessibilityScore(axeResults),
details: axeResults
};
} catch (error) {
this.report.results.accessibility = {
violations: 0,
warnings: 0,
score: 0,
details: [{ error: error.message }]
};
}
}
private async runCodeQualityAnalysis(): Promise<void> {
console.log('Running code quality analysis...');
try {
// Run ESLint
const eslintOutput = execSync('npx eslint . --format json', {
encoding: 'utf8',
cwd: process.cwd()
});
const eslintResults = JSON.parse(eslintOutput);
// Calculate metrics
const issues = this.aggregateESLintIssues(eslintResults);
const coverage = this.extractCoverage();
this.report.results.codeQuality = {
score: this.calculateCodeQualityScore(issues, coverage),
issues,
coverage,
details: eslintResults
};
} catch (error) {
this.report.results.codeQuality = {
score: 0,
issues: { bugs: 0, vulnerabilities: 0, codeSmells: 0, duplications: 0 },
coverage: 0,
details: [{ error: error.message }]
};
}
}
private async runLinting(): Promise<void> {
console.log('Running linting...');
try {
execSync('npm run lint', {
encoding: 'utf8',
cwd: process.cwd()
});
} catch (error) {
console.warn('Linting issues found:', error.message);
}
}
private calculateOverallStatus(): void {
const { results } = this.report;
// Check for critical failures
if (
results.unitTests?.failed > 0 ||
results.integrationTests?.failed > 0 ||
results.e2eTests?.failed > 0 ||
results.securityScan?.vulnerabilities?.critical > 0
) {
this.report.overallStatus = 'failed';
return;
}
// Check for warnings
if (
results.visualTests?.failed > 0 ||
results.performanceTests?.failed > 0 ||
results.securityScan?.vulnerabilities?.high > 0 ||
results.accessibility?.violations > 5 ||
(results.codeQuality?.coverage || 0) < 80
) {
this.report.overallStatus = 'warning';
return;
}
this.report.overallStatus = 'passed';
}
private generateRecommendations(): void {
const { results } = this.report;
const recommendations: string[] = [];
// Test coverage recommendations
if ((results.unitTests?.coverage || 0) < 80) {
recommendations.push('Increase unit test coverage to at least 80%');
}
// Security recommendations
if (results.securityScan?.vulnerabilities?.high > 0) {
recommendations.push('Address high-severity security vulnerabilities');
}
// Performance recommendations
if (results.performanceTests?.failed > 0) {
recommendations.push('Optimize application performance to meet performance budgets');
}
// Accessibility recommendations
if (results.accessibility?.violations > 0) {
recommendations.push('Fix accessibility violations to improve application usability');
}
// Code quality recommendations
if ((results.codeQuality?.score || 0) < 8) {
recommendations.push('Improve code quality by addressing ESLint issues and technical debt');
}
this.report.recommendations = recommendations;
}
private async saveReport(): Promise<void> {
const reportsDir = path.join(process.cwd(), 'reports');
if (!fs.existsSync(reportsDir)) {
fs.mkdirSync(reportsDir, { recursive: true });
}
const reportPath = path.join(reportsDir, `qa-report-${Date.now()}.json`);
fs.writeFileSync(reportPath, JSON.stringify(this.report, null, 2));
// Also save as latest
const latestPath = path.join(reportsDir, 'qa-report-latest.json');
fs.writeFileSync(latestPath, JSON.stringify(this.report, null, 2));
console.log(`QA report saved to: ${reportPath}`);
}
// Helper methods
private getVersion(): string {
try {
const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'));
return packageJson.version;
} catch {
return 'unknown';
}
}
private extractCoverage(): number {
try {
const coverageFile = path.join(process.cwd(), 'coverage', 'coverage-summary.json');
if (fs.existsSync(coverageFile)) {
const coverage = JSON.parse(fs.readFileSync(coverageFile, 'utf8'));
return coverage.total?.lines?.pct || 0;
}
} catch {
// Coverage data not available
}
return 0;
}
private parseLighthouseResults(): any {
// Parse Lighthouse CI results
// This would read from .lighthouseci directory
return {
passed: 1,
failed: 0,
details: []
};
}
private calculateAccessibilityScore(axeResults: any): number {
const violations = axeResults.violations?.length || 0;
const incomplete = axeResults.incomplete?.length || 0;
// Simple scoring algorithm
const totalIssues = violations + (incomplete * 0.5);
return Math.max(0, 100 - (totalIssues * 5));
}
private aggregateESLintIssues(eslintResults: any[]): any {
let bugs = 0;
let vulnerabilities = 0;
let codeSmells = 0;
eslintResults.forEach(file => {
file.messages?.forEach((message: any) => {
if (message.severity === 2) {
if (message.ruleId?.includes('security')) {
vulnerabilities++;
} else {
bugs++;
}
} else {
codeSmells++;
}
});
});
return { bugs, vulnerabilities, codeSmells, duplications: 0 };
}
private calculateCodeQualityScore(issues: any, coverage: number): number {
const issueScore = Math.max(0, 10 - (issues.bugs * 0.5 + issues.vulnerabilities * 1 + issues.codeSmells * 0.1));
const coverageScore = coverage / 10;
return (issueScore + coverageScore) / 2;
}
}
// CLI interface
if (require.main === module) {
const config = {
security: {
runSnyk: process.env.SNYK_TOKEN ? true : false
}
};
const pipeline = new QAPipeline(config);
pipeline.runFullPipeline()
.then(report => {
console.log('\n=== QA Pipeline Report ===');
console.log(`Status: ${report.overallStatus.toUpperCase()}`);
console.log(`Version: ${report.version}`);
console.log(`Environment: ${report.environment}`);
if (report.recommendations.length > 0) {
console.log('\nRecommendations:');
report.recommendations.forEach(rec => console.log(`- ${rec}`));
}
process.exit(report.overallStatus === 'failed' ? 1 : 0);
})
.catch(error => {
console.error('QA Pipeline failed:', error);
process.exit(1);
});
}
15.8. Summary#
Testing and quality assurance for Web GIS applications requires a comprehensive approach addressing the unique challenges of geospatial functionality. The complexity of coordinate systems, spatial relationships, map rendering, and performance at scale demands specialized testing strategies beyond traditional web application testing.
Unit testing focuses on spatial algorithms, coordinate transformations, and geometric calculations with emphasis on accuracy, edge cases, and performance. Component testing validates map rendering, user interactions, and feature management with proper mocking of mapping libraries and spatial services.
Integration testing verifies the complete spatial data pipeline from database queries through API endpoints to client rendering. Database integration testing specifically validates spatial indexing performance, query accuracy, and data integrity under various load conditions.
End-to-end testing covers complete user workflows including map navigation, feature editing, layer management, and real-time data updates. These tests must account for different devices, network conditions, and accessibility requirements while maintaining performance standards.
Visual testing and cross-browser compatibility ensure consistent map rendering across different browsers, devices, and pixel densities. Performance testing validates frame rates, memory usage, and load times under various conditions and user loads.
Quality assurance processes integrate all testing approaches into automated pipelines with comprehensive reporting, security scanning, and accessibility validation. Continuous monitoring and improvement processes ensure applications maintain quality standards throughout development and deployment.
These testing strategies provide the foundation for reliable, performant, and accessible Web GIS applications that meet user expectations across diverse environments and use cases.
15.9. Exercises#
15.9.1. Exercise 15.1: Comprehensive Unit Testing Suite#
Objective: Build a complete unit testing suite for geospatial functions and components.
Instructions:
Spatial algorithm testing:
Test distance calculations with known geographic points
Validate point-in-polygon algorithms with complex geometries
Test coordinate transformation accuracy across projections
Verify geometry simplification maintains shape characteristics
Component testing:
Mock MapLibre GL JS for map component testing
Test layer management and visibility controls
Validate feature editing workflows and state management
Test error handling and loading states
Edge case coverage:
Test boundary conditions for coordinate systems
Handle invalid geometries and malformed data
Test performance with large datasets
Validate behavior at projection limits
Deliverable: A comprehensive unit test suite with high coverage and robust edge case handling.
15.9.2. Exercise 15.2: Database Integration Testing#
Objective: Implement thorough integration testing for spatial database operations.
Instructions:
Spatial query testing:
Test bounding box queries with various geometries
Validate nearest neighbor search accuracy
Test spatial joins and complex geometric operations
Verify index usage and query performance
Data integrity testing:
Test geometry validation and constraint enforcement
Validate referential integrity with cascading operations
Test concurrent access and transaction isolation
Verify backup and recovery procedures
Performance benchmarking:
Measure query performance with large datasets
Test spatial index effectiveness
Validate memory usage during complex operations
Test connection pooling and resource management
Deliverable: Complete database integration test suite with performance benchmarks and data integrity validation.
15.9.3. Exercise 15.3: End-to-End User Workflow Testing#
Objective: Create comprehensive E2E tests covering complete user workflows.
Instructions:
Core workflow testing:
Test map navigation and zoom interactions
Validate feature creation, editing, and deletion
Test search functionality and location services
Verify data export and import processes
Advanced workflow testing:
Test real-time data updates and synchronization
Validate offline functionality and sync
Test multi-user collaboration features
Verify complex analysis and reporting workflows
Cross-platform testing:
Test across different browsers and devices
Validate touch interactions on mobile devices
Test with different screen sizes and orientations
Verify accessibility with assistive technologies
Deliverable: Comprehensive E2E test suite covering all major user workflows across platforms.
15.9.4. Exercise 15.4: Visual Regression Testing System#
Objective: Implement automated visual testing for map rendering consistency.
Instructions:
Map rendering tests:
Create baseline screenshots for different zoom levels
Test layer rendering consistency across browsers
Validate theme switching and style changes
Test popup and control rendering
Cross-browser visual testing:
Compare rendering across Chrome, Firefox, and Safari
Test WebGL vs Canvas fallback rendering
Validate high DPI display handling
Test print media queries and static exports
Responsive design testing:
Test mobile, tablet, and desktop layouts
Validate control positioning at different screen sizes
Test typography and icon scaling
Verify accessibility features remain visible
Deliverable: Automated visual regression testing system with comprehensive coverage and cross-browser validation.
15.9.5. Exercise 15.5: Performance Testing Framework#
Objective: Build comprehensive performance testing for Web GIS applications.
Instructions:
Frontend performance testing:
Measure map rendering performance at different zoom levels
Test frame rate during animations and interactions
Validate memory usage with large datasets
Test bundle size and loading performance
Backend performance testing:
Load test spatial API endpoints
Measure database query performance
Test concurrent user scenarios
Validate caching effectiveness
Network and infrastructure testing:
Test performance across different connection speeds
Validate CDN effectiveness and edge caching
Test geographic distribution and latency
Measure tile loading performance
Deliverable: Complete performance testing framework with automated benchmarking and performance regression detection.
15.9.6. Exercise 15.6: Security Testing Implementation#
Objective: Implement comprehensive security testing for Web GIS applications.
Instructions:
API security testing:
Test authentication and authorization mechanisms
Validate input sanitization and SQL injection prevention
Test rate limiting and abuse prevention
Verify HTTPS enforcement and header security
Data security testing:
Test geographic access controls and boundary enforcement
Validate data encryption at rest and in transit
Test user data isolation in multi-tenant scenarios
Verify secure handling of location data
Infrastructure security testing:
Scan for dependency vulnerabilities
Test container and deployment security
Validate secret management and key rotation
Test backup security and access controls
Deliverable: Comprehensive security testing suite with automated vulnerability scanning and compliance verification.
15.9.7. Exercise 15.7: Automated QA Pipeline#
Objective: Build a complete automated QA pipeline integrating all testing approaches.
Instructions:
Pipeline integration:
Integrate unit, integration, and E2E tests
Add code quality analysis and coverage reporting
Include security scanning and dependency auditing
Add performance benchmarking and regression detection
Reporting and monitoring:
Generate comprehensive QA reports
Implement automated alerting for failures
Create performance and quality dashboards
Add trend analysis and improvement tracking
Continuous improvement:
Implement quality gates and deployment criteria
Add automatic issue detection and prioritization
Create feedback loops for test effectiveness
Build capacity planning and scaling insights
Deliverable: Complete automated QA pipeline with comprehensive reporting and continuous improvement capabilities.
Reflection Questions:
How do the testing requirements for Web GIS applications differ from traditional web applications?
What are the most critical areas to focus on when testing geospatial functionality?
How can automated testing help maintain quality while supporting rapid development cycles?
What role does performance testing play in ensuring good user experience for mapping applications?