Unit Testing Guide
When to Write Unit Tests
Unit tests are appropriate for:
- Pure functions - Deterministic input/output
- Business logic - Calculations, validations, transformations
- Utilities - Shared helpers, formatters, parsers
- Complex algorithms - Sorting, searching, data processing
- Edge cases - Boundary conditions difficult to test otherwise
What Makes a Good Unit Test
Fast, Isolated, Deterministic
// ✅ Good unit test
test('calculateDiscount returns correct percentage', () => {
expect(calculateDiscount(100, 'SAVE20')).toBe(80);
expect(calculateDiscount(100, 'HALFOFF')).toBe(50);
expect(calculateDiscount(100, 'INVALID')).toBe(100);
});
// ❌ Not a unit test (depends on external state)
test('calculateDiscount with database lookup', async () => {
const discount = await calculateDiscountFromDB(100, userId);
expect(discount).toBe(80);
});
Single Responsibility
Each test should verify one behavior:
// ❌ Testing multiple things
test('user validation', () => {
const user = { email: 'bad-email', age: -5, name: '' };
const errors = validateUser(user);
expect(errors).toContain('Invalid email');
expect(errors).toContain('Age must be positive');
expect(errors).toContain('Name is required');
});
// ✅ Focused tests
test('validateUser returns error for invalid email', () => {
const user = { email: 'bad-email', age: 25, name: 'John' };
expect(validateUser(user)).toContain('Invalid email');
});
test('validateUser returns error for negative age', () => {
const user = { email: 'john@example.com', age: -5, name: 'John' };
expect(validateUser(user)).toContain('Age must be positive');
});
Testing Patterns
Testing Pure Functions
// utils/formatters.ts
export function formatCurrency(amount: number, currency = 'USD'): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency
}).format(amount);
}
// utils/formatters.test.ts
describe('formatCurrency', () => {
test('formats USD by default', () => {
expect(formatCurrency(1234.56)).toBe('$1,234.56');
});
test('formats other currencies', () => {
expect(formatCurrency(1234.56, 'EUR')).toBe('€1,234.56');
expect(formatCurrency(1234.56, 'GBP')).toBe('£1,234.56');
});
test('handles zero and negative values', () => {
expect(formatCurrency(0)).toBe('$0.00');
expect(formatCurrency(-100)).toBe('-$100.00');
});
});
Testing Business Logic
// lib/subscription-calculator.ts
export function calculateSubscriptionPrice(
seats: number,
interval: 'monthly' | 'yearly',
addons: string[] = []
): number {
const basePrice = seats * (interval === 'monthly' ? 10 : 100);
const addonPrice = addons.reduce((sum, addon) => {
return sum + getAddonPrice(addon, interval);
}, 0);
return basePrice + addonPrice;
}
// lib/subscription-calculator.test.ts
describe('calculateSubscriptionPrice', () => {
test('calculates monthly price correctly', () => {
expect(calculateSubscriptionPrice(5, 'monthly')).toBe(50);
});
test('calculates yearly price with discount', () => {
expect(calculateSubscriptionPrice(5, 'yearly')).toBe(500);
});
test('includes addon prices', () => {
const price = calculateSubscriptionPrice(5, 'monthly', ['premium-support']);
expect(price).toBe(150); // 50 base + 100 addon
});
test('handles edge cases', () => {
expect(calculateSubscriptionPrice(0, 'monthly')).toBe(0);
expect(calculateSubscriptionPrice(1, 'monthly', [])).toBe(10);
});
});
Testing Validation Logic
// lib/validators.ts
export const TeamNameSchema = z
.string()
.min(2, 'Team name must be at least 2 characters')
.max(50, 'Team name must be less than 50 characters')
.regex(/^[a-zA-Z0-9\s-]+$/, 'Team name can only contain letters, numbers, spaces, and hyphens')
.transform(s => s.trim());
// lib/validators.test.ts
describe('TeamNameSchema', () => {
test('accepts valid team names', () => {
const validNames = [
'Acme Corp',
'Team-123',
'My Team',
' Trimmed Team ', // should be trimmed
];
validNames.forEach(name => {
expect(() => TeamNameSchema.parse(name)).not.toThrow();
});
});
test('rejects invalid team names', () => {
const invalidCases = [
{ input: 'A', error: 'at least 2 characters' },
{ input: 'A'.repeat(51), error: 'less than 50 characters' },
{ input: 'Team@123', error: 'can only contain' },
{ input: 'Team!', error: 'can only contain' },
];
invalidCases.forEach(({ input, error }) => {
expect(() => TeamNameSchema.parse(input)).toThrow(error);
});
});
test('trims whitespace', () => {
expect(TeamNameSchema.parse(' My Team ')).toBe('My Team');
});
});
Testing Error Handling
// lib/error-handler.ts
export function parseApiError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'object' && error !== null && 'message' in error) {
return String(error.message);
}
if (typeof error === 'string') {
return error;
}
return 'An unexpected error occurred';
}
// lib/error-handler.test.ts
describe('parseApiError', () => {
test('handles Error instances', () => {
expect(parseApiError(new Error('Network error'))).toBe('Network error');
});
test('handles objects with message property', () => {
expect(parseApiError({ message: 'API error' })).toBe('API error');
});
test('handles string errors', () => {
expect(parseApiError('Simple error')).toBe('Simple error');
});
test('handles unexpected types', () => {
expect(parseApiError(null)).toBe('An unexpected error occurred');
expect(parseApiError(undefined)).toBe('An unexpected error occurred');
expect(parseApiError(123)).toBe('An unexpected error occurred');
});
});
Testing Async Utilities
// lib/retry.ts
export async function retry<T>(
fn: () => Promise<T>,
options: { attempts?: number; delay?: number } = {}
): Promise<T> {
const { attempts = 3, delay = 1000 } = options;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (error) {
if (i === attempts - 1) throw error;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Retry failed');
}
// lib/retry.test.ts
describe('retry', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('succeeds on first attempt', async () => {
const fn = jest.fn().mockResolvedValue('success');
const result = await retry(fn);
expect(result).toBe('success');
expect(fn).toHaveBeenCalledTimes(1);
});
test('retries on failure then succeeds', async () => {
const fn = jest.fn()
.mockRejectedValueOnce(new Error('Fail 1'))
.mockRejectedValueOnce(new Error('Fail 2'))
.mockResolvedValue('success');
const promise = retry(fn, { attempts: 3, delay: 100 });
// Fast-forward through delays
await jest.advanceTimersByTimeAsync(200);
const result = await promise;
expect(result).toBe('success');
expect(fn).toHaveBeenCalledTimes(3);
});
test('throws after all attempts fail', async () => {
const fn = jest.fn().mockRejectedValue(new Error('Always fails'));
const promise = retry(fn, { attempts: 2, delay: 100 });
await jest.advanceTimersByTimeAsync(100);
await expect(promise).rejects.toThrow('Always fails');
expect(fn).toHaveBeenCalledTimes(2);
});
});
Testing Custom Hooks (When Necessary)
Only test hooks that contain complex logic:
// hooks/use-debounced-value.ts
export function useDebouncedValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// hooks/use-debounced-value.test.ts
import { renderHook, act } from '@testing-library/react';
describe('useDebouncedValue', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('returns initial value immediately', () => {
const { result } = renderHook(() =>
useDebouncedValue('initial', 500)
);
expect(result.current).toBe('initial');
});
test('debounces value changes', () => {
const { result, rerender } = renderHook(
({ value }) => useDebouncedValue(value, 500),
{ initialProps: { value: 'initial' } }
);
act(() => {
rerender({ value: 'updated' });
});
// Value shouldn't change immediately
expect(result.current).toBe('initial');
act(() => {
jest.advanceTimersByTime(500);
});
// Value should update after delay
expect(result.current).toBe('updated');
});
});
Common Pitfalls to Avoid
1. Over-mocking
// ❌ Too much mocking
jest.mock('../../lib/constants');
jest.mock('../../lib/helpers');
jest.mock('../../lib/validators');
test('processUser works', () => {
// Test doesn't actually test real behavior
});
// ✅ Test real behavior
test('processUser validates and formats data', () => {
const user = processUser({ name: ' John ', email: 'JOHN@EXAMPLE.COM' });
expect(user).toEqual({
name: 'John',
email: 'john@example.com'
});
});
2. Testing Implementation
// ❌ Testing how, not what
test('calls toLowerCase on email', () => {
const spy = jest.spyOn(String.prototype, 'toLowerCase');
normalizeEmail('TEST@EXAMPLE.COM');
expect(spy).toHaveBeenCalled();
});
// ✅ Testing behavior
test('normalizes email to lowercase', () => {
expect(normalizeEmail('TEST@EXAMPLE.COM')).toBe('test@example.com');
});
3. Inappropriate Unit Tests
// ❌ This needs integration testing, not unit testing
test('UserList fetches and displays users', async () => {
// Mocking fetch, Supabase, React Query, etc.
// This test provides little confidence
});
// ✅ Save this for integration tests
// Unit test only the pure logic
test('sortUsers sorts by name ascending', () => {
const users = [
{ id: '1', name: 'Charlie' },
{ id: '2', name: 'Alice' },
{ id: '3', name: 'Bob' }
];
expect(sortUsers(users, 'name', 'asc')).toEqual([
{ id: '2', name: 'Alice' },
{ id: '3', name: 'Bob' },
{ id: '1', name: 'Charlie' }
]);
});
Test Structure Best Practices
Use Descriptive Names
// ❌ Vague test names
test('works', () => {});
test('handles error', () => {});
// ✅ Clear, specific names
test('formatDate returns ISO string for valid dates', () => {});
test('formatDate returns null for invalid input', () => {});
Arrange-Act-Assert Pattern
test('calculateTotal applies discount correctly', () => {
// Arrange
const items = [
{ price: 100, quantity: 2 },
{ price: 50, quantity: 1 }
];
const discountCode = 'SAVE10';
// Act
const total = calculateTotal(items, discountCode);
// Assert
expect(total).toBe(225); // 250 - 10%
});
Group Related Tests
describe('DateUtils', () => {
describe('formatDate', () => {
test('formats dates in default locale', () => {});
test('formats dates in specified locale', () => {});
test('handles invalid dates', () => {});
});
describe('parseDate', () => {
test('parses ISO strings', () => {});
test('parses timestamps', () => {});
test('returns null for invalid input', () => {});
});
});
Performance Considerations
Keep Tests Fast
// ❌ Slow test
test('processes large dataset', () => {
const data = generateMillionRecords(); // Takes seconds
expect(processData(data)).toHaveLength(1000000);
});
// ✅ Test with minimal data
test('processes data correctly', () => {
const data = [
{ id: 1, value: 10 },
{ id: 2, value: 20 },
{ id: 3, value: 30 }
];
expect(processData(data)).toEqual(expectedOutput);
});
test('handles large datasets efficiently', () => {
// Test algorithm complexity, not actual processing
const smallResult = measurePerformance(100);
const largeResult = measurePerformance(1000);
expect(largeResult.time).toBeLessThan(smallResult.time * 15);
});
Running Unit Tests
# Run all unit tests
pnpm test:unit
# Run tests in watch mode during development
pnpm test:unit --watch
# Run tests for specific file
pnpm test:unit formatters.test.ts
# Run tests with coverage
pnpm test:unit --coverage
Remember: Unit tests are just one part of our testing strategy. They excel at testing pure logic but shouldn't be forced onto components or integration points. When in doubt, prefer integration tests that test real user behavior.