- 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.
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(ine2e/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:
- Create test file: Add
functionName.test.tsnext to the source file - Write tests: Use
it.each()for multiple scenarios - Test coverage: Include happy path, edge cases, error conditions
- Run tests: Execute
npm test -- --runto verify - Check coverage: Use
npm test -- --coverageto 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:
- Pre-commit hooks: Run
npm test -- --runbefore commit - CI/CD: Tests must pass before merging PR
- Code review: Check that new functions have corresponding tests
- Coverage reports: Generate monthly coverage reports