Unit Testing Expert
Self-contained unit testing expertise for Vitest/Jest in ANY user project.
Test-Driven Development (TDD)
Red-Green-Refactor Cycle:
// 1. RED: Write failing test describe('Calculator', () => { it('should add two numbers', () => { const calc = new Calculator(); expect(calc.add(2, 3)).toBe(5); }); });
// 2. GREEN: Minimal implementation class Calculator { add(a: number, b: number): number { return a + b; } }
// 3. REFACTOR: Improve code class Calculator { add(...numbers: number[]): number { return numbers.reduce((sum, n) => sum + n, 0); } }
TDD Benefits:
-
Better design (testable code)
-
Living documentation
-
Faster debugging
-
Higher confidence
Vitest/Jest Fundamentals
Basic Test Structure
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { UserService } from './UserService';
describe('UserService', () => { let service: UserService;
beforeEach(() => { service = new UserService(); });
afterEach(() => { vi.clearAllMocks(); });
it('should create user', () => { const user = service.create({ name: 'John', email: 'john@test.com' });
expect(user).toMatchObject({
id: expect.any(String),
name: 'John',
email: 'john@test.com'
});
});
it('should throw for invalid email', () => { expect(() => { service.create({ name: 'John', email: 'invalid' }); }).toThrow('Invalid email'); }); });
Async Testing
it('should fetch user from API', async () => { const user = await api.fetchUser('user-123');
expect(user).toEqual({ id: 'user-123', name: 'John Doe' }); });
// Testing async errors it('should handle API errors', async () => { await expect(api.fetchUser('invalid')).rejects.toThrow('User not found'); });
Mocking Strategies
- Mock Functions
// Mock a function const mockFn = vi.fn(); mockFn.mockReturnValue(42); expect(mockFn()).toBe(42);
// Mock with implementation const mockAdd = vi.fn((a, b) => a + b); expect(mockAdd(2, 3)).toBe(5);
// Verify calls expect(mockFn).toHaveBeenCalledTimes(1); expect(mockFn).toHaveBeenCalledWith(expected);
- Mock Modules
// Mock entire module vi.mock('./database', () => ({ query: vi.fn().mockResolvedValue([{ id: 1, name: 'Test' }]) }));
import { query } from './database';
it('should fetch users from database', async () => { const users = await query('SELECT * FROM users'); expect(users).toHaveLength(1); });
- Spies
// Spy on existing method const spy = vi.spyOn(console, 'log');
myFunction();
expect(spy).toHaveBeenCalledWith('Expected message'); spy.mockRestore();
- Mock Dependencies
class UserService { constructor(private db: Database) {}
async getUser(id: string) { return this.db.query('SELECT * FROM users WHERE id = ?', [id]); } }
// Test with mock const mockDb = { query: vi.fn().mockResolvedValue({ id: '123', name: 'John' }) };
const service = new UserService(mockDb); const user = await service.getUser('123');
expect(mockDb.query).toHaveBeenCalledWith( 'SELECT * FROM users WHERE id = ?', ['123'] );
Test Patterns
AAA Pattern (Arrange-Act-Assert)
it('should calculate total price', () => { // Arrange const cart = new ShoppingCart(); cart.addItem({ price: 10, quantity: 2 }); cart.addItem({ price: 5, quantity: 3 });
// Act const total = cart.getTotal();
// Assert expect(total).toBe(35); });
Given-When-Then (BDD)
describe('Shopping Cart', () => { it('should apply discount when total exceeds $100', () => { // Given: A cart with items totaling $120 const cart = new ShoppingCart(); cart.addItem({ price: 120, quantity: 1 });
// When: Getting the total
const total = cart.getTotal();
// Then: 10% discount applied
expect(total).toBe(108); // $120 - $12 (10%)
}); });
Parametric Testing
describe.each([
[2, 3, 5],
[10, 5, 15],
[-1, 1, 0],
[0, 0, 0]
])('Calculator.add(%i, %i)', (a, b, expected) => {
it(should return ${expected}, () => {
const calc = new Calculator();
expect(calc.add(a, b)).toBe(expected);
});
});
Test Doubles
Mocks vs Stubs vs Spies vs Fakes
Mock: Verifies behavior (calls, arguments)
const mock = vi.fn(); mock('test'); expect(mock).toHaveBeenCalledWith('test');
Stub: Returns predefined values
const stub = vi.fn().mockReturnValue(42); expect(stub()).toBe(42);
Spy: Observes real function
const spy = vi.spyOn(obj, 'method'); obj.method(); expect(spy).toHaveBeenCalled();
Fake: Working implementation (simplified)
class FakeDatabase { private data = new Map();
async save(key, value) { this.data.set(key, value); }
async get(key) { return this.data.get(key); } }
Coverage Analysis
Running Coverage
Vitest
vitest --coverage
Jest
jest --coverage
Coverage Thresholds
// vitest.config.ts export default { test: { coverage: { provider: 'v8', reporter: ['text', 'html', 'lcov'], lines: 80, functions: 80, branches: 80, statements: 80 } } };
Coverage Best Practices
✅ DO:
-
Aim for 80-90% coverage
-
Focus on business logic
-
Test edge cases
-
Test error paths
❌ DON'T:
-
Chase 100% coverage
-
Test getters/setters only
-
Test framework code
-
Write tests just for coverage
Snapshot Testing
When to Use Snapshots
Good use cases:
-
UI component output
-
API responses
-
Configuration objects
-
Error messages
it('should render user card', () => { const card = renderUserCard({ name: 'John', role: 'Admin' }); expect(card).toMatchSnapshot(); });
// Update snapshots: vitest -u
Avoid snapshots for:
-
Dates/timestamps
-
Random values
-
Large objects (prefer specific assertions)
Test Organization
File Structure
src/ ├── services/ │ ├── UserService.ts │ └── UserService.test.ts ← Co-located tests/ ├── unit/ │ └── utils.test.ts ├── integration/ │ └── api.test.ts └── fixtures/ └── users.json
Test Naming
✅ GOOD:
describe('UserService.create', () => { it('should create user with valid email', () => {}); it('should throw error for invalid email', () => {}); it('should generate unique ID', () => {}); });
❌ BAD:
describe('UserService', () => { it('test1', () => {}); it('should work', () => {}); });
Error Handling Tests
// Synchronous errors it('should throw for negative numbers', () => { expect(() => sqrt(-1)).toThrow('Cannot compute square root of negative'); });
// Async errors it('should reject for invalid ID', async () => { await expect(fetchUser('invalid')).rejects.toThrow('Invalid ID'); });
// Error types it('should throw TypeError', () => { expect(() => doSomething()).toThrow(TypeError); });
// Custom errors it('should throw ValidationError', () => { expect(() => validate()).toThrow(ValidationError); });
Test Isolation
Reset State Between Tests
let service: UserService;
beforeEach(() => { service = new UserService(); vi.clearAllMocks(); });
afterEach(() => { vi.restoreAllMocks(); });
Avoid Test Interdependence
❌ BAD:
let user;
it('should create user', () => { user = createUser(); // Shared state });
it('should update user', () => { updateUser(user); // Depends on previous test });
✅ GOOD:
it('should update user', () => { const user = createUser(); updateUser(user); expect(user.updated).toBe(true); });
VSCode Debug Mode & Child Process Testing
CRITICAL: When tests spawn child processes (like CLI tools, hooks, or external commands), they may fail in VSCode debug mode due to NODE_OPTIONS inheritance.
Problem: NODE_OPTIONS Breaks Child Processes
VSCode debugger sets NODE_OPTIONS=--inspect-brk=<port> which child processes inherit, causing them to try to attach to the same debugger port and fail with exit code 1.
Symptoms:
-
Tests pass with "Run Test" but fail with "Debug Test"
-
Spawned processes exit with code 1 and empty output
-
spawnSync /execFileSync calls fail silently
Solution: getCleanEnv() Pattern
// src/utils/clean-env.ts export function getCleanEnv(): NodeJS.ProcessEnv { const cleanEnv = { ...process.env };
// Debugger flags (VSCode, WebStorm, IntelliJ) delete cleanEnv.NODE_OPTIONS; delete cleanEnv.NODE_INSPECT; delete cleanEnv.NODE_INSPECT_RESUME_ON_START;
// Coverage/instrumentation (CI/CD pipelines) delete cleanEnv.NODE_V8_COVERAGE; delete cleanEnv.VSCODE_INSPECTOR_OPTIONS;
return cleanEnv; }
Usage in Tests
import { getCleanEnv } from '../test-utils/clean-env.js'; import { execSync, spawnSync } from 'child_process';
it('should execute CLI command', () => { const result = execSync('node my-cli.js', { encoding: 'utf-8', env: getCleanEnv(), // ← CRITICAL for debug mode + CI/CD }); expect(result).toContain('expected output'); });
it('should spawn child process', () => { const result = spawnSync('npm', ['run', 'build'], { encoding: 'utf-8', env: getCleanEnv(), // ← CRITICAL }); expect(result.status).toBe(0); });
When to Use getCleanEnv
✅ ALWAYS USE when spawning:
-
CLI tools (node , npm , npx , claude , etc.)
-
External commands (git , gh , etc.)
-
Test hooks that spawn processes
-
Integration tests with real processes
❌ NOT NEEDED for:
-
Pure unit tests (no child processes)
-
Mocked dependencies
-
In-process testing
ESM Module Mocking with vi.hoisted()
CRITICAL: In ESM (ECMAScript Modules), imports are hoisted. Use vi.hoisted() to ensure mocks are defined before imports.
Problem: Mock Defined After Import
// ❌ WRONG: vi.mock hoisted, but mockFn not defined yet vi.mock('./module', () => ({ myFunc: mockFn // ReferenceError: mockFn is not defined }));
const mockFn = vi.fn(); import { myFunc } from './module';
Solution: vi.hoisted() Pattern
import { describe, it, expect, vi } from 'vitest';
// ✅ Define mocks in hoisted context FIRST const { mockFn } = vi.hoisted(() => ({ mockFn: vi.fn() }));
// Now vi.mock can use the hoisted mock vi.mock('./module', () => ({ myFunc: mockFn }));
// Import AFTER mock setup import { myFunc } from './module';
describe('Module', () => { it('should use mocked function', () => { mockFn.mockReturnValue('mocked'); expect(myFunc()).toBe('mocked'); expect(mockFn).toHaveBeenCalled(); }); });
Complete ESM Mocking Example
import { describe, it, expect, vi, beforeEach } from 'vitest';
// 1. Hoisted mock definitions const { mockReadFile, mockWriteFile } = vi.hoisted(() => ({ mockReadFile: vi.fn(), mockWriteFile: vi.fn() }));
// 2. Mock the module using hoisted mocks vi.mock('fs/promises', () => ({ readFile: mockReadFile, writeFile: mockWriteFile }));
// 3. Import AFTER mock setup (imports are automatically hoisted in Vitest) import { readFile, writeFile } from 'fs/promises';
describe('FileService', () => { beforeEach(() => { vi.clearAllMocks(); });
it('should read file', async () => { mockReadFile.mockResolvedValue('file content');
const content = await readFile('/path/to/file', 'utf-8');
expect(content).toBe('file content');
expect(mockReadFile).toHaveBeenCalledWith('/path/to/file', 'utf-8');
}); });
vi.hoisted() vs Traditional Mocking
Approach ESM Compatible Jest Compatible
vi.hoisted()
- vi.mock()
✅ Yes ❌ No (Vitest only)
jest.mock() with factory ⚠️ Partial ✅ Yes
Manual module replacement ✅ Yes ✅ Yes
Testing with Isolated Temp Directories
CRITICAL: Integration tests should NEVER operate on project directories. Always use isolated temp directories.
Problem: Tests Affecting Project State
// ❌ DANGEROUS: Can corrupt project state const testDir = path.join(process.cwd(), '.specweave/test');
Solution: Isolated Temp Directories
import * as os from 'os'; import * as path from 'path'; import * as fs from 'fs/promises';
// ✅ SAFE: Unique temp directory per test run
const TEST_ROOT = path.join(
os.tmpdir(),
my-test-${Date.now()}-${Math.random().toString(36).slice(2)}
);
describe('Integration Test', () => { beforeEach(async () => { await fs.mkdir(TEST_ROOT, { recursive: true }); });
afterEach(async () => { await fs.rm(TEST_ROOT, { recursive: true, force: true }); });
it('should work in isolated directory', async () => { const testFile = path.join(TEST_ROOT, 'test.json'); await fs.writeFile(testFile, '{"test": true}'); // Test logic... }); });
CWD Restoration Pattern
When tests change working directory, always restore it to prevent affecting other tests:
describe('Tests that change CWD', () => { let originalCwd: string;
beforeEach(() => { originalCwd = process.cwd(); // ← Save BEFORE changing process.chdir(TEST_ROOT); });
afterEach(() => { process.chdir(originalCwd); // ← Restore BEFORE cleanup // Now safe to delete TEST_ROOT }); });
Best Practices Summary
✅ DO:
-
Write tests before code (TDD)
-
Test behavior, not implementation
-
One assertion per test (when possible)
-
Clear test names (should...)
-
Mock external dependencies
-
Test edge cases and errors
-
Keep tests fast (<100ms each)
-
Use descriptive variable names
-
Clean up after tests
❌ DON'T:
-
Test private methods directly
-
Share state between tests
-
Use real databases/APIs
-
Test framework code
-
Write fragile tests (implementation-dependent)
-
Skip error cases
-
Use magic numbers
-
Leave commented-out tests
Quick Reference
Assertions
expect(value).toBe(expected); // === expect(value).toEqual(expected); // Deep equality expect(value).toBeTruthy(); // Boolean true expect(value).toBeFalsy(); // Boolean false expect(array).toHaveLength(3); // Array length expect(array).toContain(item); // Array includes expect(string).toMatch(/pattern/); // Regex match expect(fn).toThrow(Error); // Throws error expect(obj).toHaveProperty('key'); // Has property expect(value).toBeCloseTo(0.3, 5); // Float comparison
Lifecycle Hooks
beforeAll(() => {}); // Once before all tests beforeEach(() => {}); // Before each test afterEach(() => {}); // After each test afterAll(() => {}); // Once after all tests
Mock Utilities
vi.fn() // Create mock vi.fn().mockReturnValue(x) // Return value vi.fn().mockResolvedValue(x) // Async return vi.fn().mockRejectedValue(e) // Async error vi.mock('./module') // Mock module vi.spyOn(obj, 'method') // Spy on method vi.clearAllMocks() // Clear call history vi.resetAllMocks() // Reset + clear vi.restoreAllMocks() // Restore originals
This skill is self-contained and works in ANY user project with Vitest/Jest.