Files
metabuilder/docs/testing/TESTING_GUIDELINES.md
johndoe6345789 a5093a4119 Add comprehensive documentation for MetaBuilder, including security guidelines and unit testing best practices
- Introduced DOCUMENTATION_FINDINGS.md for a detailed analysis of project documentation, covering architecture, technology stack, completed features, and known issues.
- Created security documentation in README.md and SECURITY.md, outlining security policies, best practices, and incident response procedures.
- Added TESTING_GUIDELINES.md to establish unit testing best practices, including directory structure, parameterized tests, and test coverage enforcement.
2025-12-25 12:46:32 +00:00

8.7 KiB

Unit Testing Guidelines

This document outlines best practices for ensuring every function maps to at least one unit test.

Overview

Every exported function in the MetaBuilder codebase should have corresponding unit tests. Tests should be:

  • Comprehensive: Cover normal cases, edge cases, and error conditions
  • Parameterized: Use it.each() to reduce test duplication
  • Focused: Test one behavior per test case
  • Maintainable: Clear descriptions and organized structure

Directory Structure

Test files should be placed alongside source files with the .test.ts or .test.tsx suffix:

src/
  lib/
    utils.ts
    utils.test.ts          ← Test file for utils.ts
    schema-utils.ts
    schema-utils.test.ts   ← Test file for schema-utils.ts
  hooks/
    useKV.ts
    useKV.test.ts

Test File Naming Conventions

  • Source file: functionName.ts
  • Test file: functionName.test.ts
  • E2E tests: *.spec.ts (in e2e/ directory)

Parameterized Tests

Use it.each() to test multiple related scenarios. This reduces code duplication and improves maintainability.

Example: Testing Multiple Scenarios

describe('validateField', () => {
  it.each([
    { field: { name: 'email', type: 'email', required: true }, value: '', shouldError: true },
    { field: { name: 'email', type: 'email' }, value: 'test@example.com', shouldError: false },
    { field: { name: 'age', type: 'number', validation: { min: 0, max: 150 } }, value: 25, shouldError: false },
  ])('should validate $description', ({ field, value, shouldError }) => {
    const result = validateField(field, value)
    if (shouldError) {
      expect(result).toBeTruthy()
    } else {
      expect(result).toBeNull()
    }
  })
})

Benefits

  • DRY principle: Write test data once, execute multiple times
  • Easy to add new test cases: Just add to the array
  • Better error messages: Failed test shows which case failed
  • Clearer intent: Immediately see all scenarios being tested

Test Structure

import { describe, it, expect, beforeEach, vi } from 'vitest'
import { myFunction } from '@/lib/my-module'

describe('myFunction', () => {
  // Setup - runs before each test
  beforeEach(() => {
    // Reset state, mock data, etc.
  })

  // Group related tests
  describe('with valid input', () => {
    it.each([...])('should $description', ...)
  })

  describe('with invalid input', () => {
    it.each([...])('should $description', ...)
  })

  describe('edge cases', () => {
    it.each([...])('should $description', ...)
  })
})

Testing Different Function Types

Pure Functions (No Side Effects)

describe('cn', () => {
  it.each([
    { input: ['px-2', 'py-1'], expected: 'px-2 py-1' },
    { input: ['px-2', 'px-3'], shouldNotContain: 'px-2' },
  ])('should handle $description', ({ input, expected }) => {
    const result = cn(...input)
    expect(result).toEqual(expected)
  })
})

Async Functions

describe('initializePackageSystem', () => {
  it('should initialize without errors', async () => {
    await expect(initializePackageSystem()).resolves.not.toThrow()
  })

  it.each([
    { callCount: 1 },
    { callCount: 2 },
    { callCount: 3 },
  ])('should be idempotent after $callCount calls', async ({ callCount }) => {
    for (let i = 0; i < callCount; i++) {
      await initializePackageSystem()
    }
    expect(true).toBe(true) // No errors thrown
  })
})

React Hooks

import { renderHook, act } from '@testing-library/react'

describe('useIsMobile', () => {
  it.each([
    { width: 400, expected: true },
    { width: 768, expected: false },
    { width: 1024, expected: false },
  ])('should return $expected for width $width', ({ width, expected }) => {
    Object.defineProperty(window, 'innerWidth', {
      writable: true,
      value: width,
    })

    const { result } = renderHook(() => useIsMobile())
    expect(result.current).toBe(expected)
  })
})

