Unit Testing Document
Parent Document: TEST_PLAN.mdVersion: 1.2 | Date: January 5, 2026
1. Overview
1.1 Purpose
This document defines the unit testing strategy, standards, and test cases for the Dustac Environmental Monitoring Dashboard. Unit tests validate individual functions, components, and modules in isolation.
1.2 Scope
- Service classes and static methods
- Utility functions
- React hooks
- React components (presentation logic)
- Data transformation functions
- Validation logic
1.3 Tools & Framework
| Tool | Version | Purpose |
|---|---|---|
| Vitest | ^2.x | Test runner and assertions |
| React Testing Library | ^14.x | Component testing |
| MSW | ^2.x | API mocking |
| @testing-library/user-event | ^14.x | User interaction simulation |
2. Testing Standards
2.1 Coverage Targets
| Category | Line Coverage | Branch Coverage |
|---|---|---|
| Services | ≥85% | ≥75% |
| Utilities | ≥90% | ≥80% |
| Hooks | ≥80% | ≥70% |
| Components | ≥70% | ≥60% |
2.2 File Naming Convention
src/
├── features/
│ └── reports/
│ ├── services/
│ │ ├── reportDataService.ts
│ │ └── reportDataService.test.ts # Co-located test
│ └── hooks/
│ ├── useReports.ts
│ └── useReports.test.ts
└── lib/
├── utils.ts
└── utils.test.ts2.3 Test Structure (AAA Pattern)
typescript
describe('FunctionName', () => {
it('should [expected behavior] when [condition]', () => {
// Arrange - Setup test data and conditions
const input = { ... };
// Act - Execute the function
const result = functionName(input);
// Assert - Verify the outcome
expect(result).toBe(expected);
});
});3. Test Commands
bash
# Run all unit tests
pnpm test:unit
# Run tests in watch mode
pnpm test:unit -- --watch
# Run specific test file
pnpm test:unit -- src/features/reports/services/reportDataService.test.ts
# Run tests matching pattern
pnpm test:unit -- -t "prepareDeviceTimeline"
# Generate coverage report
pnpm test:unit:coverage
# Run with UI
pnpm test:unit -- --ui4. Unit Test Cases by Module
4.1 Report Data Service (reportDataService.ts)
TC-UNIT-REPORT-001: getMeasurements - Fetch All Measurements
| Field | Value |
|---|---|
| Function | ReportDataService.getMeasurements() |
| Priority | P0 |
| Type | Functional |
Test Code:
typescript
describe('ReportDataService.getMeasurements', () => {
it('should fetch all measurements for upload ID', async () => {
// Arrange
const uploadId = 'test-upload-123';
mockSupabase.from('upload_measurements').select.mockResolvedValue({
data: mockMeasurements,
error: null
});
// Act
const result = await ReportDataService.getMeasurements(uploadId);
// Assert
expect(result).toHaveLength(mockMeasurements.length);
expect(mockSupabase.from).toHaveBeenCalledWith('upload_measurements');
});
it('should filter by csvFileId when provided', async () => {
const uploadId = 'test-upload-123';
const csvFileId = 'csv-file-456';
await ReportDataService.getMeasurements(uploadId, csvFileId);
expect(mockSupabase.eq).toHaveBeenCalledWith('csv_file_id', csvFileId);
});
it('should throw error when Supabase returns error', async () => {
mockSupabase.from('upload_measurements').select.mockResolvedValue({
data: null,
error: { message: 'Database error' }
});
await expect(ReportDataService.getMeasurements('id'))
.rejects.toThrow('Database error');
});
});TC-UNIT-REPORT-002: calculateStatistics - Basic Statistics
| Field | Value |
|---|---|
| Function | ReportDataService.calculateStatistics() |
| Priority | P0 |
| Type | Functional |
Test Code:
typescript
describe('ReportDataService.calculateStatistics', () => {
const mockMeasurements: MeasurementData[] = [
{ time: '2025-01-01T00:00:00Z', numberconcentrations2p5: 10, ... },
{ time: '2025-01-01T01:00:00Z', numberconcentrations2p5: 20, ... },
{ time: '2025-01-01T02:00:00Z', numberconcentrations2p5: 30, ... },
];
it('should calculate correct min, max, avg', () => {
const result = ReportDataService.calculateStatistics(mockMeasurements);
expect(result.particleConcentrations.nc_2_5.min).toBe(10);
expect(result.particleConcentrations.nc_2_5.max).toBe(30);
expect(result.particleConcentrations.nc_2_5.avg).toBe(20);
});
it('should calculate correct median for odd count', () => {
const result = ReportDataService.calculateStatistics(mockMeasurements);
expect(result.particleConcentrations.nc_2_5.median).toBe(20);
});
it('should return zero stats for empty array', () => {
const result = ReportDataService.calculateStatistics([]);
expect(result.totalMeasurements).toBe(0);
});
it('should calculate data quality completeness', () => {
const result = ReportDataService.calculateStatistics(mockMeasurements);
expect(result.dataQuality.completeness).toBeGreaterThan(0);
expect(result.dataQuality.completeness).toBeLessThanOrEqual(100);
});
});TC-UNIT-REPORT-003: prepareDeviceTimeline - Gap Detection
| Field | Value |
|---|---|
| Function | ReportDataService.prepareDeviceTimeline() |
| Priority | P1 |
| Type | Functional |
Test Code:
typescript
describe('ReportDataService.prepareDeviceTimeline', () => {
it('should return empty array for empty measurements', () => {
const result = ReportDataService.prepareDeviceTimeline([]);
expect(result).toEqual([]);
});
it('should detect no gaps when all records within 1 hour', () => {
const measurements = generateMeasurements({
device: 'DEVICE-001',
count: 10,
intervalMinutes: 30 // 30 min intervals
});
const result = ReportDataService.prepareDeviceTimeline(measurements);
expect(result).toHaveLength(1);
expect(result[0].dataGaps).toBe(0);
expect(result[0].segments).toHaveLength(1);
});
it('should detect single gap when >1 hour between records', () => {
const measurements = [
...generateMeasurements({ device: 'DEVICE-001', count: 5, startTime: '2025-01-01T00:00:00Z' }),
...generateMeasurements({ device: 'DEVICE-001', count: 5, startTime: '2025-01-01T03:00:00Z' }) // 2hr gap
];
const result = ReportDataService.prepareDeviceTimeline(measurements);
expect(result[0].dataGaps).toBe(1);
expect(result[0].segments).toHaveLength(2);
});
it('should detect multiple gaps correctly', () => {
const measurements = [
...generateMeasurements({ device: 'DEVICE-001', count: 3, startTime: '2025-01-01T00:00:00Z' }),
...generateMeasurements({ device: 'DEVICE-001', count: 3, startTime: '2025-01-01T05:00:00Z' }),
...generateMeasurements({ device: 'DEVICE-001', count: 3, startTime: '2025-01-01T10:00:00Z' })
];
const result = ReportDataService.prepareDeviceTimeline(measurements);
expect(result[0].dataGaps).toBe(2);
expect(result[0].segments).toHaveLength(3);
});
it('should handle exactly 60 minute gap as NO gap (boundary)', () => {
const measurements = [
{ time: '2025-01-01T00:00:00Z', loc: 'DEVICE-001', ... },
{ time: '2025-01-01T01:00:00Z', loc: 'DEVICE-001', ... } // exactly 60 min
];
const result = ReportDataService.prepareDeviceTimeline(measurements);
expect(result[0].dataGaps).toBe(0);
});
it('should handle 61 minute gap as gap (boundary)', () => {
const measurements = [
{ time: '2025-01-01T00:00:00Z', loc: 'DEVICE-001', ... },
{ time: '2025-01-01T01:01:00Z', loc: 'DEVICE-001', ... } // 61 min
];
const result = ReportDataService.prepareDeviceTimeline(measurements);
expect(result[0].dataGaps).toBe(1);
});
it('should group measurements by device', () => {
const measurements = [
...generateMeasurements({ device: 'DEVICE-001', count: 5 }),
...generateMeasurements({ device: 'DEVICE-002', count: 5 }),
...generateMeasurements({ device: 'DEVICE-003', count: 5 })
];
const result = ReportDataService.prepareDeviceTimeline(measurements);
expect(result).toHaveLength(3);
expect(result.map(d => d.device)).toContain('DEVICE-001');
expect(result.map(d => d.device)).toContain('DEVICE-002');
expect(result.map(d => d.device)).toContain('DEVICE-003');
});
it('should sort devices by firstSeen timestamp', () => {
const measurements = [
{ time: '2025-01-01T10:00:00Z', loc: 'DEVICE-C', ... },
{ time: '2025-01-01T05:00:00Z', loc: 'DEVICE-A', ... },
{ time: '2025-01-01T08:00:00Z', loc: 'DEVICE-B', ... }
];
const result = ReportDataService.prepareDeviceTimeline(measurements);
expect(result[0].device).toBe('DEVICE-A');
expect(result[1].device).toBe('DEVICE-B');
expect(result[2].device).toBe('DEVICE-C');
});
it('should calculate segment record counts correctly', () => {
const measurements = [
...generateMeasurements({ device: 'DEVICE-001', count: 10, startTime: '2025-01-01T00:00:00Z' }),
...generateMeasurements({ device: 'DEVICE-001', count: 5, startTime: '2025-01-01T05:00:00Z' })
];
const result = ReportDataService.prepareDeviceTimeline(measurements);
expect(result[0].segments[0].recordCount).toBe(10);
expect(result[0].segments[1].recordCount).toBe(5);
expect(result[0].recordCount).toBe(15);
});
});TC-UNIT-REPORT-004: applySampling - Data Sampling
| Field | Value |
|---|---|
| Function | ReportDataService.applySampling() |
| Priority | P1 |
| Type | Functional |
Test Code:
typescript
describe('ReportDataService.applySampling', () => {
const data = Array.from({ length: 100 }, (_, i) => ({ id: i }));
it('should return all data when samplingRate is 100', () => {
const result = ReportDataService['applySampling'](data, 100);
expect(result).toHaveLength(100);
});
it('should return approximately 50% when samplingRate is 50', () => {
const result = ReportDataService['applySampling'](data, 50);
expect(result.length).toBeGreaterThan(45);
expect(result.length).toBeLessThan(55);
});
it('should always include first and last data points', () => {
const result = ReportDataService['applySampling'](data, 10);
expect(result[0]).toEqual({ id: 0 });
expect(result[result.length - 1]).toEqual({ id: 99 });
});
it('should handle empty array', () => {
const result = ReportDataService['applySampling']([], 50);
expect(result).toEqual([]);
});
it('should clamp samplingRate to valid range', () => {
const result1 = ReportDataService['applySampling'](data, 0);
expect(result1.length).toBeGreaterThan(0); // clamped to 1%
const result2 = ReportDataService['applySampling'](data, 150);
expect(result2).toHaveLength(100); // clamped to 100%
});
});4.2 CSV Parser Service (csvParser.ts)
TC-UNIT-CSV-001: Parse Valid CSV
typescript
describe('CsvParser.parse', () => {
it('should parse valid CSV with all required columns', async () => {
const csvContent = `time,loc,numberconcentrations2p5,humidity
2025-01-01T00:00:00Z,DEVICE-001,25.5,45
2025-01-01T01:00:00Z,DEVICE-001,30.2,48`;
const result = await CsvParser.parse(csvContent);
expect(result.success).toBe(true);
expect(result.data).toHaveLength(2);
expect(result.data[0].loc).toBe('DEVICE-001');
});
it('should handle UTF-8 BOM correctly', async () => {
const csvWithBom = '\uFEFFtime,loc,value\n2025-01-01,DEVICE,10';
const result = await CsvParser.parse(csvWithBom);
expect(result.success).toBe(true);
expect(result.headers).toContain('time');
});
it('should return error for missing required columns', async () => {
const csvContent = `loc,value\nDEVICE,10`;
const result = await CsvParser.parse(csvContent);
expect(result.success).toBe(false);
expect(result.errors).toContain('Missing required column: time');
});
it('should handle empty file', async () => {
const result = await CsvParser.parse('');
expect(result.success).toBe(false);
});
});4.3 Date Utilities (utils.ts)
TC-UNIT-UTIL-001: Date Formatting
typescript
describe('formatDate', () => {
it('should format date to YYYY-MM-DD', () => {
const date = new Date('2025-01-15T10:30:00Z');
expect(formatDate(date)).toBe('2025-01-15');
});
it('should handle timezone correctly for Perth (AWST)', () => {
const date = new Date('2025-01-15T20:00:00Z'); // 4am next day in Perth
expect(formatDateInPerth(date)).toBe('2025-01-16');
});
});
describe('parseISODate', () => {
it('should parse ISO date string', () => {
const result = parseISODate('2025-01-15T10:30:00Z');
expect(result).toBeInstanceOf(Date);
expect(result.getUTCFullYear()).toBe(2025);
});
it('should return null for invalid date', () => {
expect(parseISODate('invalid')).toBeNull();
});
});4.4 React Hooks
TC-UNIT-HOOK-001: useReports Hook
typescript
describe('useReports', () => {
it('should initialize with empty reports array', () => {
const { result } = renderHook(() => useReports());
expect(result.current.reports).toEqual([]);
expect(result.current.loading).toBe(true);
});
it('should fetch reports on mount', async () => {
mockReportService.fetchReports.mockResolvedValue(mockReports);
const { result } = renderHook(() => useReports());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.reports).toEqual(mockReports);
});
it('should set error state on fetch failure', async () => {
mockReportService.fetchReports.mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useReports());
await waitFor(() => {
expect(result.current.error).toBe('Network error');
});
});
});4.5 React Components
TC-UNIT-COMP-001: DeviceTimelineChartECharts
typescript
describe('DeviceTimelineChartECharts', () => {
const mockData: DeviceTimelineDataPoint[] = [
{
device: 'DEVICE-001',
firstSeen: new Date('2025-01-01T00:00:00Z'),
lastSeen: new Date('2025-01-01T10:00:00Z'),
totalDuration: 10,
recordCount: 100,
dataGaps: 1,
segments: [
{ start: new Date('2025-01-01T00:00:00Z'), end: new Date('2025-01-01T04:00:00Z'), recordCount: 50 },
{ start: new Date('2025-01-01T06:00:00Z'), end: new Date('2025-01-01T10:00:00Z'), recordCount: 50 }
]
}
];
it('should render chart container', () => {
render(<DeviceTimelineChartECharts data={mockData} />);
expect(screen.getByRole('img')).toBeInTheDocument(); // ECharts renders as img role
});
it('should display device names', () => {
render(<DeviceTimelineChartECharts data={mockData} />);
expect(screen.getByText('DEVICE-001')).toBeInTheDocument();
});
it('should apply dark variant styles', () => {
const { container } = render(
<DeviceTimelineChartECharts data={mockData} variant="dark" />
);
// Check that dark theme classes are applied
expect(container.firstChild).toHaveStyle({ background: expect.any(String) });
});
it('should handle empty data gracefully', () => {
render(<DeviceTimelineChartECharts data={[]} />);
expect(screen.getByText(/no data/i)).toBeInTheDocument();
});
it('should render correct number of segments', () => {
render(<DeviceTimelineChartECharts data={mockData} />);
// Each segment should be rendered as a bar
const segments = screen.getAllByTestId('timeline-segment');
expect(segments).toHaveLength(2);
});
});5. Test Helpers & Utilities
5.1 Mock Data Generators
typescript
// src/test/helpers/generators.ts
export function generateMeasurements(options: {
device: string;
count: number;
startTime?: string;
intervalMinutes?: number;
}): MeasurementData[] {
const { device, count, startTime = '2025-01-01T00:00:00Z', intervalMinutes = 15 } = options;
const start = new Date(startTime);
return Array.from({ length: count }, (_, i) => ({
time: new Date(start.getTime() + i * intervalMinutes * 60000).toISOString(),
loc: device,
site: 'TEST-SITE',
numberconcentrations0p5: Math.random() * 100,
numberconcentrations1p0: Math.random() * 80,
numberconcentrations2p5: Math.random() * 50,
numberconcentrations4p0: Math.random() * 30,
numberconcentrations10p0: Math.random() * 20,
roadtemperature: 25 + Math.random() * 10,
humidity: 40 + Math.random() * 30,
road_corrugations: Math.random() * 5
}));
}
export function generateDeviceTimelineData(options: {
deviceCount: number;
gapsPerDevice?: number;
}): DeviceTimelineDataPoint[] {
const { deviceCount, gapsPerDevice = 0 } = options;
return Array.from({ length: deviceCount }, (_, i) => ({
device: `DEVICE-${String(i + 1).padStart(3, '0')}`,
firstSeen: new Date('2025-01-01T00:00:00Z'),
lastSeen: new Date('2025-01-01T23:59:59Z'),
totalDuration: 24,
recordCount: 100,
dataGaps: gapsPerDevice,
segments: Array.from({ length: gapsPerDevice + 1 }, (_, j) => ({
start: new Date(`2025-01-01T${String(j * 6).padStart(2, '0')}:00:00Z`),
end: new Date(`2025-01-01T${String((j + 1) * 6 - 1).padStart(2, '0')}:59:59Z`),
recordCount: Math.floor(100 / (gapsPerDevice + 1))
}))
}));
}5.2 Supabase Mock
typescript
// src/test/mocks/supabase.ts
export const mockSupabase = {
from: vi.fn(() => ({
select: vi.fn().mockReturnThis(),
insert: vi.fn().mockReturnThis(),
update: vi.fn().mockReturnThis(),
delete: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
range: vi.fn().mockReturnThis(),
single: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockReturnThis()
})),
auth: {
getSession: vi.fn(),
signInWithPassword: vi.fn(),
signOut: vi.fn()
},
storage: {
from: vi.fn(() => ({
upload: vi.fn(),
download: vi.fn(),
createSignedUrl: vi.fn()
}))
}
};
vi.mock('@/lib/supabase', () => ({
supabase: mockSupabase
}));6. Test Configuration
6.1 Vitest Config (vitest.config.ts)
typescript
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*'
],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80
}
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
});6.2 Test Setup (src/test/setup.ts)
typescript
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
// Cleanup after each test
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
// Mock window.matchMedia
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()
}))
});
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn()
}));7. Coverage Report
Run pnpm test:unit:coverage to generate coverage report.
Current Coverage Status:
| Module | Lines | Branches | Functions |
|---|---|---|---|
| reportDataService | 87% | 78% | 90% |
| csvParser | 92% | 85% | 95% |
| utils | 95% | 88% | 100% |
| useReports | 82% | 75% | 85% |
| Components | 71% | 62% | 75% |
Related Documents:
- TEST_PLAN.md - Main test plan
- TEST_INTEGRATION.md - Integration tests
- TEST_E2E.md - End-to-end tests