Skip to main content
CharleOS uses Vitest for fast, modern unit testing. This page covers how to write and run unit tests.

What to Unit Test

Focus on business logic and utility functions:

Billing Calculations

Value-based billing formula, day rates, prorated revenue

Time Utilities

Working days, t-shirt sizing, time formatting

Data Transformations

Parsing, validation, formatting functions

Helper Functions

Pure functions with deterministic output

Test Structure

File Organization

__tests__/
├── billing.test.ts          # Billing calculations
├── tshirt-sizes.test.ts     # T-shirt sizing logic
├── working-days.test.ts     # Date utilities
├── day-rate.test.ts         # Day rate calculations
├── currency.test.ts         # Currency formatting
└── setup.ts                 # Test setup file

Basic Test Example

// __tests__/billing.test.ts
import { describe, it, expect } from "vitest";
import { calculateBillable } from "@/lib/utils/billing";

describe("calculateBillable", () => {
  it("returns minimum when actual is below minimum", () => {
    const result = calculateBillable({
      actual: 100,
      average: 150,
      maximum: 200,
    });
    
    expect(result).toBe(150);
  });

  it("returns maximum when actual exceeds maximum", () => {
    const result = calculateBillable({
      actual: 250,
      average: 150,
      maximum: 200,
    });
    
    expect(result).toBe(200);
  });

  it("returns actual when within range", () => {
    const result = calculateBillable({
      actual: 175,
      average: 150,
      maximum: 200,
    });
    
    expect(result).toBe(175);
  });
});

Vitest Features

Test Blocks

import { describe, it, expect, beforeEach, afterEach } from "vitest";

describe("Feature Name", () => {
  let testData;

  beforeEach(() => {
    // Run before each test
    testData = setupTestData();
  });

  afterEach(() => {
    // Run after each test
    cleanup();
  });

  it("should do something", () => {
    expect(testData).toBeDefined();
  });

  it("should do something else", () => {
    expect(true).toBe(true);
  });
});

Assertions

// Equality
expect(result).toBe(5);               // Strict equality (===)
expect(result).toEqual({ foo: "bar" });  // Deep equality

// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();

// Numbers
expect(result).toBeGreaterThan(10);
expect(result).toBeGreaterThanOrEqual(10);
expect(result).toBeLessThan(100);
expect(result).toBeLessThanOrEqual(100);
expect(result).toBeCloseTo(10.5, 1);  // Floating point

// Strings
expect(str).toMatch(/pattern/);
expect(str).toContain("substring");

// Arrays
expect(array).toContain(item);
expect(array).toHaveLength(3);

// Objects
expect(obj).toHaveProperty("key");
expect(obj).toHaveProperty("key", "value");
expect(obj).toMatchObject({ foo: "bar" });

// Exceptions
expect(() => fn()).toThrow();
expect(() => fn()).toThrow(Error);
expect(() => fn()).toThrow("error message");

Async Tests

it("should handle async operations", async () => {
  const result = await fetchData();
  expect(result).toBeDefined();
});

it("should resolve promise", () => {
  return expect(promise).resolves.toBe("value");
});

it("should reject promise", () => {
  return expect(promise).rejects.toThrow("error");
});

Common Testing Patterns

Testing Pure Functions

