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#
- Identify testable units: Find pure functions and isolated logic
- Write descriptive tests: Use clear test names that describe behavior
- Cover edge cases: Test boundaries, null values, and errors
- Use test fixtures: Create reusable test data
- Group related tests: Organize with
describeblocks
Best Practices#
- Test one thing per test - Each test should verify a single behavior
- Use descriptive names - Test names should describe expected behavior
- Arrange-Act-Assert - Structure tests clearly with setup, action, verification
- Test edge cases - Empty arrays, null values, boundaries
- Keep tests independent - Tests should not depend on each other
- Avoid testing implementation - Test behavior, not internal details
- Use parameterized tests - Use
it.eachfor multiple inputs