SavvySolve Docs

Testing Infrastructure

Vitest configuration with 100% coverage enforcement and CI integration

Testing Infrastructure

SavvySolve enforces 100% test coverage on all code. This isn't aspirational—CI literally blocks any PR that drops coverage below 100%. The PRD mandates test-driven development (TDD), and this infrastructure makes that requirement enforceable rather than optional.

Why 100% Coverage?

The project follows a strict TDD workflow:

RED → GREEN → REFACTOR
Write failing test → Make it pass → Clean up → Commit

100% coverage enforcement serves multiple purposes. It prevents regressions as the codebase grows. It enables confident refactoring since any behavioral change will fail a test. Most importantly, it documents expected behavior—tests serve as living specifications that never go stale.

Test Stack

The testing infrastructure uses Vitest as the test runner, chosen for its speed and native TypeScript support. Happy-dom provides a lightweight DOM implementation that's faster than jsdom while still supporting React component testing.

vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [react(), tsconfigPaths()],
  test: {
    environment: "happy-dom",
    globals: true,
    setupFiles: ["./tests/setup.ts"],
    include: ["**/*.test.{ts,tsx}"],
    exclude: ["node_modules", ".next", "tests/e2e/**"],
    coverage: {
      provider: "v8",
      reporter: ["text", "json", "html"],
      reportsDirectory: "./coverage",
      thresholds: {
        lines: 100,
        branches: 100,
        functions: 100,
        statements: 100,
      },
    },
  },
});

The thresholds block is where enforcement happens. If any metric drops below 100%, the test command exits with a non-zero code, failing CI.

Running Tests

Four npm scripts cover different testing workflows:

# Run all tests once
bun run test

# Run with coverage report (fails if <100%)
bun run test:coverage

# Open interactive UI in browser
bun run test:ui

# Watch mode for development
bun run test:watch

During development, test:watch runs continuously and re-executes tests as files change. Before committing, test:coverage verifies you haven't missed any code paths.

Test Setup

The setup file configures both jest-dom matchers and MSW (Mock Service Worker) for API mocking:

tests/setup.ts
import "@testing-library/jest-dom/vitest";
import { beforeAll, afterEach, afterAll } from "vitest";
import { server } from "./mocks/server";

// Start MSW server before all tests
beforeAll(() => {
  server.listen({ onUnhandledRequest: "warn" });
});

// Reset handlers after each test to ensure test isolation
afterEach(() => {
  server.resetHandlers();
});

// Close server after all tests complete
afterAll(() => {
  server.close();
});

This enables DOM assertions like toBeInTheDocument() and toHaveTextContent(), while also intercepting all HTTP requests to external APIs during tests.

Writing Tests

Tests live alongside the code they test. A file at lib/utils.ts has its tests at lib/utils.test.ts. This co-location makes it easy to find tests and keeps related code together.

lib/utils.test.ts
import { describe, it, expect } from "vitest";
import { cn } from "./utils";

describe("cn utility", () => {
  it("merges class names correctly", () => {
    const result = cn("px-4", "py-2");
    expect(result).toBe("px-4 py-2");
  });

  it("handles conditional classes", () => {
    const isActive = true;
    const result = cn("base", isActive && "active");
    expect(result).toBe("base active");
  });

  it("handles Tailwind class conflicts by taking last value", () => {
    const result = cn("px-4", "px-8");
    expect(result).toBe("px-8");
  });
});

Notice the test names describe behavior, not implementation. "Handles conditional classes" tells you what the function does without revealing how.

Test Fixtures

Shared test data factories live in tests/fixtures/. These provide consistent mock data across tests without duplicating setup code:

tests/fixtures/index.ts
import type { CustomerInfo } from "@/lib/db/schema/tickets";

export function createMockCustomerInfo(
  overrides: Partial<CustomerInfo> = {}
): CustomerInfo {
  return {
    name: "John Doe",
    phone: "+15551234567",
    email: "john@example.com",
    ...overrides,
  };
}

