Unit Testing Skill
Fast, isolated tests that verify individual functions and modules. Unit tests run in the CI runner (not Kubernetes) for rapid feedback.
Overview
| Aspect | Details |
|---|---|
| Location | components/*/src/**/*.test.ts |
| Framework | Vitest |
| Runs In | CI runner (fast feedback) |
| Written By | Implementors (alongside their code) |
Test Structure
File Naming
Place test files next to the code they test:
src/model/use-cases/
├── create_user.ts
├── create_user.test.ts # Unit test for create_user
├── update_user.ts
└── update_user.test.ts
Basic Pattern
// src/model/use-cases/create_user.test.ts
import { describe, it, expect } from 'vitest';
import { createUser } from './create_user';
import type { Dependencies } from '../dependencies';
/**
* @spec changes/user-management/SPEC.md
* @issue PROJ-123
*/
describe('createUser', () => {
describe('AC1: Valid user creation', () => {
it('creates user when email is unique', async () => {
// Arrange (Given)
const mockDeps = createMockDependencies();
const args = { email: 'test@example.com', name: 'Test User' };
// Act (When)
const result = await createUser(mockDeps, args);
// Assert (Then)
expect(result.success).toBe(true);
if (result.success) {
expect(result.user.email).toBe('test@example.com');
}
});
});
});
Mocking Strategies
Dependency Injection Mocks
For functions that receive dependencies as the first argument (CMDO pattern):
import type { Dependencies } from '../dependencies';
const createMockDependencies = (overrides?: Partial<Dependencies>): Dependencies => ({
findUserByEmail: async () => null,
findUserById: async () => null,
insertUser: async (data) => ({ id: 'mock-id', ...data, createdAt: new Date() }),
updateUser: async (id, data) => ({ id, ...data, updatedAt: new Date() }),
...overrides,
});
// Usage in tests
it('returns error when email exists', async () => {
const deps = createMockDependencies({
findUserByEmail: async () => ({ id: '123', email: 'exists@test.com', name: 'Existing' }),
});
const result = await createUser(deps, { email: 'exists@test.com', name: 'New' });
expect(result.success).toBe(false);
});
Vitest Mock Functions
For tracking calls and controlling return values:
import { describe, it, expect, vi } from 'vitest';
it('calls insertUser with correct data', async () => {
const insertUser = vi.fn().mockResolvedValue({ id: '123', email: 'test@example.com' });
const deps = createMockDependencies({ insertUser });
await createUser(deps, { email: 'test@example.com', name: 'Test' });
expect(insertUser).toHaveBeenCalledOnce();
expect(insertUser).toHaveBeenCalledWith(
expect.objectContaining({ email: 'test@example.com' })
);
});
Module Mocks
For mocking entire modules (use sparingly):
import { vi } from 'vitest';
vi.mock('../services/email', () => ({
sendEmail: vi.fn().mockResolvedValue({ sent: true }),
}));
import { sendEmail } from '../services/email';
it('sends welcome email after user creation', async () => {
await createUserWithWelcomeEmail(deps, { email: 'new@test.com' });
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({ to: 'new@test.com', template: 'welcome' })
);
});
Fixtures and Test Data
Factory Functions
Create reusable factories for test data:
// src/__tests__/fixtures/user.ts
import type { User } from '../../types/generated';
export const createTestUser = (overrides?: Partial<User>): User => ({
id: 'test-user-id',
email: 'test@example.com',
name: 'Test User',
role: 'planner',
clientId: 'test-client',
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
...overrides,
});
// Usage
it('formats user display name', () => {
const user = createTestUser({ name: 'John Doe' });
expect(formatDisplayName(user)).toBe('John Doe');
});
it('uses email when name is empty', () => {
const user = createTestUser({ name: '', email: 'john@example.com' });
expect(formatDisplayName(user)).toBe('john');
});
Shared Test Constants
// src/__tests__/fixtures/constants.ts
export const TEST_USER_ID = 'test-user-123';
export const TEST_CLIENT_ID = 'test-client-456';
export const TEST_EMAIL = 'test@example.com';
export const VALID_CREDENTIALS = {
email: TEST_EMAIL,
password: 'ValidPass123!',
};
export const INVALID_CREDENTIALS = {
email: TEST_EMAIL,
password: 'wrong',
};
Isolation Strategies
Reset State Between Tests
import { describe, it, beforeEach, afterEach, vi } from 'vitest';
describe('userService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
// Tests run in isolation
});
Avoid Shared Mutable State
// BAD: Shared mutable state
let testUser = { id: '1', name: 'Test' };
it('test 1', () => {
testUser.name = 'Modified'; // Affects other tests!
});
// GOOD: Create fresh data per test
it('test 1', () => {
const testUser = createTestUser();
// Modifications don't affect other tests
});
Time Mocking
import { vi, beforeEach, afterEach } from 'vitest';
describe('time-dependent tests', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-15T10:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('calculates expiration correctly', () => {
const token = createToken({ expiresIn: '1h' });
expect(token.expiresAt).toEqual(new Date('2026-01-15T11:00:00Z'));
});
});
Testing Async Code
Promises
it('handles async operations', async () => {
const result = await createUser(deps, validArgs);
expect(result.success).toBe(true);
});
it('handles rejected promises', async () => {
const deps = createMockDependencies({
insertUser: async () => { throw new Error('DB connection failed'); },
});
await expect(createUser(deps, validArgs)).rejects.toThrow('DB connection failed');
});
Testing Error Scenarios
it('returns error result for validation failure', async () => {
const result = await createUser(deps, { email: 'invalid-email', name: '' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe('invalid_email');
}
});
it('handles database errors gracefully', async () => {
const deps = createMockDependencies({
insertUser: async () => { throw new Error('Connection timeout'); },
});
const result = await createUser(deps, validArgs);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe('database_error');
}
});
Testing Discriminated Unions
For functions returning discriminated union results:
type CreateUserResult =
| { readonly success: true; readonly user: User }
| { readonly success: false; readonly error: 'email_exists' | 'invalid_email' };
it('returns success with user data', async () => {
const result = await createUser(deps, validArgs);
// Type narrowing via discriminant
expect(result.success).toBe(true);
if (result.success) {
expect(result.user.email).toBe('test@example.com');
expect(result.user.id).toBeDefined();
}
});
it('returns specific error code', async () => {
const deps = createMockDependencies({
findUserByEmail: async () => existingUser,
});
const result = await createUser(deps, { email: existingUser.email, name: 'New' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe('email_exists');
}
});
Coverage Guidelines
What to Cover
- All public functions
- All code paths (success, error, edge cases)
- All acceptance criteria from specs
- Boundary conditions