Skip to main content
CharleOS uses a comprehensive testing strategy with Vitest for unit tests and Playwright for E2E tests. This ensures code quality and prevents regressions.

Testing Stack

Unit Tests

Vitest - Fast unit testing for utilities and business logic
  • Tests in __tests__/
  • Run with npm run test
  • Coverage reports included

E2E Tests

Playwright - Browser automation for critical user flows
  • Tests in e2e/
  • Run with npm run test:e2e
  • Cross-browser testing

Test Coverage

What We Test

Unit Tests (__tests__/):
  • Business logic (billing calculations, time utilities)
  • Data transformations
  • Helper functions
  • Type utilities
E2E Tests (e2e/):
  • Authentication flows
  • Critical user journeys (create task, approve quote)
  • Client portal functionality
  • Multi-user workflows
What We Don’t Test:
  • UI styling/layout
  • Third-party libraries
  • Database migrations
  • Simple CRUD operations

Running Tests

Unit Tests

# Run all unit tests
npm run test

# Run in watch mode (auto-rerun on changes)
npm run test:watch

# Run with coverage report
npm run test:coverage

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

E2E Tests

# Run E2E tests (headless)
npm run test:e2e

# Run with UI (interactive)
npm run test:e2e:ui

# Run specific test file
npx playwright test e2e/auth.spec.ts

# Run with debugging
npx playwright test --debug

Unit Test Examples

Testing Utilities

// __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);  // Uses average (minimum)
  });

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

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

Testing Date Utilities

// __tests__/working-days.test.ts
import { describe, it, expect } from "vitest";
import { calculateWorkingDays, isWorkingDay } from "@/lib/utils/working-days";

describe("isWorkingDay", () => {
  it("returns true for Monday-Friday", () => {
    const monday = new Date("2024-01-22");  // Monday
    expect(isWorkingDay(monday)).toBe(true);
  });

  it("returns false for Saturday-Sunday", () => {
    const saturday = new Date("2024-01-20");  // Saturday
    expect(isWorkingDay(saturday)).toBe(false);
  });
});

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

  it("excludes weekends", () => {
    const startDate = new Date("2024-01-22");  // Monday
    const endDate = new Date("2024-01-28");    // Sunday (next week)
    
    const workingDays = calculateWorkingDays(startDate, endDate);
    
    expect(workingDays).toBe(5);  // Only Mon-Fri
  });
});

Testing T-shirt Sizes

// __tests__/tshirt-sizes.test.ts
import { describe, it, expect } from "vitest";
import {
  getTshirtSizeConfig,
  calculateWorkSplit,
  roundUpToNearest15,
} from "@/lib/utils/tshirt-sizes";

describe("getTshirtSizeConfig", () => {
  it("returns correct config for M size", () => {
    const config = getTshirtSizeConfig("m");
    
    expect(config.label).toBe("M");
    expect(config.minMinutes).toBe(60);
    expect(config.maxMinutes).toBe(240);
  });
});

describe("calculateWorkSplit", () => {
  it("splits 270 minutes correctly (80/20)", () => {
    const { coreMinutes, feedbackBudgetMinutes } = calculateWorkSplit(270);
    
    expect(feedbackBudgetMinutes).toBe(60);  // 20% rounded UP to 15min
    expect(coreMinutes).toBe(210);           // Remainder
  });

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

describe("roundUpToNearest15", () => {
  it("rounds up to nearest 15 minutes", () => {
    expect(roundUpToNearest15(54)).toBe(60);
    expect(roundUpToNearest15(60)).toBe(60);
    expect(roundUpToNearest15(61)).toBe(75);
  });
});

E2E Test Examples

Authentication Flow

// e2e/auth.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Authentication", () => {
  test("should redirect to auth page when not logged in", async ({ page }) => {
    await page.goto("/dashboard");
    
    await expect(page).toHaveURL("/auth");
  });

  test("should sign in with Google", async ({ page }) => {
    await page.goto("/auth");
    
    // Click Google sign-in button
    await page.click('button:has-text("Sign in with Google")');
    
    // (In real tests, you'd mock the OAuth flow)
    // For now, verify redirect to Google
    await expect(page).toHaveURL(/accounts\.google\.com/);
  });
});

Task Creation Flow