export function createMockTicket(overrides: Record<string, unknown> = {}) {
  return {
    id: "test-ticket-id",
    customerInfo: createMockCustomerInfo(),
    description: "Need help with my computer",
    deviceType: "windows" as const,
    urgency: "medium" as const,
    status: "pending" as const,
    solverId: null,
    claimedAt: null,
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides,
  };
}

Fixtures accept overrides to customize specific fields while providing sensible defaults. This keeps tests focused on what matters:

// Only specify what this test cares about
const urgentTicket = createMockTicket({ urgency: "critical" });

CI Integration

GitHub Actions runs the full test suite on every push and pull request:

.github/workflows/ci.yml
name: CI

on:
  push:
    branches: [master, main]
  pull_request:
    branches: [master, main]

jobs:
  lint-typecheck-test:
    name: Lint, Typecheck, Test
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
      
      - uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      - run: bun install --frozen-lockfile
      - run: bun run typecheck
      - run: bun run lint
      - run: bun run test:coverage

The pipeline runs in order: TypeScript check, then ESLint, then tests with coverage. If any step fails, the PR cannot be merged.

Coverage Exclusions

Not all files need test coverage. Configuration files, type definitions, and build scripts are excluded:

vitest.config.ts
coverage: {
  exclude: [
    "node_modules/**",
    ".next/**",
    "**/*.config.{ts,js,mjs}",
    "**/types/**",
    "tests/**",
    "scripts/**",
    "content/**",
    "**/*.d.ts",
  ],
}

The content/ directory contains MDX documentation, not application code. The scripts/ directory has one-off utilities like the database seed script. These don't need unit tests.

Mocking External APIs with MSW

SavvySolve integrates with several external services: Telnyx for voice/SMS, Stripe for payments, and Clerk for authentication. Testing code that calls these APIs requires mocking—we don't want tests making real API calls that cost money or have side effects.

Mock Service Worker (MSW) intercepts HTTP requests at the network level and returns mock responses. This approach has several advantages over mocking fetch or axios directly: it tests the actual HTTP layer, works with any HTTP client, and the mocks look like real API responses.

Handler Structure

Mock handlers live in tests/mocks/handlers/ with one file per service:

tests/mocks/
├── server.ts              # MSW server setup
├── handlers.ts            # Re-exports all handlers
├── factories.ts           # Mock data generators
└── handlers/
    ├── index.ts           # Combines all handlers
    ├── telnyx.ts          # Telnyx API mocks
    ├── stripe.ts          # Stripe API mocks
    └── clerk.ts           # Clerk API mocks

Writing Mock Handlers

Handlers use MSW's http namespace to intercept specific endpoints:

tests/mocks/handlers/telnyx.ts
import { http, HttpResponse } from "msw";

const TELNYX_API_BASE = "https://api.telnyx.com/v2";

export const telnyxHandlers = [
  // Mock sending an SMS
  http.post(`${TELNYX_API_BASE}/messages`, async ({ request }) => {
    const body = await request.json();

    return HttpResponse.json({
      data: {
        id: `mock-msg-${Date.now()}`,
        to: body.to,
        from: body.from,
        text: body.text,
        type: "SMS",
        record_type: "message",
        created_at: new Date().toISOString(),
      },
    });
  }),

  // Mock initiating a call
  http.post(`${TELNYX_API_BASE}/calls`, async ({ request }) => {
    const body = await request.json();

    return HttpResponse.json({
      data: {
        call_control_id: `mock-call-ctrl-${Date.now()}`,
        call_leg_id: `mock-leg-${Date.now()}`,
        call_session_id: `mock-session-${Date.now()}`,
        is_alive: true,
        record_type: "call",
      },
    });
  }),
];

The handlers parse the request body and return responses that match the real API structure. This ensures code consuming these APIs works correctly.

Mock Factories with Faker

For generating realistic test data, use the factories in tests/mocks/factories.ts:

