Testing Guide
How to test Sightline applications and customizations.
Overview
Sightline uses a comprehensive testing strategy:
| Type | Tool | Purpose |
|---|---|---|
| Unit Tests | Vitest | Test individual functions and components |
| Component Tests | Vitest + Testing Library | Test React components in isolation |
| E2E Tests | Playwright | Test full user flows in real browsers |
Running Tests
# Run all unit tests
pnpm test
# Run with coverage
pnpm test:coverage# Run unit tests once
pnpm test:run
# Run specific test file
pnpm test src/lib/__tests__/format-utils.test.ts# Run E2E tests
pnpm test:e2e
# Run with UI
pnpm test:e2e --ui
# Run specific test file
pnpm test:e2e e2e/map.spec.ts# Watch mode for development (default)
pnpm test
# Run specific file in watch mode
pnpm test src/adapters/__tests__/Unit Testing
Test Setup
Tests use the setup file at src/__tests__/setup.ts:
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
// Cleanup after each test
afterEach(() => {
cleanup();
});
// Mock window.matchMedia for theme tests
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});Testing Utilities
import { describe, it, expect } from 'vitest';
import { formatValue, formatList, formatDuration } from '../format-utils';
describe('formatValue', () => {
it('converts snake_case to Title Case', () => {
expect(formatValue('radar_visual')).toBe('Radar Visual');
});
it('uses label lookup when provided', () => {
const labels = { rv: 'Radar-Visual' };
expect(formatValue('rv', labels)).toBe('Radar-Visual');
});
it('handles empty string', () => {
expect(formatValue('')).toBe('');
});
});
describe('formatList', () => {
it('formats array with labels', () => {
const labels = { a: 'Alpha', b: 'Beta' };
expect(formatList(['a', 'b'], labels)).toBe('Alpha, Beta');
});
it('handles empty array', () => {
expect(formatList([])).toBe('');
});
});
describe('formatDuration', () => {
it('formats seconds to readable duration', () => {
expect(formatDuration(3661)).toBe('1h 1m');
});
it('handles undefined', () => {
expect(formatDuration(undefined)).toBe('');
});
});Testing Data Adapters
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { StaticAdapter } from '../static-adapter';
describe('StaticAdapter', () => {
const mockConfig = {
id: 'test',
name: 'Test Adapter',
adapter: 'static' as const,
adapterConfig: { path: '/data/test.json' },
enabled: true,
};
beforeEach(() => {
vi.resetAllMocks();
});
it('fetches incidents from JSON file', async () => {
const mockData = {
incidents: [
{
id: 'inc-1',
status: 'published',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
temporal: { date: '2024-01-15', dateCertainty: 'exact' },
location: {
name: 'Test Location',
country: 'USA',
latitude: 33.4484,
longitude: -112.074,
siteType: 'urban',
locationSensitivity: 'standard',
},
},
],
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockData),
});
const adapter = new StaticAdapter(mockConfig);
const incidents = await adapter.getIncidents();
expect(incidents).toHaveLength(1);
expect(incidents[0].id).toBe('inc-1');
expect(fetch).toHaveBeenCalledWith('/data/test.json');
});
it('handles fetch errors', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
const adapter = new StaticAdapter(mockConfig);
await expect(adapter.getIncidents()).rejects.toThrow('Network error');
});
});Component Testing
Testing React Components
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { StatsPanel } from '../index';
import { DataProvider } from '@/core';
// Mock config context for labels
vi.mock('@/core/config-context', () => ({
useLabels: () => ({ incidentPlural: 'incidents' }),
}));
// Create mock incidents
const mockIncidents = Array(25).fill(null).map((_, i) => ({
id: `incident-${i}`,
location: { name: 'Test Location', country: 'US' },
classification: { locationSensitivity: 'standard' },
})) as IncidentDisplay[];
describe('StatsPanel', () => {
it('renders incident counts', () => {
render(<StatsPanel incidents={mockIncidents} totalIncidents={50} />);
expect(screen.getByText('25')).toBeInTheDocument();
expect(screen.getByText(/of 50/)).toBeInTheDocument();
});
it('shows zero state when empty', () => {
render(<StatsPanel incidents={[]} totalIncidents={0} />);
expect(screen.getByText('0')).toBeInTheDocument();
});
});Testing Custom Hooks
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useMapUIState } from '../use-map-ui-state';
describe('useMapUIState', () => {
it('initializes with default state', () => {
const { result } = renderHook(() => useMapUIState());
expect(result.current.sidebarOpen).toBe(true);
expect(result.current.timelineOpen).toBe(true);
});
it('toggles sidebar state', () => {
const { result } = renderHook(() => useMapUIState());
act(() => {
result.current.setSidebarOpen(false);
});
expect(result.current.sidebarOpen).toBe(false);
});
});Testing with Custom Renderers
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { LocationMiniMap } from '../location-mini-map';
// Mock mapbox-gl
vi.mock('mapbox-gl', () => ({
default: {
Map: vi.fn(() => ({
on: vi.fn(),
remove: vi.fn(),
addControl: vi.fn(),
})),
NavigationControl: vi.fn(),
},
}));
describe('LocationMiniMap', () => {
const defaultProps = {
latitude: 33.4484,
longitude: -112.074,
zoom: 10,
};
it('renders map container', () => {
render(<LocationMiniMap {...defaultProps} />);
expect(screen.getByTestId('mini-map')).toBeInTheDocument();
});
});E2E Testing
Writing E2E Tests
import { test, expect } from '@playwright/test';
test.describe('Map Interactions', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Wait for map to load
await page.waitForSelector('.mapboxgl-map');
});
test('displays incident markers', async ({ page }) => {
// Wait for markers to render
await page.waitForSelector('[data-testid="incident-marker"]');
const markers = await page.locator('[data-testid="incident-marker"]').count();
expect(markers).toBeGreaterThan(0);
});
test('opens popup on marker click', async ({ page }) => {
// Click first marker
await page.locator('[data-testid="incident-marker"]').first().click();
// Popup should appear
await expect(page.locator('[data-testid="incident-popup"]')).toBeVisible();
});
test('zooms to marker location', async ({ page }) => {
const marker = page.locator('[data-testid="incident-marker"]').first();
await marker.click();
// Check map zoomed in
const mapZoom = await page.evaluate(() => {
// @ts-ignore
return window.mapInstance?.getZoom();
});
expect(mapZoom).toBeGreaterThan(5);
});
});Testing Filters
import { test, expect } from '@playwright/test';
test.describe('Filters', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.mapboxgl-map');
});
test('filters incidents by date range', async ({ page }) => {
// Get initial count
const initialCount = await page.locator('[data-testid="incident-marker"]').count();
// Set date filter
await page.getByRole('button', { name: /filters/i }).click();
await page.fill('[data-testid="date-start"]', '2024-01-01');
await page.fill('[data-testid="date-end"]', '2024-06-30');
// Wait for filter to apply
await page.waitForTimeout(500);
// Count should change
const filteredCount = await page.locator('[data-testid="incident-marker"]').count();
expect(filteredCount).toBeLessThanOrEqual(initialCount);
});
test('clears all filters', async ({ page }) => {
// Apply some filters first
await page.getByRole('button', { name: /filters/i }).click();
await page.getByRole('checkbox', { name: /radar confirmed/i }).check();
// Clear filters
await page.getByRole('button', { name: /clear all/i }).click();
// Checkbox should be unchecked
await expect(page.getByRole('checkbox', { name: /radar confirmed/i })).not.toBeChecked();
});
});Testing Theme Switching
import { test, expect } from '@playwright/test';
test.describe('Theme', () => {
test('toggles between light and dark mode', async ({ page }) => {
await page.goto('/');
// Click theme toggle
await page.getByRole('button', { name: /toggle theme/i }).click();
// Check dark class is applied
const isDark = await page.evaluate(() =>
document.documentElement.classList.contains('dark')
);
// Toggle again
await page.getByRole('button', { name: /toggle theme/i }).click();
const isLight = await page.evaluate(() =>
!document.documentElement.classList.contains('dark')
);
// One of these should be true depending on initial state
expect(isDark || isLight).toBe(true);
});
});Test Fixtures
Incident Fixtures
import type { Incident } from '@disclosureos/sightline-core';
export const mockIncident: Incident = {
id: 'test-incident-001',
status: 'published',
createdAt: '2024-01-15T00:00:00Z',
updatedAt: '2024-01-15T00:00:00Z',
summary: 'Test incident summary',
temporal: {
date: '2024-01-15',
dateCertainty: 'exact',
time: '21:30:00',
timeCertainty: 'approximate',
},
location: {
name: 'Phoenix, Arizona',
country: 'United States',
latitude: 33.4484,
longitude: -112.074,
siteType: 'urban',
locationSensitivity: 'standard',
},
classification: {
incidentType: 'sighting',
hynekClassification: 'nl',
},
};
export const mockIncidents: Incident[] = [
mockIncident,
{
...mockIncident,
id: 'test-incident-002',
location: {
...mockIncident.location,
name: 'Los Angeles, California',
latitude: 34.0522,
longitude: -118.2437,
},
},
{
...mockIncident,
id: 'test-incident-003',
location: {
...mockIncident.location,
name: 'Chicago, Illinois',
latitude: 41.8781,
longitude: -87.6298,
},
},
];Testing Custom Adapters
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CustomAdapter } from '../custom-adapter';
describe('CustomAdapter', () => {
const mockConfig = {
id: 'custom',
name: 'Custom Source',
adapter: 'custom' as const,
adapterConfig: { endpoint: 'https://api.example.com/data' },
enabled: true,
};
beforeEach(() => {
vi.resetAllMocks();
});
it('transforms external data to Sightline format', async () => {
const externalData = {
data: [
{
uuid: 'ext-001',
event_date: '2024-01-15',
lat: 33.4484,
lng: -112.074,
place_name: 'Phoenix',
description: 'Test event',
category: 'visual',
},
],
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(externalData),
});
const adapter = new CustomAdapter(mockConfig);
const incidents = await adapter.getIncidents();
expect(incidents[0]).toMatchObject({
id: 'ext-001',
temporal: { date: '2024-01-15' },
location: { latitude: 33.4484, longitude: -112.074 },
});
});
});Coverage Reports
Generate and view test coverage:
# Generate coverage report
pnpm test:coverage
# View HTML report
open coverage/index.htmlCoverage thresholds are configured in vitest.config.mts:
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
thresholds: {
lines: 70,
branches: 70,
functions: 70,
statements: 70,
},
},
},
});Best Practices
- Test behavior, not implementation — Focus on what the component does, not how
- Use meaningful test names — Describe what's being tested and expected outcome
- Keep tests independent — Each test should run in isolation
- Mock external dependencies — Don't rely on APIs or databases in unit tests
- Use fixtures — Create reusable test data
- Test edge cases — Empty states, errors, boundary conditions