// e2e/tasks.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Task Management", () => {
  test.beforeEach(async ({ page }) => {
    // Sign in as admin
    await page.goto("/auth");
    // (Sign-in logic here)
  });

  test("should create a new task", async ({ page }) => {
    await page.goto("/tasks");
    
    // Click create button
    await page.click('button:has-text("Create Task")');
    
    // Fill form
    await page.fill('input[name="title"]', "Test Task");
    await page.selectOption('select[name="clientId"]', "client-123");
    await page.selectOption('select[name="pmId"]', "user-456");
    
    // Submit
    await page.click('button[type="submit"]');
    
    // Verify success
    await expect(page.locator('text=Task created successfully')).toBeVisible();
    await expect(page).toHaveURL(/\/tasks\/task-/);
  });
});

Client Portal Flow

// e2e/client-portal.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Client Portal", () => {
  test("should view tasks as client user", async ({ page }) => {
    // Sign in as client
    await page.goto("/client-portal/sign-in");
    await page.fill('input[name="email"]', "[email protected]");
    await page.fill('input[name="password"]', "password");
    await page.click('button[type="submit"]');
    
    // Navigate to tasks
    await page.click('a:has-text("Tasks")');
    
    // Verify tasks are visible
    await expect(page.locator('[data-testid="task-card"]')).toHaveCount(3);
  });

  test("should submit a help desk ticket", async ({ page }) => {
    // Sign in as client
    await page.goto("/client-portal/sign-in");
    // (Sign-in logic)
    
    // Navigate to help desk
    await page.goto("/client-portal/help-desk");
    
    // Click create ticket
    await page.click('button:has-text("New Ticket")');
    
    // Fill form
    await page.fill('input[name="title"]', "Bug Report");
    await page.fill('textarea[name="description"]', "Something is broken");
    await page.selectOption('select[name="type"]', "bug");
    await page.selectOption('select[name="priority"]', "high");
    
    // Submit
    await page.click('button[type="submit"]');
    
    // Verify success
    await expect(page.locator('text=Ticket created')).toBeVisible();
  });
});

Test Configuration

Vitest Config

// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    globals: true,
    environment: "node",
    include: ["__tests__/**/*.test.ts"],
    setupFiles: ["__tests__/setup.ts"],
    coverage: {
      provider: "v8",
      reporter: ["text", "json", "html"],
      include: ["lib/**/*.ts", "app/api/**/*.ts"],
      exclude: ["lib/db/**", "lib/auth*.ts"],
    },
  },
});

Playwright Config

// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry",
  },
  projects: [
    {
      name: "setup",
      testMatch: /global-setup\.ts/,
    },
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },
      dependencies: ["setup"],
    },
  ],
  webServer: {
    command: "npm run dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
  },
});

CI/CD Integration

Tests run automatically on every pull request:
# .github/workflows/ci.yml
jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - name: Run unit tests
        run: npm run test:coverage
      
      - name: Upload coverage
        uses: codecov/codecov-action@v3

  e2e-tests:
    runs-on: ubuntu-latest
    steps:
      - name: Install Playwright
        run: npx playwright install --with-deps
      
      - name: Run E2E tests
        run: npm run test:e2e

Best Practices

Focus on behavior, not internal details:
// ✅ Good: Test behavior
it("calculates billable time correctly", () => {
  const result = calculateBillable({ actual: 175, average: 150, max: 200 });
  expect(result).toBe(175);
});

// ❌ Bad: Test implementation
it("uses Math.min and Math.max", () => {
  // Testing internal implementation details
});
Unit tests should run in milliseconds:
  • No database calls in unit tests
  • Mock external dependencies
  • Use in-memory data
// ✅ Good: Clear and specific
it("returns average when actual is below average", () => { ... });

// ❌ Bad: Vague
it("works correctly", () => { ... });
Don’t just test the happy path:
describe("calculateWorkingDays", () => {
  it("handles same start and end date");
  it("handles start date after end date");
  it("handles weekends");
  it("handles single day");
  it("handles bank holidays");
});

Debugging Tests

Vitest

# Run tests with debugging
npm run test -- --inspect-brk

# Run specific test
npm run test -- -t "calculates billable time"

# Show console.log output
npm run test -- --reporter=verbose

Playwright

# Run with headed browser
npx playwright test --headed

# Run with debugging
npx playwright test --debug

# Show trace viewer
npx playwright show-trace trace.zip

# Generate code for test
npx playwright codegen http://localhost:3000

Coverage Reports

# Generate coverage report
npm run test:coverage

# Open HTML report
open coverage/index.html
Target Coverage:
  • Business logic: 80%+ coverage
  • Utilities: 90%+ coverage
  • API routes: Not required (tested via E2E)