> ## Documentation Index
> Fetch the complete documentation index at: https://docs.charle.agency/llms.txt
> Use this file to discover all available pages before exploring further.

# Unit Tests

> Writing unit tests with Vitest in CharleOS

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**:

<CardGroup cols={2}>
  <Card title="Billing Calculations" icon="calculator">
    Value-based billing formula, day rates, prorated revenue
  </Card>

  <Card title="Time Utilities" icon="clock">
    Working days, t-shirt sizing, time formatting
  </Card>

  <Card title="Data Transformations" icon="arrows-rotate">
    Parsing, validation, formatting functions
  </Card>

  <Card title="Helper Functions" icon="tools">
    Pure functions with deterministic output
  </Card>
</CardGroup>

## 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

```typescript theme={null}
// __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

```typescript theme={null}
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

```typescript theme={null}
// 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

```typescript theme={null}
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

```typescript theme={null}
// 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

```typescript theme={null}
// __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

```typescript theme={null}
// __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

```typescript theme={null}
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

```bash theme={null}
# 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:

```bash theme={null}
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

```bash theme={null}
# Run tests with coverage
npm run test:coverage

# Open HTML report
open coverage/index.html
```

### Coverage Configuration

```typescript theme={null}
// 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

```typescript theme={null}
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

```typescript theme={null}
// 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

<AccordionGroup>
  <Accordion title="Test Behavior, Not Implementation" icon="bullseye">
    Focus on what the function does, not how:

    ```typescript theme={null}
    // ✅ 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
    });
    ```
  </Accordion>

  <Accordion title="Use Descriptive Test Names" icon="file-signature">
    Test names should describe the scenario:

    ```typescript theme={null}
    // ✅ Good
    it("returns average when actual is below average");
    it("rounds feedback budget up to nearest 15 minutes");

    // ❌ Bad
    it("test 1");
    it("works");
    ```
  </Accordion>

  <Accordion title="Keep Tests Independent" icon="arrows-split-up-and-left">
    Tests should not depend on each other:

    ```typescript theme={null}
    // ✅ 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
    });
    ```
  </Accordion>

  <Accordion title="Test Edge Cases" icon="diamond">
    Don't just test the happy path:

    ```typescript theme={null}
    describe("formatCurrency", () => {
      it("formats positive amounts");
      it("formats negative amounts");
      it("handles zero");
      it("handles very large numbers");
      it("handles fractional pence");
    });
    ```
  </Accordion>
</AccordionGroup>

## Debugging Tests

### Using Console

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

### Using Vitest UI

```bash theme={null}
# Launch Vitest UI
npx vitest --ui
```

Opens a browser-based UI for exploring tests.

### Debugging in VS Code

Add to `.vscode/launch.json`:

```json theme={null}
{
  "type": "node",
  "request": "launch",
  "name": "Debug Vitest Tests",
  "runtimeExecutable": "npm",
  "runtimeArgs": ["run", "test", "--", "--run"],
  "console": "integratedTerminal"
}
```

## Related Documentation

<CardGroup cols={2}>
  <Card title="Testing Overview" icon="flask" href="/developer/testing">
    Testing strategy and tools
  </Card>

  <Card title="E2E Tests" icon="robot" href="/developer/testing/e2e-tests">
    End-to-end testing with Playwright
  </Card>

  <Card title="Vitest Docs" icon="book" href="https://vitest.dev/">
    Official Vitest documentation
  </Card>
</CardGroup>
