Testing Guide - jra3/mulm GitHub Wiki
Testing Guide
This guide explains how to write and run tests in the Mulm project using Node.js's native test runner.
Overview
Mulm uses Node.js's built-in test runner (available since Node.js 18) for all testing. Tests are written in TypeScript and executed with tsx.
Test Framework: Node.js native test runner (node:test)
TypeScript Execution: tsx (TypeScript Execute)
Assertion Library: Node.js native assertions (node:assert)
Database: In-memory SQLite for test isolation
Key Principles:
- ✅ Each test gets a fresh in-memory database
- ✅ Migrations run automatically for each test
- ✅ Tests are isolated and can run in parallel
- ✅ Mock external services (R2, email, etc.)
- ✅ Use helper utilities for common setup
Running Tests
All Tests
npm test
This runs all *.test.ts files with:
NODE_ENV=test(silences logger output)tsx --test(TypeScript execution with native test runner)
Specific Test File
npm test -- src/__tests__/waitingPeriod.test.ts
Or use tsx directly:
NODE_ENV=test tsx --test src/__tests__/species-typeahead.test.ts
Watch Mode
npm run test:watch
Re-runs tests automatically when files change. Great for rapid feedback during development.
CI/CD
Tests run automatically on:
- Push to repository
- Pull requests
- Pre-deployment checks
Exit code 0 = all tests passed
Exit code 1 = one or more tests failed
Test File Structure
File Naming Convention
src/__tests__/feature-name.test.ts
Examples:
waitingPeriod.test.ts- Unit tests for waiting period utilityspecies-typeahead.test.ts- Integration tests for species searchwitness-integration.test.ts- End-to-end witness workflow testsupload.test.ts- Upload transaction tests
Basic Test Structure
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert';
import { Database, open } from 'sqlite';
import sqlite3 from 'sqlite3';
import { overrideConnection } from '../db/conn';
describe('Feature Name', () => {
let db: Database;
beforeEach(async () => {
// Setup runs before each test
db = await open({
filename: ':memory:',
driver: sqlite3.Database,
});
await db.exec('PRAGMA foreign_keys = ON;');
await db.migrate({ migrationsPath: './db/migrations' });
overrideConnection(db);
});
afterEach(async () => {
// Cleanup runs after each test
if (db) {
await db.close();
}
});
test('should do something specific', async () => {
// Arrange
const input = 'test data';
// Act
const result = await functionUnderTest(input);
// Assert
assert.strictEqual(result, expectedValue);
});
test('should handle edge cases', async () => {
// Test implementation
});
});
Test Types
Unit Tests
Test individual functions or utilities in isolation.
Example: Testing a utility function
// src/__tests__/waitingPeriod.test.ts
import { describe, test } from 'node:test';
import assert from 'node:assert';
import { getRequiredWaitingDays } from '../utils/waitingPeriod';
describe('getRequiredWaitingDays', () => {
test('returns 30 days for marine fish', () => {
const marineSubmission = {
species_type: 'Fish',
species_class: 'Marine'
};
assert.strictEqual(getRequiredWaitingDays(marineSubmission), 30);
});
test('returns 60 days for freshwater fish', () => {
const freshwaterSubmission = {
species_type: 'Fish',
species_class: 'New World'
};
assert.strictEqual(getRequiredWaitingDays(freshwaterSubmission), 60);
});
});
Characteristics:
- No database required
- Fast execution
- Test pure functions
- Focus on logic, not integration
Integration Tests
Test multiple components working together, including database operations.
Example: Testing database queries
// src/__tests__/species-typeahead.test.ts
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert';
import { Database, open } from 'sqlite';
import sqlite3 from 'sqlite3';
import { overrideConnection } from '../db/conn';
import { searchSpeciesTypeahead } from '../db/species';
describe('Species Typeahead Search Tests', () => {
let db: Database;
beforeEach(async () => {
db = await open({
filename: ':memory:',
driver: sqlite3.Database,
});
await db.exec('PRAGMA foreign_keys = ON;');
await db.migrate({ migrationsPath: './db/migrations' });
overrideConnection(db);
// Insert test data
await setupTestSpecies();
});
afterEach(async () => {
if (db) {
await db.close();
}
});
async function setupTestSpecies() {
const groupResult = await db.run(`
INSERT INTO species_name_group (program_class, canonical_genus, canonical_species_name)
VALUES ('Fish', 'Poecilia', 'reticulata')
`);
const groupId = groupResult.lastID as number;
await db.run(`
INSERT INTO species_name (group_id, common_name, scientific_name)
VALUES (?, 'Guppy', 'Poecilia reticulata')
`, [groupId]);
}
test('should search by common name', async () => {
const results = await searchSpeciesTypeahead('guppy');
assert.ok(results.length > 0);
assert.strictEqual(results[0].common_name, 'Guppy');
assert.strictEqual(results[0].scientific_name, 'Poecilia reticulata');
});
test('should be case-insensitive', async () => {
const lowerResults = await searchSpeciesTypeahead('guppy');
const upperResults = await searchSpeciesTypeahead('GUPPY');
assert.strictEqual(lowerResults.length, upperResults.length);
});
});
Characteristics:
- Uses in-memory database
- Tests database queries and business logic
- Tests component interactions
- Still fast (no file I/O)
End-to-End Workflow Tests
Test complete workflows from start to finish.
Example: Testing witness workflow
// src/__tests__/witness-integration.test.ts
describe('Witness Workflow Integration Tests', () => {
let db: Database;
let testMember: TestMember;
let witness: TestMember;
beforeEach(async () => {
db = await open({ filename: ':memory:', driver: sqlite3.Database });
await db.exec('PRAGMA foreign_keys = ON;');
await db.migrate({ migrationsPath: './db/migrations' });
overrideConnection(db);
// Create test users
const memberId = await createMember('[email protected]', 'Test Member');
const witnessId = await createMember('[email protected]', 'Test Witness');
testMember = await getMember(memberId);
witness = await getMember(witnessId);
});
afterEach(async () => {
if (db) await db.close();
});
test('should complete full witness confirmation flow', async () => {
// 1. Create submission with witness
const submissionId = await createTestSubmission(testMember.id);
await db.run(
'UPDATE submissions SET witnessed_by = ?, witness_verification_status = ? WHERE id = ?',
[witness.id, 'pending', submissionId]
);
// 2. Witness confirms
await confirmWitness(submissionId, witness.id);
// 3. Verify status updated
const submission = await getSubmissionById(submissionId);
assert.strictEqual(submission.witness_verification_status, 'confirmed');
});
test('should handle witness decline', async () => {
const submissionId = await createTestSubmission(testMember.id);
await db.run(
'UPDATE submissions SET witnessed_by = ?, witness_verification_status = ? WHERE id = ?',
[witness.id, 'pending', submissionId]
);
// Witness declines
await declineWitness(submissionId, witness.id);
// Verify status
const submission = await getSubmissionById(submissionId);
assert.strictEqual(submission.witness_verification_status, 'declined');
});
});
Characteristics:
- Tests entire user workflows
- Multiple database operations
- Simulates real user actions
- Validates business rules
Test Database Setup
In-Memory SQLite
Every test gets a fresh in-memory database:
beforeEach(async () => {
db = await open({
filename: ':memory:', // Stored in RAM, not disk
driver: sqlite3.Database,
});
// Enable foreign key constraints
await db.exec('PRAGMA foreign_keys = ON;');
// Run all migrations automatically
await db.migrate({
migrationsPath: './db/migrations',
});
// Override global connection for app code
overrideConnection(db);
});
Benefits:
- ✅ Fast (no disk I/O)
- ✅ Isolated (tests don't affect each other)
- ✅ Clean (fresh database every test)
- ✅ Parallel-safe (each test has own database)
Database Helper Utilities
Use shared helpers from src/__tests__/testDbHelper.helper.ts:
import {
setupTestDatabase,
createTestMembers,
createTestSpeciesName,
createTestSubmission
} from './testDbHelper.helper';
describe('My Feature', () => {
let testDb: TestDatabase;
beforeEach(async () => {
testDb = await setupTestDatabase();
});
afterEach(async () => {
await testDb.cleanup();
});
test('should work with test data', async () => {
// Create test members
const [member1, member2] = await createTestMembers(2);
// Create test species
const nameId = await createTestSpeciesName(
testDb.db,
'Test Fish',
'Testus fishus'
);
// Create test submission
const submissionId = await createTestSubmission(
testDb.db,
member1.id,
'Fish',
'Catfish'
);
// Your test assertions
});
});
Available Helpers:
| Helper | Purpose |
|---|---|
setupTestDatabase() |
Creates in-memory DB, runs migrations, returns cleanup function |
createTestMembers(count) |
Creates N members with unique emails |
createTestSpeciesName() |
Creates species name group and name entry |
createTestSubmission() |
Creates a basic submission with required fields |
createMultipleTestSubmissions() |
Bulk create submissions |
Mocking Strategies
Mocking External Services
R2/S3 Client
For upload tests, mock the S3 client:
import { mock } from 'node:test';
import { S3Client } from '@aws-sdk/client-s3';
import { overrideR2Client } from '../utils/r2-client';
describe('Upload Tests', () => {
const mockS3Client = {
send: mock.fn(async () => ({
$metadata: { httpStatusCode: 200 }
}))
} as unknown as S3Client;
beforeEach(async () => {
// ... database setup ...
overrideR2Client(mockS3Client, {
endpoint: 'https://test.r2.cloudflarestorage.com',
accessKeyId: 'test-key',
secretAccessKey: 'test-secret',
bucketName: 'test-bucket',
publicUrl: 'https://test.example.com'
});
});
test('should upload to R2', async () => {
// Test upload functionality
// R2 calls will be mocked
});
});
Email Notifications
Email notifications are automatically skipped in NODE_ENV=test:
// src/utils/logger.ts
export const logger = {
info: (...args) => {
if (process.env.NODE_ENV !== 'test') {
console.log(...args);
}
},
// ...
};
// src/notifications.ts
export async function sendEmail(...) {
if (process.env.NODE_ENV === 'test') {
logger.info('Skipping email in test environment');
return;
}
// Send actual email
}
No additional mocking needed - emails are already suppressed in tests.
Mocking Functions
Use Node.js's built-in mock module:
import { mock } from 'node:test';
test('should call callback', () => {
const mockCallback = mock.fn();
functionUnderTest(mockCallback);
assert.strictEqual(mockCallback.mock.calls.length, 1);
assert.deepStrictEqual(mockCallback.mock.calls[0].arguments, ['expected', 'args']);
});
Assertions
Common Assertions
import assert from 'node:assert';
// Equality
assert.strictEqual(actual, expected);
assert.notStrictEqual(actual, unexpected);
// Deep equality (objects/arrays)
assert.deepStrictEqual(actual, expected);
// Truthiness
assert.ok(value); // truthy
assert.ok(!value); // falsy
// Throws
assert.throws(() => {
functionThatShouldThrow();
}, /Expected error message/);
// Async throws
await assert.rejects(async () => {
await asyncFunctionThatShouldThrow();
}, /Expected error/);
// Custom message
assert.strictEqual(actual, expected, 'Custom failure message');
Advanced Assertions
// Array contains
const results = await getResults();
assert.ok(results.length > 0, 'Should have results');
assert.ok(results.some(r => r.id === 123), 'Should contain item with id=123');
// Object properties
assert.ok(typeof result.id === 'number');
assert.ok(result.email.includes('@'));
assert.ok(result.created_at instanceof Date);
// Negation
assert.ok(!results.some(r => r.deleted), 'Should not contain deleted items');
Testing Patterns
Arrange-Act-Assert (AAA)
test('should calculate total points', async () => {
// Arrange - Set up test data
const [member] = await createTestMembers(1);
await createTestSubmission(db, member.id, 'Fish', 'Catfish');
await db.run('UPDATE submissions SET points = 10, approved_on = ? WHERE member_id = ?',
[new Date().toISOString(), member.id]);
// Act - Execute the code under test
const totalPoints = await getTotalPoints(member.id);
// Assert - Verify the result
assert.strictEqual(totalPoints, 10);
});
Testing Edge Cases
test('should handle empty search query', async () => {
const results = await searchSpeciesTypeahead('');
assert.strictEqual(results.length, 0, 'Empty query should return no results');
});
test('should handle queries shorter than minimum length', async () => {
const results = await searchSpeciesTypeahead('a');
assert.strictEqual(results.length, 0, 'Single character should return no results');
});
test('should handle special characters', async () => {
const results = await searchSpeciesTypeahead("O'Reilly's Fish");
// Should not throw error
assert.ok(Array.isArray(results));
});
Testing Error Conditions
test('should throw error for invalid input', async () => {
await assert.rejects(
async () => await functionUnderTest(invalidInput),
{ message: /Invalid input/ }
);
});
test('should handle database constraint violations', async () => {
const [member] = await createTestMembers(1);
// Insert first record
await db.run('INSERT INTO submissions (...) VALUES (...)');
// Try to insert duplicate (should fail on unique constraint)
await assert.rejects(
async () => await db.run('INSERT INTO submissions (...) VALUES (...)'),
/UNIQUE constraint/
);
});
Testing Transactions
test('should rollback on transaction failure', async () => {
const [member] = await createTestMembers(1);
const submissionId = await createTestSubmission(db, member.id);
// Initial state
await db.run('UPDATE submissions SET points = 10 WHERE id = ?', [submissionId]);
// Attempt transaction that should fail
try {
await db.exec('BEGIN TRANSACTION;');
await db.run('UPDATE submissions SET points = 20 WHERE id = ?', [submissionId]);
await db.run('INSERT INTO submissions (id) VALUES (?)', [submissionId]); // Duplicate, should fail
await db.exec('COMMIT;');
assert.fail('Transaction should have failed');
} catch (error) {
await db.exec('ROLLBACK;').catch(() => {});
}
// Verify rollback
const submission = await db.get('SELECT points FROM submissions WHERE id = ?', submissionId);
assert.strictEqual(submission.points, 10, 'Points should be rolled back to original value');
});
Testing Async Operations
test('should handle concurrent operations', async () => {
const [member] = await createTestMembers(1);
// Create multiple submissions concurrently
const submissionPromises = Array(5).fill(null).map(() =>
createTestSubmission(db, member.id, 'Fish', 'Catfish')
);
const submissionIds = await Promise.all(submissionPromises);
assert.strictEqual(submissionIds.length, 5);
assert.strictEqual(new Set(submissionIds).size, 5, 'All IDs should be unique');
});
Writing Good Tests
Test Naming
✅ Good:
test('should return empty array for queries shorter than 2 characters', async () => { ... });
test('should calculate total points for approved submissions', async () => { ... });
test('should throw error when member not found', async () => { ... });
❌ Bad:
test('test search', async () => { ... });
test('it works', async () => { ... });
test('edge case', async () => { ... });
Pattern: should [expected behavior] when/for [condition]
Test Organization
Group related tests with describe:
describe('Species Search', () => {
describe('query validation', () => {
test('should reject empty queries', async () => { ... });
test('should reject single character queries', async () => { ... });
});
describe('filtering', () => {
test('should filter by species type', async () => { ... });
test('should filter by species class', async () => { ... });
});
describe('sorting', () => {
test('should sort results alphabetically', async () => { ... });
});
});
Test Independence
Each test should be completely independent:
✅ Good:
test('test A', async () => {
const [member] = await createTestMembers(1);
// Test using this member
});
test('test B', async () => {
const [member] = await createTestMembers(1);
// Test using a different member
});
❌ Bad:
let sharedMember; // DON'T share state between tests
test('test A', async () => {
sharedMember = await createTestMembers(1);
});
test('test B', async () => {
// Depends on test A running first - fragile!
await doSomethingWith(sharedMember);
});
Test Focus
Test one thing per test:
✅ Good:
test('should search by common name', async () => {
const results = await searchSpeciesTypeahead('guppy');
assert.ok(results.some(r => r.common_name === 'Guppy'));
});
test('should search by scientific name', async () => {
const results = await searchSpeciesTypeahead('Poecilia');
assert.ok(results.some(r => r.scientific_name.includes('Poecilia')));
});
❌ Bad:
test('should search', async () => {
// Tests too many things at once
const results1 = await searchSpeciesTypeahead('guppy');
assert.ok(results1.length > 0);
const results2 = await searchSpeciesTypeahead('Poecilia');
assert.ok(results2.length > 0);
const results3 = await searchSpeciesTypeahead('');
assert.strictEqual(results3.length, 0);
// If any assertion fails, you don't know which one
});
Common Testing Scenarios
Testing Database Queries
test('should find submissions by member', async () => {
const [member1, member2] = await createTestMembers(2);
// Create submissions for both members
await createTestSubmission(db, member1.id, 'Fish', 'Catfish');
await createTestSubmission(db, member1.id, 'Fish', 'Livebearers');
await createTestSubmission(db, member2.id, 'Plant', 'Anubius');
// Query for member1's submissions
const submissions = await getSubmissionsByMember(member1.id);
assert.strictEqual(submissions.length, 2);
submissions.forEach(sub => {
assert.strictEqual(sub.member_id, member1.id);
});
});
Testing Form Validation
test('should validate required fields', async () => {
const invalidForm = {
species_type: 'Fish',
// Missing required fields
};
const result = bapForm.safeParse(invalidForm);
assert.strictEqual(result.success, false);
assert.ok(result.error.issues.some(i => i.path.includes('species_common_name')));
assert.ok(result.error.issues.some(i => i.path.includes('species_latin_name')));
});
test('should accept valid form', async () => {
const validForm = {
species_type: 'Fish',
species_class: 'Catfish',
species_common_name: 'Bronze Cory',
species_latin_name: 'Corydoras aeneus',
// ... all required fields
};
const result = bapForm.safeParse(validForm);
assert.strictEqual(result.success, true);
});
Testing Calculations
test('should calculate correct level for point total', () => {
const submissions = [10, 10, 10, 15, 15]; // 60 points total
const level = calculateLevel(levelRules['fish'], submissions);
assert.strictEqual(level, 'Breeder'); // 50+ points with category requirements
});
test('should not upgrade level if category requirements not met', () => {
const submissions = [5, 5, 5, 5, 5, 5, 5, 5, 5, 5]; // 50 points, all 5-point
const level = calculateLevel(levelRules['fish'], submissions);
assert.strictEqual(level, 'Hobbyist'); // Missing 20pts from 10/15/20 categories
});
Testing Date Logic
test('should calculate waiting period correctly', () => {
const submission = {
submitted_on: '2025-10-01T10:00:00Z',
species_type: 'Fish',
species_class: 'New World'
};
const status = getWaitingPeriodStatus(submission);
assert.strictEqual(status.requiredDays, 60);
assert.ok(status.daysRemaining >= 0);
});
Debugging Tests
Running Single Test
NODE_ENV=test tsx --test src/__tests__/mytest.test.ts
Adding Debug Output
test('debugging test', async () => {
const results = await searchSpeciesTypeahead('guppy');
console.log('Results:', JSON.stringify(results, null, 2));
assert.ok(results.length > 0);
});
Note: Debug output only shows when test fails or with --test-reporter=spec.
Inspecting Database State
test('debugging database', async () => {
const [member] = await createTestMembers(1);
// Check what's in the database
const allMembers = await db.all('SELECT * FROM members');
console.log('All members:', allMembers);
const allSubmissions = await db.all('SELECT * FROM submissions');
console.log('All submissions:', allSubmissions);
// Your test assertions
});
Using Breakpoints
With VS Code debugger:
- Add breakpoint in test file
- Run > Start Debugging
- Select "Node.js" configuration
- Test will pause at breakpoint
Or use debugger; statement:
test('with debugger', async () => {
const results = await searchSpeciesTypeahead('guppy');
debugger; // Execution pauses here when running with --inspect
assert.ok(results.length > 0);
});
Run with:
NODE_ENV=test node --inspect --loader tsx --test src/__tests__/mytest.test.ts
Best Practices
DO
✅ Write tests for new features before merging
✅ Test edge cases and error conditions
✅ Use descriptive test names
✅ Keep tests focused (one assertion per test when possible)
✅ Use test helpers to reduce boilerplate
✅ Clean up resources in afterEach
✅ Run tests before pushing code
DON'T
❌ Share state between tests ❌ Rely on test execution order ❌ Test implementation details (test behavior, not internals) ❌ Make tests too complex ❌ Skip cleanup (can cause test pollution) ❌ Commit failing tests ❌ Test third-party libraries (trust they're tested)
Test Coverage
Checking Coverage
Currently, test coverage is not automatically measured. To add coverage:
npm install --save-dev c8
Update package.json:
{
"scripts": {
"test:coverage": "c8 npm test"
}
}
Run coverage:
npm run test:coverage
What to Cover
Priority areas:
- Database queries and transactions
- Form validation
- Business logic (level calculation, points, awards)
- API endpoints
- Critical workflows (submission approval, witness confirmation)
Lower priority:
- View rendering (Pug templates)
- Static content
- Third-party integrations (mock them)
Troubleshooting
Tests Hang or Don't Exit
Cause: Database connections not closed
Solution: Ensure afterEach cleanup:
afterEach(async () => {
if (db) {
await db.close();
}
});
Foreign Key Constraint Errors
Cause: Foreign keys not enabled
Solution: Enable in beforeEach:
beforeEach(async () => {
db = await open({ filename: ':memory:', driver: sqlite3.Database });
await db.exec('PRAGMA foreign_keys = ON;');
// ...
});
Migration Errors
Cause: Migration files not found or invalid SQL
Solution: Check migrations path is correct:
await db.migrate({
migrationsPath: './db/migrations', // Relative to project root
});
Unique Constraint Violations
Cause: Reusing test data across tests
Solution: Use unique emails/names in each test:
const email = `test-${Date.now()}-${Math.random()}@test.com`;
Or use the helper:
const [member] = await createTestMembers(1); // Generates unique email
Related Documentation
- Database Schema - Database structure and relationships
- Migration Guide - Database migration system
- CLAUDE.md - Project testing philosophy