Unit Testing

Test individual functions, utilities, and pure business logic with Vitest in a Node.js environment.

Table of Contents

Unit Testing Principles

Unit tests focus on testing individual functions in isolation:
Unit Test Scope: Test pure functions, utilities, and business logic without external dependencies like databases, APIs, or UI components.
Organize Tests by Function:
// src/lib/__tests__/crypto.test.ts
import { describe, it, expect } from 'vitest'
import { hashPassword, verifyPassword, generateApiKey } from '../crypto'

describe('Crypto Utilities', () => {
  describe('hashPassword', () => {
    it('should hash password with bcrypt format', async () => {
      const password = 'TestPassword123!'
      const hash = await hashPassword(password)

      expect(hash).not.toBe(password)
      expect(hash).toMatch(/^\$2[aby]?\$/) // bcrypt format
      expect(hash.length).toBeGreaterThan(50)
    })

    it('should generate different hashes for same password', async () => {
      const password = 'TestPassword123!'
      const hash1 = await hashPassword(password)
      const hash2 = await hashPassword(password)

      expect(hash1).not.toBe(hash2) // Salt should make them different
    })
  })

  describe('verifyPassword', () => {
    it('should verify correct password', async () => {
      const password = 'TestPassword123!'
      const hash = await hashPassword(password)

      const isValid = await verifyPassword(password, hash)
      expect(isValid).toBe(true)
    })

    it('should reject incorrect password', async () => {
      const password = 'TestPassword123!'
      const wrongPassword = 'WrongPassword123!'
      const hash = await hashPassword(password)

      const isValid = await verifyPassword(wrongPassword, hash)
      expect(isValid).toBe(false)
    })
  })
})
Key Principles:
  • One Function Per Describe Block: Group related tests
  • Descriptive Test Names: Clearly state what is being tested
  • Arrange-Act-Assert: Setup, execute, verify pattern
  • Test Edge Cases: Include boundary conditions and error cases

Testing Utilities & Helpers

Test utility functions and helper modules used throughout the application:
Email and Input Validation:
// src/lib/__tests__/validation.test.ts
import { describe, it, expect } from 'vitest'
import {
  validateEmail,
  validatePassword,
  validateUrl,
  sanitizeInput,
  isValidUUID,
} from '../validation'

describe('Email Validation', () => {
  it('should accept valid email formats', () => {
    const validEmails = [
      'test@example.com',
      'user.name@domain.co.uk',
      'test+tag@gmail.com',
      'a@b.co',
    ]

    validEmails.forEach(email => {
      expect(validateEmail(email)).toBe(true)
    })
  })

  it('should reject invalid email formats', () => {
    const invalidEmails = [
      'invalid-email',
      '@domain.com',
      'test@',
      'test.domain.com',
      '',
      null,
      undefined,
    ]

    invalidEmails.forEach(email => {
      expect(validateEmail(email as string)).toBe(false)
    })
  })
})

describe('Password Validation', () => {
  it('should enforce minimum requirements', () => {
    expect(validatePassword('weak')).toBe(false) // Too short
    expect(validatePassword('12345678')).toBe(false) // No letters
    expect(validatePassword('password')).toBe(false) // No numbers
    expect(validatePassword('Password123')).toBe(true) // Valid
    expect(validatePassword('SuperStrong123!')).toBe(true) // Very strong
  })

  it('should return validation details', () => {
    const result = validatePassword('weak')

    expect(result).toMatchObject({
      isValid: false,
      requirements: {
        minLength: false,
        hasLetter: true,
        hasNumber: false,
        hasSpecialChar: false,
      },
    })
  })
})

describe('URL Validation', () => {
  it('should validate different URL formats', () => {
    expect(validateUrl('https://example.com')).toBe(true)
    expect(validateUrl('http://localhost:3000')).toBe(true)
    expect(validateUrl('ftp://files.example.com')).toBe(true)
    expect(validateUrl('not-a-url')).toBe(false)
    expect(validateUrl('')).toBe(false)
  })
})

