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 utility
  • species-typeahead.test.ts - Integration tests for species search
  • witness-integration.test.ts - End-to-end witness workflow tests
  • upload.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:

  1. Add breakpoint in test file
  2. Run > Start Debugging
  3. Select "Node.js" configuration
  4. 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