// lib/utils/currency.ts
export function formatCurrency(pence: number): string {
  return${(pence / 100).toFixed(2)}`;
}

// __tests__/currency.test.ts
describe("formatCurrency", () => {
  it("formats pence as pounds", () => {
    expect(formatCurrency(1250)).toBe("£12.50");
  });

  it("handles zero", () => {
    expect(formatCurrency(0)).toBe("£0.00");
  });

  it("handles negative values", () => {
    expect(formatCurrency(-500)).toBe("£-5.00");
  });

  it("always shows two decimal places", () => {
    expect(formatCurrency(1000)).toBe("£10.00");
    expect(formatCurrency(1001)).toBe("£10.01");
  });
});

Testing Date Functions

// __tests__/working-days.test.ts
import { calculateWorkingDays } from "@/lib/utils/working-days";

describe("calculateWorkingDays", () => {
  it("counts Monday to Friday as 5 days", () => {
    const startDate = new Date("2024-01-22");  // Monday
    const endDate = new Date("2024-01-26");    // Friday
    
    expect(calculateWorkingDays(startDate, endDate)).toBe(5);
  });

  it("excludes weekends", () => {
    const startDate = new Date("2024-01-20");  // Saturday
    const endDate = new Date("2024-01-21");    // Sunday
    
    expect(calculateWorkingDays(startDate, endDate)).toBe(0);
  });

  it("handles same start and end date", () => {
    const date = new Date("2024-01-22");  // Monday
    
    expect(calculateWorkingDays(date, date)).toBe(1);
  });

  it("handles start date after end date", () => {
    const startDate = new Date("2024-01-26");
    const endDate = new Date("2024-01-22");
    
    expect(calculateWorkingDays(startDate, endDate)).toBe(0);
  });
});

Testing Complex Calculations

// __tests__/tshirt-sizes.test.ts
import { calculateWorkSplit } from "@/lib/utils/tshirt-sizes";

describe("calculateWorkSplit", () => {
  it("splits M size (270 min) into 80/20", () => {
    const { coreMinutes, feedbackBudgetMinutes } = calculateWorkSplit(270);
    
    // 20% = 54 min, rounded UP to 60
    expect(feedbackBudgetMinutes).toBe(60);
    // Remainder = 270 - 60 = 210
    expect(coreMinutes).toBe(210);
  });

  it("handles XS size (no split)", () => {
    const { coreMinutes, feedbackBudgetMinutes } = calculateWorkSplit(15);
    
    expect(coreMinutes).toBe(15);
    expect(feedbackBudgetMinutes).toBe(0);
  });

  it("rounds feedback budget up to nearest 15 min", () => {
    const { feedbackBudgetMinutes } = calculateWorkSplit(100);
    
    // 20% = 20 min, rounded UP to 30
    expect(feedbackBudgetMinutes).toBe(30);
  });

  it("ensures total equals input", () => {
    const totalMinutes = 480;  // 8 hours
    const { coreMinutes, feedbackBudgetMinutes } = calculateWorkSplit(totalMinutes);
    
    expect(coreMinutes + feedbackBudgetMinutes).toBe(totalMinutes);
  });
});

Testing with Edge Cases

describe("calculateDayRate", () => {
  it("calculates day rate correctly", () => {
    const dayRate = calculateDayRate({
      monthlyCost: 500000,  // £5000
      monthlyHours: 100,
    });
    
    expect(dayRate).toBe(38462);  // £384.62 per day (7.5h)
  });

  it("handles zero hours", () => {
    const dayRate = calculateDayRate({
      monthlyCost: 500000,
      monthlyHours: 0,
    });
    
    expect(dayRate).toBe(0);
  });

  it("handles zero cost", () => {
    const dayRate = calculateDayRate({
      monthlyCost: 0,
      monthlyHours: 100,
    });
    
    expect(dayRate).toBe(0);
  });

  it("rounds to nearest penny", () => {
    const dayRate = calculateDayRate({
      monthlyCost: 333333,
      monthlyHours: 100,
    });
    
    // Should be rounded, not have many decimal places
    expect(dayRate % 1).toBe(0);  // Is an integer (pence)
  });
});

Running Tests

Basic Commands

# Run all tests
npm run test

# Run in watch mode
npm run test:watch

# Run specific file
npm run test __tests__/billing.test.ts

# Run tests matching pattern
npm run test -- -t "calculate"

# Run with coverage
npm run test:coverage

Watch Mode

Watch mode automatically reruns tests when files change:
npm run test:watch
Features:
  • Press a to run all tests
  • Press f to run only failed tests
  • Press t to filter by test name
  • Press p to filter by filename
  • Press q to quit

Test Coverage

Generating Coverage Reports

# Run tests with coverage
npm run test:coverage

# Open HTML report
open coverage/index.html

Coverage Configuration

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: "v8",
      reporter: ["text", "json", "html"],
      include: [
        "lib/**/*.ts",           // Utility functions
        "app/api/**/*.ts",       // API routes
        "components/**/*.tsx",   // Components
      ],
      exclude: [
        "lib/db/**",            // Database schema
        "lib/auth*.ts",         // Auth config
        "lib/client-auth*.ts",  // Client auth config
      ],
    },
  },
});

Coverage Thresholds

Target coverage:
  • Business logic: 80%+
  • Utilities: 90%+
  • Components: Not required

Mocking

Mocking Functions

import { vi } from "vitest";

describe("with mocks", () => {
  it("mocks a function", () => {
    const mockFn = vi.fn();
    mockFn.mockReturnValue(42);
    
    expect(mockFn()).toBe(42);
    expect(mockFn).toHaveBeenCalled();
  });

  it("mocks implementation", () => {
    const mockFn = vi.fn((x) => x * 2);
    
    expect(mockFn(21)).toBe(42);
  });
});

Mocking Modules

// Mock entire module
vi.mock("@/lib/db", () => ({
  db: {
    select: vi.fn(),
    insert: vi.fn(),
  },
}));

// Partial mock
vi.mock("@/lib/services/tasks", async () => {
  const actual = await vi.importActual("@/lib/services/tasks");
  return {
    ...actual,
    createTask: vi.fn(),
  };
});

Best Practices

Focus on what the function does, not how:
// ✅ Good: Test behavior
it("calculates billable time using MIN/MAX formula", () => {
  expect(calculateBillable({ actual: 175, ... })).toBe(175);
});

// ❌ Bad: Test implementation
it("calls Math.min and Math.max", () => {
  // Testing internal details
});
Test names should describe the scenario:
// ✅ Good
it("returns average when actual is below average");
it("rounds feedback budget up to nearest 15 minutes");

// ❌ Bad
it("test 1");
it("works");
Tests should not depend on each other:
// ✅ Good: Each test is independent
describe("calculator", () => {
  it("adds numbers", () => {
    expect(add(1, 2)).toBe(3);
  });
  
  it("subtracts numbers", () => {
    expect(subtract(5, 3)).toBe(2);
  });
});

// ❌ Bad: Tests depend on shared state
let result;
it("test 1", () => {
  result = calculate();
});
it("test 2", () => {
  expect(result).toBe(10);  // Depends on test 1
});
Don’t just test the happy path:
describe("formatCurrency", () => {
  it("formats positive amounts");
  it("formats negative amounts");
  it("handles zero");
  it("handles very large numbers");
  it("handles fractional pence");
});

Debugging Tests

Using Console

it("debugs with console", () => {
  const result = calculate(input);
  console.log("Result:", result);  // Shown with --reporter=verbose
  expect(result).toBe(expected);
});

Using Vitest UI

# Launch Vitest UI
npx vitest --ui
Opens a browser-based UI for exploring tests.

Debugging in VS Code

Add to .vscode/launch.json:
{
  "type": "node",
  "request": "launch",
  "name": "Debug Vitest Tests",
  "runtimeExecutable": "npm",
  "runtimeArgs": ["run", "test", "--", "--run"],
  "console": "integratedTerminal"
}