describe('Input Sanitization', () => {
  it('should remove dangerous content', () => {
    expect(sanitizeInput('<script>alert("xss")</script>')).toBe('alert("xss")')
    expect(sanitizeInput('Hello <b>world</b>')).toBe('Hello world')
    expect(sanitizeInput('Safe text')).toBe('Safe text')
  })

  it('should preserve safe formatting', () => {
    const safeHtml = '<p>Hello <em>world</em></p>'
    expect(sanitizeInput(safeHtml, { allowedTags: ['p', 'em'] })).toBe(safeHtml)
  })
})

describe('UUID Validation', () => {
  it('should validate UUID format', () => {
    expect(isValidUUID('123e4567-e89b-12d3-a456-426614174000')).toBe(true)
    expect(isValidUUID('550e8400-e29b-41d4-a716-446655440000')).toBe(true)
    expect(isValidUUID('invalid-uuid')).toBe(false)
    expect(isValidUUID('123')).toBe(false)
  })
})

Business Logic Testing

Test core business logic and calculation functions:
Financial and Mathematical Calculations:
// src/lib/__tests__/calculations.test.ts
import { describe, it, expect } from 'vitest'
import {
  calculateTax,
  calculateDiscount,
  calculateSubscriptionPrice,
  calculateProration,
  compoundInterest,
} from '../calculations'

describe('Tax Calculations', () => {
  it('should calculate tax correctly', () => {
    expect(calculateTax(100, 0.2)).toBe(20)
    expect(calculateTax(1000, 0.15)).toBe(150)
    expect(calculateTax(0, 0.2)).toBe(0)
  })

  it('should handle edge cases', () => {
    expect(calculateTax(100, 0)).toBe(0)
    expect(calculateTax(-100, 0.2)).toBe(-20) // Negative amounts
  })

  it('should round to correct decimal places', () => {
    expect(calculateTax(33.33, 0.333)).toBe(11.11) // Rounded to 2 decimals
  })
})

describe('Discount Calculations', () => {
  it('should calculate percentage discount', () => {
    expect(calculateDiscount(100, 0.2)).toBe(80) // 20% off
    expect(calculateDiscount(50, 0.5)).toBe(25) // 50% off
  })

  it('should calculate fixed amount discount', () => {
    expect(calculateDiscount(100, 10, 'fixed')).toBe(90)
    expect(calculateDiscount(50, 60, 'fixed')).toBe(0) // Can't go below 0
  })

  it('should handle multiple discounts', () => {
    const discounts = [
      { type: 'percentage', value: 0.1 }, // 10% off
      { type: 'fixed', value: 5 }, // $5 off
    ]
    expect(calculateDiscount(100, discounts)).toBe(85) // $100 -> $90 -> $85
  })
})

describe('Subscription Pricing', () => {
  it('should calculate monthly pricing', () => {
    const plan = { basePrice: 1000, features: { seats: 5 }, interval: 'month' }
    expect(calculateSubscriptionPrice(plan)).toBe(1000)
  })

  it('should apply annual discount', () => {
    const plan = { basePrice: 1000, interval: 'year', annualDiscount: 0.2 }
    expect(calculateSubscriptionPrice(plan)).toBe(9600) // 12 * 1000 * 0.8
  })

  it('should calculate per-seat pricing', () => {
    const plan = { basePrice: 1000, perSeat: 200 }
    expect(calculateSubscriptionPrice(plan, { seats: 5 })).toBe(2000) // 1000 + (5 * 200)
  })
})

describe('Proration Calculations', () => {
  it('should calculate prorated amount for partial period', () => {
    const startDate = new Date('2024-01-15')
    const endDate = new Date('2024-02-01')
    const monthlyPrice = 1000

    const prorated = calculateProration(monthlyPrice, startDate, endDate)
    expect(prorated).toBe(548) // ~17 days of 31-day month
  })

  it('should handle full month', () => {
    const startDate = new Date('2024-01-01')
    const endDate = new Date('2024-02-01')
    const monthlyPrice = 1000

    const prorated = calculateProration(monthlyPrice, startDate, endDate)
    expect(prorated).toBe(1000)
  })
})

Error Handling & Validation

Test error conditions and validation logic:
Input Validation and Sanitization:
// src/lib/__tests__/input-validation.test.ts
import { describe, it, expect } from 'vitest'
import {
  validateUserInput,
  sanitizeUserInput,
  ValidationError,
  validateSchema,
} from '../input-validation'