tests/mocks/factories.ts
import { faker } from "@faker-js/faker";
import type { Ticket } from "@/lib/db/schema/tickets";

export function createMockTicket(overrides: Partial<Ticket> = {}): Ticket {
  return {
    id: faker.string.uuid(),
    customerInfo: {
      name: faker.person.fullName(),
      phone: faker.phone.number({ style: "international" }),
      email: faker.internet.email(),
    },
    description: faker.lorem.paragraph(),
    deviceType: faker.helpers.arrayElement(["iphone", "android", "mac", "windows"]),
    urgency: faker.helpers.arrayElement(["low", "medium", "high", "critical"]),
    status: "pending",
    solverId: null,
    claimedAt: null,
    createdAt: faker.date.recent({ days: 7 }),
    updatedAt: new Date(),
    ...overrides,
  };
}

The overrides parameter lets tests customize specific fields while getting realistic defaults for everything else.

Testing Webhook Handlers

Each handler file exports mock webhook payloads for testing webhook endpoints:

tests/mocks/handlers/stripe.ts
export const mockStripeWebhooks = {
  checkoutSessionCompleted: (sessionId: string, customerEmail: string) => ({
    id: `evt_mock_${Date.now()}`,
    type: "checkout.session.completed",
    data: {
      object: {
        id: sessionId,
        payment_status: "paid",
        customer_email: customerEmail,
      },
    },
  }),
};

Use these in tests to verify webhook processing:

import { mockStripeWebhooks } from "@/tests/mocks/handlers/stripe";

it("processes checkout completion webhook", async () => {
  const webhook = mockStripeWebhooks.checkoutSessionCompleted(
    "cs_test_123",
    "customer@example.com"
  );

  const response = await app.inject({
    method: "POST",
    url: "/api/webhooks/stripe",
    payload: webhook,
  });

  expect(response.statusCode).toBe(200);
});

Overriding Handlers Per-Test

Sometimes a test needs different behavior than the default handlers. Use server.use() to add one-off handlers:

import { server } from "@/tests/mocks/server";
import { http, HttpResponse } from "msw";

it("handles Telnyx API errors gracefully", async () => {
  // Override the default handler for this test only
  server.use(
    http.post("https://api.telnyx.com/v2/messages", () => {
      return HttpResponse.json(
        { errors: [{ code: "10008", title: "Rate limit exceeded" }] },
        { status: 429 }
      );
    })
  );

  // Test code that should handle the error...
});

The override only applies to this test—afterEach(() => server.resetHandlers()) in setup.ts restores the defaults.

Component Testing

React components use Testing Library, which encourages testing from the user's perspective:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "@/components/ui/button";

describe("Button", () => {
  it("calls onClick when clicked", async () => {
    const handleClick = vi.fn();
    render(<Button onClick={handleClick}>Click me</Button>);
    
    await userEvent.click(screen.getByRole("button"));
    
    expect(handleClick).toHaveBeenCalledOnce();
  });
});

Query by role (getByRole) rather than by test ID or class name. This tests the component as a user would interact with it and catches accessibility issues where semantic HTML is missing.

End-to-End Testing with agent-browser

While unit tests verify individual functions and components, end-to-end (E2E) tests validate complete user flows through the application. SavvySolve uses agent-browser, a CLI tool that provides browser automation without the complexity of raw Playwright APIs.

E2E Configuration

E2E tests have their own Vitest configuration with longer timeouts appropriate for browser operations:

vitest.e2e.config.ts
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [tsconfigPaths()],
  test: {
    include: ["tests/e2e/**/*.test.ts"],
    testTimeout: 60000, // 60 seconds for browser operations
    hookTimeout: 30000, // 30 seconds for setup/teardown
    globals: true,
    fileParallelism: false, // Sequential execution avoids browser conflicts
  },
});

E2E tests run sequentially (fileParallelism: false) because browser automation can have race conditions when multiple tests try to control the same browser instance.

