Testing Guide
This guide covers testing patterns and best practices for Wellpipe packages. All packages use Vitest as the test runner.
Running Tests
# Run all tests
npm test
# Run tests in watch mode
npm test -- --watch
# Run tests for a specific file
npm test -- src/client.test.ts
# Run tests with coverage
npm test -- --coverage
Test Organization
Tests are colocated with source files:
src/
├── client.ts
├── client.test.ts
├── tools/
│ ├── sleep-tools.ts
│ └── sleep-tools.test.ts
└── normalizers/
├── sleep.ts
└── sleep.test.ts
Mocking the API Client
Use the mock client for testing tools and normalizers:
import { describe, it, expect } from 'vitest';
import { MockWhoopClient } from '../testing';
describe('getSleepTrend', () => {
it('returns trend for valid data', async () => {
const client = new MockWhoopClient();
// Set up mock data
client.setSleepData([
{
id: 'sleep-1',
providerId: '1',
start: '2024-01-01T23:00:00.000Z',
end: '2024-01-02T07:00:00.000Z',
durationMs: 28800000,
isNap: false,
score: 85,
},
{
id: 'sleep-2',
providerId: '2',
start: '2024-01-02T23:00:00.000Z',
end: '2024-01-03T07:00:00.000Z',
durationMs: 27000000,
isNap: false,
score: 78,
},
]);
const result = await getSleepTrend({ days: 7 }, client);
expect(result.sessions).toBe(2);
expect(result.averageScore).toBe(81.5);
});
});
MockWhoopClient API
The mock client implements HealthProvider and allows setting test data:
class MockWhoopClient implements HealthProvider {
readonly name = 'whoop-mock';
// Set mock data
setSleepData(data: SleepData[]): void;
setRecoveryData(data: RecoveryData[]): void;
setWorkoutData(data: WorkoutData[]): void;
// HealthProvider methods (return mock data)
getSleep(startDate: string, endDate: string): Promise<SleepData[]>;
getRecovery(startDate: string, endDate: string): Promise<RecoveryData[]>;
getWorkouts(startDate: string, endDate: string): Promise<WorkoutData[]>;
// Extended methods
getCycles(startDate: string, endDate: string): Promise<CycleData[]>;
getProfile(): Promise<UserProfile>;
getBodyMeasurement(): Promise<BodyMeasurement>;
// Simulate errors
setError(error: Error): void;
clearError(): void;
// Track calls
getCalls(): MethodCall[];
clearCalls(): void;
}
Setting Mock Data
const client = new MockWhoopClient();
// Sleep data
client.setSleepData([
{
id: 'sleep-1',
providerId: '1',
start: '2024-01-15T23:00:00.000Z',
end: '2024-01-16T07:00:00.000Z',
durationMs: 28800000,
isNap: false,
score: 85,
stages: {
lightMs: 14400000,
deepMs: 7200000,
remMs: 5400000,
awakeMs: 1800000,
},
metrics: {
efficiency: 93.5,
respiratoryRate: 14.2,
},
},
]);
// Recovery data
client.setRecoveryData([
{
id: 'recovery-1',
providerId: '1',
date: '2024-01-16',
score: 72,
metrics: {
hrv: 45.5,
restingHeartRate: 52,
spo2: 97.8,
},
},
]);
// Workout data
client.setWorkoutData([
{
id: 'workout-1',
providerId: '1',
start: '2024-01-16T06:00:00.000Z',
end: '2024-01-16T07:00:00.000Z',
durationMs: 3600000,
sportType: 'running',
strain: 12.5,
metrics: {
averageHeartRate: 155,
maxHeartRate: 175,
calories: 450,
distance: 8000,
},
},
]);
Simulating Errors
it('handles API errors', async () => {
const client = new MockWhoopClient();
client.setError(new Error('Rate limit exceeded'));
await expect(client.getSleep('2024-01-01', '2024-01-07'))
.rejects.toThrow('Rate limit exceeded');
});
Tracking Calls
it('queries correct date range', async () => {
const client = new MockWhoopClient();
client.setSleepData([]);
await getSleepTrend({ days: 14 }, client);
const calls = client.getCalls();
expect(calls).toHaveLength(1);
expect(calls[0].method).toBe('getSleep');
// Check date range is 14 days
});
Testing Zod Schemas
Test schema validation separately:
import { describe, it, expect } from 'vitest';
import { GetSleepTrendSchema } from './schemas';
describe('GetSleepTrendSchema', () => {
it('accepts valid input', () => {
const result = GetSleepTrendSchema.parse({
days: 14,
includeNaps: true,
});
expect(result.days).toBe(14);
expect(result.includeNaps).toBe(true);
});
it('applies defaults', () => {
const result = GetSleepTrendSchema.parse({});
expect(result.days).toBe(30);
expect(result.includeNaps).toBe(false);
});
it('rejects days below minimum', () => {
expect(() => GetSleepTrendSchema.parse({ days: 3 }))
.toThrow();
});
it('rejects days above maximum', () => {
expect(() => GetSleepTrendSchema.parse({ days: 100 }))
.toThrow();
});
it('rejects invalid types', () => {
expect(() => GetSleepTrendSchema.parse({ days: 'seven' }))
.toThrow();
});
});
Testing Normalizers
Test data transformation logic:
import { describe, it, expect } from 'vitest';
import { normalizeSleep } from './sleep';
describe('normalizeSleep', () => {
it('normalizes WHOOP sleep record', () => {
const whoopRecord = {
id: 12345,
start: '2024-01-15T23:00:00.000Z',
end: '2024-01-16T07:00:00.000Z',
time_in_bed_milliseconds: 28800000,
nap: false,
score: {
sleep_performance_percentage: 85,
sleep_efficiency_percentage: 93.5,
respiratory_rate: 14.2,
stage_summary: {
total_light_sleep_time_milli: 14400000,
total_slow_wave_sleep_time_milli: 7200000,
total_rem_sleep_time_milli: 5400000,
total_awake_time_milli: 1800000,
},
},
};
const result = normalizeSleep(whoopRecord);
expect(result).toEqual({
id: 'whoop-12345',
providerId: '12345',
start: '2024-01-15T23:00:00.000Z',
end: '2024-01-16T07:00:00.000Z',
durationMs: 28800000,
isNap: false,
score: 85,
stages: {
lightMs: 14400000,
deepMs: 7200000,
remMs: 5400000,
awakeMs: 1800000,
},
metrics: {
efficiency: 93.5,
respiratoryRate: 14.2,
},
});
});
it('handles missing score', () => {
const whoopRecord = {
id: 12345,
start: '2024-01-15T23:00:00.000Z',
end: '2024-01-16T07:00:00.000Z',
time_in_bed_milliseconds: 28800000,
nap: false,
score: null,
};
const result = normalizeSleep(whoopRecord);
expect(result.score).toBeUndefined();
expect(result.stages).toBeUndefined();
expect(result.metrics).toBeUndefined();
});
it('identifies naps correctly', () => {
const napRecord = {
id: 12345,
start: '2024-01-15T14:00:00.000Z',
end: '2024-01-15T14:30:00.000Z',
time_in_bed_milliseconds: 1800000,
nap: true,
score: null,
};
const result = normalizeSleep(napRecord);
expect(result.isNap).toBe(true);
});
});
Testing Token Providers
Mock external dependencies:
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DatabaseTokenProvider } from './database-token-provider';
describe('DatabaseTokenProvider', () => {
let mockDb: {
getTokens: ReturnType<typeof vi.fn>;
updateTokens: ReturnType<typeof vi.fn>;
};
let mockRefresh: ReturnType<typeof vi.fn>;
let provider: DatabaseTokenProvider;
beforeEach(() => {
mockDb = {
getTokens: vi.fn(),
updateTokens: vi.fn(),
};
mockRefresh = vi.fn();
provider = new DatabaseTokenProvider('user-123', mockDb, mockRefresh);
});
it('returns access token when not expired', async () => {
mockDb.getTokens.mockResolvedValue({
accessToken: 'valid-token',
expiresAt: new Date(Date.now() + 3600000),
});
const token = await provider.getAccessToken();
expect(token).toBe('valid-token');
expect(mockRefresh).not.toHaveBeenCalled();
});
it('refreshes expired token', async () => {
mockDb.getTokens.mockResolvedValue({
accessToken: 'expired-token',
refreshToken: 'refresh-token',
expiresAt: new Date(Date.now() - 1000),
});
mockRefresh.mockResolvedValue({
accessToken: 'new-token',
refreshToken: 'new-refresh',
expiresIn: 3600,
});
const token = await provider.getAccessToken();
expect(token).toBe('new-token');
expect(mockRefresh).toHaveBeenCalledWith('refresh-token');
expect(mockDb.updateTokens).toHaveBeenCalled();
});
it('throws when refresh token missing', async () => {
mockDb.getTokens.mockResolvedValue({
accessToken: 'expired-token',
expiresAt: new Date(Date.now() - 1000),
});
await expect(provider.getAccessToken())
.rejects.toThrow('No refresh token available');
});
});
Integration Testing
For integration tests that hit real APIs (use sparingly):
import { describe, it, expect } from 'vitest';
// Skip in CI, only run manually
describe.skipIf(!process.env.WHOOP_ACCESS_TOKEN)('WHOOP API Integration', () => {
it('fetches sleep data', async () => {
const client = new WhoopClient(new EnvTokenProvider());
const sleep = await client.getSleep('2024-01-01', '2024-01-07');
expect(Array.isArray(sleep)).toBe(true);
if (sleep.length > 0) {
expect(sleep[0]).toHaveProperty('id');
expect(sleep[0]).toHaveProperty('start');
expect(sleep[0]).toHaveProperty('durationMs');
}
});
});
Test Data Factories
Create factories for consistent test data:
// testing/factories.ts
import { SleepData, RecoveryData, WorkoutData } from '@wellpipe/core';
let idCounter = 0;
export function createSleepData(overrides: Partial<SleepData> = {}): SleepData {
const id = ++idCounter;
return {
id: `sleep-${id}`,
providerId: String(id),
start: '2024-01-15T23:00:00.000Z',
end: '2024-01-16T07:00:00.000Z',
durationMs: 28800000,
isNap: false,
score: 85,
stages: {
lightMs: 14400000,
deepMs: 7200000,
remMs: 5400000,
awakeMs: 1800000,
},
...overrides,
};
}
export function createRecoveryData(overrides: Partial<RecoveryData> = {}): RecoveryData {
const id = ++idCounter;
return {
id: `recovery-${id}`,
providerId: String(id),
date: '2024-01-16',
score: 72,
metrics: {
hrv: 45,
restingHeartRate: 52,
},
...overrides,
};
}
export function createWorkoutData(overrides: Partial<WorkoutData> = {}): WorkoutData {
const id = ++idCounter;
return {
id: `workout-${id}`,
providerId: String(id),
start: '2024-01-16T06:00:00.000Z',
end: '2024-01-16T07:00:00.000Z',
durationMs: 3600000,
sportType: 'running',
strain: 12.5,
...overrides,
};
}
Usage:
import { createSleepData } from '../testing/factories';
it('calculates average score', () => {
const data = [
createSleepData({ score: 80 }),
createSleepData({ score: 90 }),
];
expect(averageScore(data)).toBe(85);
});
Coverage Requirements
Aim for high coverage on critical paths:
| Area | Target |
|---|---|
| Core interfaces | 100% |
| Normalizers | 95% |
| Tool handlers | 90% |
| API clients | 80% |
Run coverage report:
npm test -- --coverage