Functions with Side Effects

describe('updateValue', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it.each([
    { key: 'user_name', value: 'John' },
    { key: 'user_count', value: 0 },
  ])('should update $key with $value', async ({ key, value }) => {
    const saveSpy = vi.fn()
    await updateValue(key, value, saveSpy)
    expect(saveSpy).toHaveBeenCalledWith(key, value)
  })
})

Testing Best Practices

1. Test Coverage

Every exported function should have at least one test covering:

  • Happy path (normal operation)
  • Edge cases (null, undefined, empty arrays)
  • Error conditions (if applicable)
// ✅ Good: Tests multiple scenarios
it.each([
  { input: 'valid@email.com', expected: true },
  { input: 'invalid', expected: false },
  { input: '', expected: false },
  { input: null, expected: false },
])('should validate email', ({ input, expected }) => {
  // ...
})

2. Clear Test Descriptions

Use descriptive names that explain what is being tested:

// ✅ Good
it('should return true for valid email address')

// ❌ Poor
it('works')

// ✅ Good with parameterized
it.each([...])('should $description for $input', ...)

// ❌ Poor with parameterized
it.each([...])('test $input', ...)

3. Arrange-Act-Assert Pattern

Organize tests into three clear sections:

it('should update value correctly', () => {
  // ARRANGE: Set up test data
  const initialValue = 10
  const increment = 5

  // ACT: Execute the function
  const result = add(initialValue, increment)

  // ASSERT: Verify the result
  expect(result).toBe(15)
})

4. Isolation and Independence

Tests should not depend on other tests or shared state:

// ✅ Good: Each test is independent
describe('userService', () => {
  beforeEach(() => {
    database.clear()
  })

  it('should create user', () => {
    const user = userService.create({ name: 'John' })
    expect(user.id).toBeDefined()
  })

  it('should retrieve user', () => {
    const user = userService.create({ name: 'Jane' })
    const retrieved = userService.get(user.id)
    expect(retrieved.name).toBe('Jane')
  })
})

5. Mocking External Dependencies

Use vi.fn() and vi.mock() for testing functions with external dependencies:

import { vi } from 'vitest'

describe('fetchUser', () => {
  it.each([
    { userId: 1, name: 'John' },
    { userId: 2, name: 'Jane' },
  ])('should fetch user $userId', async ({ userId, name }) => {
    // Mock the API call
    const mockFetch = vi.fn().mockResolvedValue({ name })

    const result = await fetchUser(userId, mockFetch)
    expect(result.name).toBe(name)
    expect(mockFetch).toHaveBeenCalledWith(userId)
  })
})

Creating Tests for New Functions

When adding a new exported function:

  1. Create test file: Add functionName.test.ts next to the source file
  2. Write tests: Use it.each() for multiple scenarios
  3. Test coverage: Include happy path, edge cases, error conditions
  4. Run tests: Execute npm test -- --run to verify
  5. Check coverage: Use npm test -- --coverage to verify coverage

Running Tests

# Run all tests once
npm test -- --run

# Run tests in watch mode
npm test

# Run specific test file
npm test -- src/lib/schema-utils.test.ts

# Run with coverage
npm test -- --coverage

# Generate coverage report
node scripts/generate-test-coverage-report.js

Function-to-Test Mapping

Current test coverage can be viewed in FUNCTION_TEST_COVERAGE.md.

The report shows:

  • Functions with test coverage
  • Functions needing test coverage
  • Total functions: 185+
  • Total test cases: 6000+

Test Examples

Schema Utils Tests

View full test - Demonstrates:

  • Parameterized tests with it.each()
  • Testing utility functions
  • Edge case coverage
  • Sorting and filtering logic

Utils Tests

View full test - Demonstrates:

  • Testing pure functions
  • Parameterized tests for similar scenarios
  • Falsy value handling

Package Loader Tests

View full test - Demonstrates:

  • Testing async functions
  • Mock usage
  • Testing for idempotency

Enforcement

To ensure all functions have tests:

  1. Pre-commit hooks: Run npm test -- --run before commit
  2. CI/CD: Tests must pass before merging PR
  3. Code review: Check that new functions have corresponding tests
  4. Coverage reports: Generate monthly coverage reports

Resources