Browser Helpers

The tests/e2e/helpers.ts file wraps agent-browser CLI commands in a convenient API:

tests/e2e/helpers.ts
export const browser = {
  async open(path: string): Promise<void> {
    const url = `http://localhost:3000${path}`;
    await run(`open "${url}"`);
  },

  async snapshot(): Promise<Record<string, unknown>> {
    return runJson("snapshot -i --json");
  },

  async click(ref: string): Promise<void> {
    await run(`click ${ref}`);
  },

  async fill(ref: string, value: string): Promise<void> {
    await run(`fill ${ref} "${value}"`);
  },

  async waitForText(text: string, timeout = 10000): Promise<void> {
    await run(`wait --text "${text}" --timeout ${timeout}`);
  },

  async close(): Promise<void> {
    await run("close");
  },
};

The snapshot() method returns interactive elements with @ref identifiers (like @e1, @e2) that can be passed to click() and fill(). This approach makes tests resilient to CSS and DOM structure changes.

Writing E2E Tests

E2E tests follow the same Vitest patterns as unit tests but operate on the real application:

tests/e2e/health.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { browser } from "./helpers";

describe("Health Check", () => {
  beforeAll(async () => {
    await browser.open("/");
  });

  afterAll(async () => {
    await browser.close();
  });

  it("loads the homepage", async () => {
    await browser.waitForText("Docs", 15000);
    const snapshot = await browser.snapshot();
    expect(snapshot).toBeDefined();
  });

  it("navigates to docs", async () => {
    await browser.open("/docs");
    await browser.waitForText("Documentation", 15000);
    const snapshot = await browser.snapshot();
    expect(snapshot).toBeDefined();
  });
});

The health check smoke test verifies the application boots correctly and critical navigation works. More complex E2E tests will test user flows like ticket submission and solver claiming.

Running E2E Tests

E2E tests require a running dev server:

# Terminal 1: Start the dev server
bun run dev

# Terminal 2: Run E2E tests
bun run test:e2e

E2E in CI

E2E tests run in their own GitHub Actions workflow, triggered on-demand or when the e2e label is added to a PR:

.github/workflows/e2e.yml
name: E2E Tests

on:
  workflow_dispatch:
  pull_request:
    types: [labeled]

jobs:
  e2e:
    if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'e2e'
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      
      - run: bun install --frozen-lockfile
      
      - name: Install agent-browser
        run: |
          npm install -g agent-browser
          agent-browser install

      - run: bun run build
      
      - name: Start server and run E2E tests
        run: |
          bun run start &
          sleep 10
          bun run test:e2e

E2E tests aren't part of the main CI pipeline because they require browser installation and a running server, which adds significant time. The on-demand workflow lets developers verify critical paths before major releases without slowing down everyday PRs.

File Structure

lib/
├── utils.ts
└── utils.test.ts        # Co-located with source

tests/
├── setup.ts             # Jest-dom + MSW setup
├── fixtures/
│   └── index.ts         # Re-exports factories + static fixtures
├── mocks/
│   ├── server.ts        # MSW server for Node.js
│   ├── handlers.ts      # Re-exports all handlers
│   ├── factories.ts     # Faker-based data generators
│   └── handlers/
│       ├── index.ts     # Combines all service handlers
│       ├── telnyx.ts    # Telnyx API + webhook mocks
│       ├── stripe.ts    # Stripe API + webhook mocks
│       └── clerk.ts     # Clerk API + webhook mocks
├── integration/         # Database integration tests (future)
└── e2e/
    ├── helpers.ts       # Browser automation wrappers
    └── health.test.ts   # Smoke test for critical paths

.github/
└── workflows/
    ├── ci.yml           # Main CI (lint, typecheck, unit tests)
    └── e2e.yml          # On-demand E2E testing

vitest.config.ts         # Unit test configuration
vitest.e2e.config.ts     # E2E test configuration
coverage/                # Generated coverage reports (gitignored)

On this page