Unit Testing Pattern

Write effective unit tests for utility functions, services, and business logic with Vitest, including edge cases and error handling.

Overview#

Unit tests verify individual functions and modules in isolation. They're the foundation of a testing strategy, providing fast feedback and ensuring code correctness at the smallest level.

When to use:

  • Testing pure utility functions
  • Validating business logic
  • Testing data transformations
  • Verifying error handling

Key features:

  • Fast execution
  • Isolated from dependencies
  • Easy to write and maintain
  • High coverage potential

Code Example#

Testing Pure Functions#

1// lib/utils.test.ts 2import { describe, it, expect } from 'vitest' 3import { 4 formatCurrency, 5 slugify, 6 truncate, 7 calculateTax, 8 parseQueryString 9} from './utils' 10 11describe('formatCurrency', () => { 12 it('formats positive amounts', () => { 13 expect(formatCurrency(1234.56)).toBe('$1,234.56') 14 expect(formatCurrency(0.99)).toBe('$0.99') 15 }) 16 17 it('handles zero', () => { 18 expect(formatCurrency(0)).toBe('$0.00') 19 }) 20 21 it('formats negative amounts', () => { 22 expect(formatCurrency(-50)).toBe('-$50.00') 23 }) 24 25 it('handles different currencies', () => { 26 expect(formatCurrency(100, 'EUR')).toBe('100.00 EUR') 27 expect(formatCurrency(100, 'GBP')).toBe('100.00 GBP') 28 }) 29 30 it('rounds to 2 decimal places', () => { 31 expect(formatCurrency(10.999)).toBe('$11.00') 32 expect(formatCurrency(10.994)).toBe('$10.99') 33 }) 34}) 35 36describe('slugify', () => { 37 it('converts to lowercase', () => { 38 expect(slugify('Hello World')).toBe('hello-world') 39 }) 40 41 it('replaces spaces with hyphens', () => { 42 expect(slugify('foo bar baz')).toBe('foo-bar-baz') 43 }) 44 45 it('removes special characters', () => { 46 expect(slugify('Hello! World?')).toBe('hello-world') 47 }) 48 49 it('handles multiple spaces', () => { 50 expect(slugify('hello world')).toBe('hello-world') 51 }) 52 53 it('handles empty strings', () => { 54 expect(slugify('')).toBe('') 55 }) 56 57 it('handles unicode characters', () => { 58 expect(slugify('caf\u00e9')).toBe('cafe') 59 }) 60}) 61 62describe('truncate', () => { 63 it('truncates long strings', () => { 64 expect(truncate('hello world', 5)).toBe('hello...') 65 }) 66 67 it('does not truncate short strings', () => { 68 expect(truncate('hello', 10)).toBe('hello') 69 }) 70 71 it('uses custom suffix', () => { 72 expect(truncate('hello world', 5, '>')).toBe('hello>') 73 }) 74 75 it('handles exact length', () => { 76 expect(truncate('hello', 5)).toBe('hello') 77 }) 78})

Testing Validation Functions#

1// lib/validation.test.ts 2import { describe, it, expect } from 'vitest' 3import { 4 isValidEmail, 5 isValidPassword, 6 isValidUrl, 7 isValidPhone 8} from './validation' 9 10describe('isValidEmail', () => { 11 const validEmails = [ 12 'user@example.com', 13 'user.name@example.com', 14 'user+tag@example.co.uk', 15 'user@subdomain.example.com' 16 ] 17 18 const invalidEmails = [ 19 'invalid', 20 '@example.com', 21 'user@', 22 'user@.com', 23 'user@example', 24 '', 25 null, 26 undefined 27 ] 28 29 it.each(validEmails)('accepts valid email: %s', (email) => { 30 expect(isValidEmail(email)).toBe(true) 31 }) 32 33 it.each(invalidEmails)('rejects invalid email: %s', (email) => { 34 expect(isValidEmail(email as string)).toBe(false) 35 }) 36}) 37 38describe('isValidPassword', () => { 39 it('requires minimum length', () => { 40 expect(isValidPassword('short')).toBe(false) 41 expect(isValidPassword('longenough123')).toBe(true) 42 }) 43 44 it('requires at least one number', () => { 45 expect(isValidPassword('NoNumbers!')).toBe(false) 46 expect(isValidPassword('HasNumber1')).toBe(true) 47 }) 48 49 it('requires at least one uppercase', () => { 50 expect(isValidPassword('alllowercase1')).toBe(false) 51 expect(isValidPassword('HasUppercase1')).toBe(true) 52 }) 53 54 it('returns validation errors', () => { 55 const result = isValidPassword('weak', { returnErrors: true }) 56 expect(result.valid).toBe(false) 57 expect(result.errors).toContain('Password must be at least 8 characters') 58 }) 59})

Testing Business Logic#

