Unit Testing
Testing pure logic in isolation — Vitest, assertion patterns, test doubles, and what makes a good unit test.
Real-World Analogy
Testing a recipe ingredient in isolation: you taste the sauce before it goes in the dish. If the sauce is wrong, you know exactly what to fix — you don’t have to serve the whole meal and guess which ingredient was off. Unit tests give you that same pinpoint feedback on individual functions.
Setup: Vitest
Vitest is the modern choice for TypeScript projects — fast, ESM-native, compatible with Jest’s API:
npm install -D vitest @vitest/coverage-v8 // vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true, // no need to import describe/it/expect
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: ['node_modules', 'dist', '**/*.config.*', 'src/migrations/**'],
},
},
}); // package.json
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
} Anatomy of a Good Unit Test
// src/pricing.test.ts
import { describe, it, expect } from 'vitest';
import { calculateDiscount, applyPromoCode } from './pricing';
describe('calculateDiscount', () => {
it('applies 20% for premium members', () => {
// Arrange
const priceInCents = 100_00;
const tier = 'premium';
// Act
const result = calculateDiscount(priceInCents, tier);
// Assert
expect(result).toBe(80_00);
});
it('returns original price for standard members', () => {
expect(calculateDiscount(50_00, 'standard')).toBe(50_00);
});
it('handles zero price', () => {
expect(calculateDiscount(0, 'premium')).toBe(0);
});
it('rounds down fractional cents', () => {
// 33_33 * 0.8 = 26.664 → 26_66
expect(calculateDiscount(33_33, 'premium')).toBe(26_66);
});
}); Each test: one logical assertion, descriptive name that reads as a sentence, no shared mutable state between tests.
Assertions
// Equality
expect(value).toBe(42); // strict ===
expect(value).toEqual({ a: 1 }); // deep equality (objects/arrays)
expect(value).not.toBe(null);
// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// Numbers
expect(0.1 + 0.2).toBeCloseTo(0.3, 5); // floating point
expect(value).toBeGreaterThan(0);
expect(value).toBeLessThanOrEqual(100);
// Strings
expect(str).toContain('hello');
expect(str).toMatch(/^\d{4}-\d{2}-\d{2}$/); // regex
// Arrays
expect(arr).toHaveLength(3);
expect(arr).toContain('item');
expect(arr).toEqual(expect.arrayContaining(['a', 'b'])); // subset
// Objects
expect(obj).toMatchObject({ name: 'Alice' }); // partial match
// Errors
expect(() => fn()).toThrow('expected message');
expect(() => fn()).toThrow(ValidationError);
await expect(asyncFn()).rejects.toThrow('error');
// Snapshots (use sparingly — for serializable output like HTML or JSON)
expect(renderResult).toMatchSnapshot(); Spies and Mocks
import { vi, describe, it, expect, beforeEach } from 'vitest';
// Spy on a function (tracks calls, lets original run)
it('calls the logger on success', () => {
const logger = { info: vi.fn() };
processOrder({ id: '1' }, logger);
expect(logger.info).toHaveBeenCalledWith('Order processed', { orderId: '1' });
expect(logger.info).toHaveBeenCalledTimes(1);
});
// Mock a function (replace with fake implementation)
it('uses the mocked time', () => {
const now = new Date('2024-01-15T10:00:00Z');
vi.setSystemTime(now);
const result = createTimestamp();
expect(result).toBe('2024-01-15T10:00:00.000Z');
vi.useRealTimers();
});
// Mock a module
vi.mock('./email', () => ({
sendEmail: vi.fn().mockResolvedValue({ messageId: 'msg-123' }),
}));
import { sendEmail } from './email';
it('sends a welcome email on signup', async () => {
await createUser({ email: 'user@example.com' });
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({ to: 'user@example.com', subject: 'Welcome' })
);
}); // Reset mocks between tests
beforeEach(() => {
vi.clearAllMocks(); // clears call history
// vi.resetAllMocks() — also resets implementations
// vi.restoreAllMocks() — restores original implementations
}); Testing Async Code
// Async/await (preferred)
it('resolves with user data', async () => {
const user = await fetchUser('user-1');
expect(user.name).toBe('Alice');
});
// Rejected promises
it('throws on missing user', async () => {
await expect(fetchUser('nonexistent')).rejects.toThrow('User not found');
});
// Timers (without waiting real time)
it('debounces rapid calls', async () => {
vi.useFakeTimers();
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced();
debounced();
debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledTimes(1);
vi.useRealTimers();
}); Testing Classes
class Cart {
private items: Map<string, number> = new Map();
add(productId: string, quantity: number) {
const current = this.items.get(productId) ?? 0;
this.items.set(productId, current + quantity);
}
total(prices: Record<string, number>): number {
let sum = 0;
for (const [id, qty] of this.items) {
sum += (prices[id] ?? 0) * qty;
}
return sum;
}
isEmpty(): boolean {
return this.items.size === 0;
}
}
describe('Cart', () => {
let cart: Cart;
beforeEach(() => {
cart = new Cart(); // fresh instance per test — no shared state
});
it('starts empty', () => {
expect(cart.isEmpty()).toBe(true);
});
it('accumulates quantities for the same product', () => {
cart.add('prod-1', 2);
cart.add('prod-1', 3);
const total = cart.total({ 'prod-1': 10_00 });
expect(total).toBe(50_00);
});
it('calculates total across multiple products', () => {
cart.add('prod-1', 1);
cart.add('prod-2', 2);
const total = cart.total({ 'prod-1': 20_00, 'prod-2': 15_00 });
expect(total).toBe(50_00); // 20 + 30
});
}); Parameterized Tests
Test the same logic across many inputs without duplicating test code:
import { describe, it, expect } from 'vitest';
import { parseDate } from './date-utils';
describe.each([
['2024-01-15', { year: 2024, month: 1, day: 15 }],
['2024-12-31', { year: 2024, month: 12, day: 31 }],
['2000-02-29', { year: 2000, month: 2, day: 29 }], // leap year
])('parseDate("%s")', (input, expected) => {
it('parses correctly', () => {
expect(parseDate(input)).toEqual(expected);
});
});
// For error cases
it.each([
['2024-13-01', 'Invalid month'],
['2024-00-01', 'Invalid month'],
['not-a-date', 'Invalid format'],
])('parseDate("%s") throws "%s"', (input, message) => {
expect(() => parseDate(input)).toThrow(message);
}); What Not to Unit Test
- Framework code (Express routing, ORM queries) — test these at integration level
- Simple getters/setters with no logic
- Configuration objects
- Code that’s all I/O (DB calls, HTTP calls) — mock the I/O or use integration tests
- Private methods — if you need to test them, your class may need splitting
Focus unit tests on: algorithms, business rules, data transformations, error handling, edge cases.
Running Tests
# Run once
npm test
# Watch mode (re-runs on file change)
npm run test:watch
# Run specific file
npx vitest run src/pricing.test.ts
# Run tests matching a pattern
npx vitest run --reporter=verbose -t "discount"
# Coverage report
npm run test:coverage
# Open coverage/index.html in browser