unit-testing-expert

Self-contained unit testing expertise for Vitest/Jest in ANY user project.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "unit-testing-expert" with this command: npx skills add anton-abyzov/specweave/anton-abyzov-specweave-unit-testing-expert

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

  1. 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);

  1. 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); });

  1. Spies

// Spy on existing method const spy = vi.spyOn(console, 'log');

myFunction();

expect(spy).toHaveBeenCalledWith('Expected message'); spy.mockRestore();

  1. 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.

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

technical-writing

No summary provided by upstream source.

Repository SourceNeeds Review
General

spec-driven-brainstorming

No summary provided by upstream source.

Repository SourceNeeds Review
General

kafka-architecture

No summary provided by upstream source.

Repository SourceNeeds Review