1// lib/pricing.test.ts 2import { describe, it, expect } from 'vitest' 3import { 4 calculateSubtotal, 5 calculateDiscount, 6 calculateTax, 7 calculateTotal 8} from './pricing' 9 10describe('calculateSubtotal', () => { 11 it('sums item prices', () => { 12 const items = [ 13 { price: 10, quantity: 2 }, 14 { price: 5, quantity: 3 } 15 ] 16 expect(calculateSubtotal(items)).toBe(35) 17 }) 18 19 it('handles empty cart', () => { 20 expect(calculateSubtotal([])).toBe(0) 21 }) 22 23 it('handles single item', () => { 24 expect(calculateSubtotal([{ price: 25, quantity: 1 }])).toBe(25) 25 }) 26}) 27 28describe('calculateDiscount', () => { 29 it('applies percentage discount', () => { 30 expect(calculateDiscount(100, { type: 'percentage', value: 10 })).toBe(10) 31 }) 32 33 it('applies fixed discount', () => { 34 expect(calculateDiscount(100, { type: 'fixed', value: 15 })).toBe(15) 35 }) 36 37 it('caps discount at subtotal', () => { 38 expect(calculateDiscount(50, { type: 'fixed', value: 100 })).toBe(50) 39 }) 40 41 it('handles zero discount', () => { 42 expect(calculateDiscount(100, { type: 'percentage', value: 0 })).toBe(0) 43 }) 44}) 45 46describe('calculateTax', () => { 47 it('calculates tax rate', () => { 48 expect(calculateTax(100, 0.08)).toBe(8) 49 }) 50 51 it('rounds to 2 decimal places', () => { 52 expect(calculateTax(10.5, 0.07)).toBe(0.74) // 0.735 rounded 53 }) 54 55 it('handles tax-exempt items', () => { 56 expect(calculateTax(100, 0)).toBe(0) 57 }) 58}) 59 60describe('calculateTotal', () => { 61 it('combines subtotal, discount, and tax', () => { 62 const result = calculateTotal({ 63 items: [{ price: 100, quantity: 1 }], 64 discount: { type: 'percentage', value: 10 }, 65 taxRate: 0.08 66 }) 67 68 expect(result.subtotal).toBe(100) 69 expect(result.discount).toBe(10) 70 expect(result.tax).toBe(7.2) // (100 - 10) * 0.08 71 expect(result.total).toBe(97.2) // 100 - 10 + 7.2 72 }) 73})

Testing Error Handling#

1// lib/api-client.test.ts 2import { describe, it, expect, vi } from 'vitest' 3import { fetchData, parseResponse, handleApiError } from './api-client' 4 5describe('fetchData', () => { 6 it('throws on network error', async () => { 7 global.fetch = vi.fn().mockRejectedValue(new Error('Network error')) 8 9 await expect(fetchData('/api/data')).rejects.toThrow('Network error') 10 }) 11 12 it('throws on non-ok response', async () => { 13 global.fetch = vi.fn().mockResolvedValue({ 14 ok: false, 15 status: 404, 16 json: () => Promise.resolve({ error: 'Not found' }) 17 }) 18 19 await expect(fetchData('/api/data')).rejects.toThrow('Not found') 20 }) 21}) 22 23describe('parseResponse', () => { 24 it('parses valid JSON', () => { 25 expect(parseResponse('{"name":"test"}')).toEqual({ name: 'test' }) 26 }) 27 28 it('throws on invalid JSON', () => { 29 expect(() => parseResponse('invalid')).toThrow('Invalid JSON response') 30 }) 31 32 it('handles empty response', () => { 33 expect(parseResponse('')).toBeNull() 34 }) 35}) 36 37describe('handleApiError', () => { 38 it('extracts error message from response', () => { 39 const error = handleApiError({ 40 status: 400, 41 body: { error: { message: 'Bad request' } } 42 }) 43 expect(error.message).toBe('Bad request') 44 expect(error.code).toBe('BAD_REQUEST') 45 }) 46 47 it('provides default message for unknown errors', () => { 48 const error = handleApiError({ status: 500, body: {} }) 49 expect(error.message).toBe('An unexpected error occurred') 50 }) 51})

Testing with Test Data#

1// lib/user.test.ts 2import { describe, it, expect } from 'vitest' 3import { formatUserName, getUserInitials, isAdmin } from './user' 4 5// Test fixtures 6const users = { 7 standard: { 8 id: '1', 9 firstName: 'John', 10 lastName: 'Doe', 11 email: 'john@example.com', 12 role: 'user' 13 }, 14 admin: { 15 id: '2', 16 firstName: 'Jane', 17 lastName: 'Smith', 18 email: 'jane@example.com', 19 role: 'admin' 20 }, 21 noLastName: { 22 id: '3', 23 firstName: 'Alice', 24 lastName: '', 25 email: 'alice@example.com', 26 role: 'user' 27 } 28} 29 30describe('formatUserName', () => { 31 it('formats full name', () => { 32 expect(formatUserName(users.standard)).toBe('John Doe') 33 }) 34 35 it('handles missing last name', () => { 36 expect(formatUserName(users.noLastName)).toBe('Alice') 37 }) 38 39 it('formats with reversed order', () => { 40 expect(formatUserName(users.standard, { reverse: true })).toBe('Doe, John') 41 }) 42}) 43 44describe('getUserInitials', () => { 45 it('returns initials from full name', () => { 46 expect(getUserInitials(users.standard)).toBe('JD') 47 }) 48 49 it('handles single name', () => { 50 expect(getUserInitials(users.noLastName)).toBe('A') 51 }) 52}) 53 54describe('isAdmin', () => { 55 it('returns true for admin users', () => { 56 expect(isAdmin(users.admin)).toBe(true) 57 }) 58 59 it('returns false for regular users', () => { 60 expect(isAdmin(users.standard)).toBe(false) 61 }) 62})

Usage Instructions#

  1. Identify testable units: Find pure functions and isolated logic
  2. Write descriptive tests: Use clear test names that describe behavior
  3. Cover edge cases: Test boundaries, null values, and errors
  4. Use test fixtures: Create reusable test data
  5. Group related tests: Organize with describe blocks

Best Practices#

  1. Test one thing per test - Each test should verify a single behavior
  2. Use descriptive names - Test names should describe expected behavior
  3. Arrange-Act-Assert - Structure tests clearly with setup, action, verification
  4. Test edge cases - Empty arrays, null values, boundaries
  5. Keep tests independent - Tests should not depend on each other
  6. Avoid testing implementation - Test behavior, not internal details
  7. Use parameterized tests - Use it.each for multiple inputs