describe('User Input Validation', () => {
  it('should validate required fields', () => {
    const input = { name: '', email: 'test@example.com' }
    const result = validateUserInput(input)

    expect(result.isValid).toBe(false)
    expect(result.errors).toContain('name is required')
  })

  it('should validate field formats', () => {
    const input = { name: 'John Doe', email: 'invalid-email' }
    const result = validateUserInput(input)

    expect(result.isValid).toBe(false)
    expect(result.errors).toContain('email format is invalid')
  })

  it('should validate field lengths', () => {
    const input = {
      name: 'J', // Too short
      email: 'test@example.com',
      password: '1234567890123456789012345678901234567890123456789012345678901234567890' // Too long
    }
    const result = validateUserInput(input)

    expect(result.isValid).toBe(false)
    expect(result.errors).toContain('name must be at least 2 characters')
    expect(result.errors).toContain('password must be less than 50 characters')
  })

  it('should accept valid input', () => {
    const input = {
      name: 'John Doe',
      email: 'john@example.com',
      password: 'SecurePassword123!'
    }
    const result = validateUserInput(input)

    expect(result.isValid).toBe(true)
    expect(result.errors).toHaveLength(0)
  })
})

describe('Input Sanitization', () => {
  it('should remove dangerous scripts', () => {
    const input = '<script>alert("xss")</script>Hello World'
    const sanitized = sanitizeUserInput(input)

    expect(sanitized).toBe('Hello World')
    expect(sanitized).not.toContain('<script>')
  })

  it('should preserve safe content', () => {
    const input = 'Hello <em>world</em> with <strong>formatting</strong>'
    const sanitized = sanitizeUserInput(input, { allowedTags: ['em', 'strong'] })

    expect(sanitized).toBe(input)
  })

  it('should trim whitespace', () => {
    const input = '  Hello World  '
    const sanitized = sanitizeUserInput(input)

    expect(sanitized).toBe('Hello World')
  })
})

describe('Schema Validation', () => {
  const userSchema = {
    name: { type: 'string', required: true, minLength: 2 },
    email: { type: 'email', required: true },
    age: { type: 'number', min: 0, max: 150 },
    role: { type: 'enum', values: ['user', 'admin', 'moderator'] },
  }

  it('should validate against schema', () => {
    const validData = {
      name: 'John Doe',
      email: 'john@example.com',
      age: 30,
      role: 'user',
    }

    const result = validateSchema(validData, userSchema)
    expect(result.isValid).toBe(true)
  })

  it('should detect schema violations', () => {
    const invalidData = {
      name: 'J', // Too short
      email: 'invalid-email',
      age: -5, // Below minimum
      role: 'invalid-role',
    }

    const result = validateSchema(invalidData, userSchema)
    expect(result.isValid).toBe(false)
    expect(result.errors).toHaveLength(4)
  })

  it('should handle missing required fields', () => {
    const incompleteData = { name: 'John Doe' }

    const result = validateSchema(incompleteData, userSchema)
    expect(result.isValid).toBe(false)
    expect(result.errors).toContain('email is required')
  })
})

Best Practices Summary

Unit Testing Excellence:
  • Test Pure Functions: Focus on functions without side effects
  • Clear Test Names: Describe exactly what is being tested
  • Fast Execution: Unit tests should run quickly in isolation
  • Edge Cases: Test boundary conditions and error scenarios

Key Principles:

  1. Isolation: Each test should be independent and not rely on external state
  2. Deterministic: Tests should produce the same result every time
  3. Fast: Unit tests should execute quickly (< 10ms per test)
  4. Focused: One assertion per test, testing one specific behavior
  5. Readable: Tests should serve as documentation of the function's behavior

Common Patterns:

// ✅ Good unit test structure
describe('FunctionName', () => {
  describe('when input is valid', () => {
    it('should return expected result', () => {
      const result = functionName(validInput)
      expect(result).toBe(expectedOutput)
    })
  })

  describe('when input is invalid', () => {
    it('should throw ValidationError', () => {
      expect(() => functionName(invalidInput)).toThrow(ValidationError)
    })
  })
})
Ready to test your UI components? Continue with UI Component Testing to learn about testing React components with Testing Library.
    Unit Testing | ShipSaaS Documentation | ShipSaaS - Launch your SaaS with AI in days