Testing Guide
Patterns, conventions, and guidance for writing tests in Mankunku.
Setup
Tests use Vitest with the following configuration (from vite.config.ts):
test: {
include: ['tests/unit/**/*.test.ts'],
environment: 'node',
alias: {
'$lib': './src/lib'
}
}
Running Tests
# Run all unit tests
npm run test:unit
# Watch mode
npx vitest
# Specific file
npx vitest tests/unit/audio/capture.test.ts
# With coverage
npx vitest --coverage
Test File Location
Tests mirror the source structure under tests/unit/:
tests/
└── unit/
├── audio/
│ └── capture.test.ts
└── scoring/
└── note-segmenter.test.ts
Mocking Audio APIs
Browser audio APIs (AudioContext, AnalyserNode, MediaStream) don't exist in the Node test environment. Use mock factories.
Mock AudioContext
function createMockAudioContext() {
return {
createMediaStreamSource: vi.fn(() => ({
connect: vi.fn(),
disconnect: vi.fn()
})),
createAnalyser: vi.fn(() => createMockAnalyser()),
sampleRate: 48000,
currentTime: 0
};
}
Mock AnalyserNode
function createMockAnalyser(fftSize = 4096) {
const buffer = new Float32Array(fftSize);
return {
fftSize,
smoothingTimeConstant: 0,
getFloatTimeDomainData: vi.fn((out: Float32Array) => {
out.set(buffer);
}),
connect: vi.fn(),
disconnect: vi.fn(),
context: { sampleRate: 48000, currentTime: 0 },
_buffer: buffer // exposed for test manipulation
};
}
Mock MediaStream
function createMockStream() {
const track = { stop: vi.fn(), kind: 'audio' };
return {
getTracks: vi.fn(() => [track]),
_track: track
};
}
Mock Navigator
vi.stubGlobal('navigator', {
mediaDevices: {
getUserMedia: vi.fn(async () => mockStream)
},
permissions: {
query: vi.fn()
}
});
Mocking Module Dependencies
Use vi.mock() to mock internal modules before importing the module under test:
let mockAudioCtx: ReturnType<typeof createMockAudioContext>;
vi.mock('$lib/audio/audio-context.ts', () => ({
initAudio: vi.fn(async () => mockAudioCtx)
}));
Reset Between Tests
Audio modules use module-level singletons. Use vi.resetModules() in beforeEach and re-import:
let captureModule: typeof import('$lib/audio/capture.ts');
beforeEach(async () => {
vi.resetModules();
mockAudioCtx = createMockAudioContext();
captureModule = await import('$lib/audio/capture.ts');
});
Testing Patterns
Pure Function Tests
Most scoring, music theory, and phrase modules are pure functions — test directly without mocks:
import { segmentNotes } from '$lib/audio/note-segmenter.ts';
it('uses median MIDI note for robustness', () => {
const readings = [
makeReading(60, 0.1), // C4
makeReading(60, 0.2), // C4
makeReading(61, 0.3), // C#4 (outlier)
];
const notes = segmentNotes(readings, [0.0], 0.5);
expect(notes[0].midi).toBe(60); // median wins
});
Helper Factories
Create helpers for common test data:
function makeReading(midi: number, time: number): PitchReading {
return {
midiFloat: midi,
midi,
cents: 0,
clarity: 0.9,
time,
frequency: 440 * Math.pow(2, (midi - 69) / 12)
};
}
Testing Scoring
import { scorePitch } from '$lib/scoring/pitch-scoring.ts';
it('returns 1.0 for correct MIDI note', () => {
const expected = { pitch: 60, duration: [1, 4], offset: [0, 1] };
const detected = { midi: 60, cents: 0, onsetTime: 0, duration: 0.5, clarity: 0.9 };
expect(scorePitch(expected, detected)).toBe(1.1); // 1.0 + 0.1 intonation bonus
});
it('returns 0.0 for wrong MIDI note', () => {
const expected = { pitch: 60, duration: [1, 4], offset: [0, 1] };
const detected = { midi: 62, cents: 0, onsetTime: 0, duration: 0.5, clarity: 0.9 };
expect(scorePitch(expected, detected)).toBe(0);
});
Testing Adaptive Difficulty
import { createInitialAdaptiveState, processAttempt } from '$lib/difficulty/adaptive.ts';
it('advances after consistent high scores', () => {
let state = createInitialAdaptiveState();
for (let i = 0; i < 10; i++) {
state = processAttempt(state, 0.90, 0.90, 0.90);
}
expect(state.currentLevel).toBeGreaterThan(1);
});
Testing Validation
import { validatePhrase, rulesForDifficulty } from '$lib/phrases/validator.ts';
it('rejects phrases with intervals too large for the level', () => {
const phrase = makePhraseWithInterval(10); // 10 semitones
const rules = rulesForDifficulty(1); // max 5 semitones
const result = validatePhrase(phrase, rules);
expect(result.valid).toBe(false);
});
What to Test
High Priority
- Scoring pipeline — DTW alignment, pitch/rhythm scoring, grade assignment
- Adaptive difficulty — State transitions, level changes
- Note segmentation — Median robustness, onset boundaries, edge cases
- Music theory — Interval math, transposition, scale realization
- Validation — Contour rules, range checks
Medium Priority
- Phrase generator — Output validity, stage pipeline
- Library loader — Query filtering, transposition
- Persistence — Save/load round-trip
Not Testable in Node
- Svelte components — Require browser environment (use Playwright for E2E)
- Audio playback — Requires real AudioContext and Tone.js
- Pitch detection — Requires real audio signal
Tips
- Keep tests focused: one assertion per test when practical
- Use descriptive test names that read as specifications
- Test edge cases: empty arrays, zero values, boundary conditions
- Use
toBeCloseTo()for floating-point comparisons - Don't test implementation details — test behavior and outputs