This is Part 5 of a series on plan-based development with Claude Code. Today we explore our testing philosophy: fast feedback over comprehensive coverage.
The Testing Philosophy
Our testing strategy prioritizes fast feedback over comprehensive coverage. This might sound backwards, but here’s the reasoning:
- A test suite that takes 30 seconds to run gets executed constantly
- A test suite that takes 10 minutes gets executed only in CI
- Fast, frequent testing catches issues earlier in the development cycle
Test Infrastructure Setup
We use Vitest with shared configurations that optimize for speed:
export function createServerConfig(options: { name: string; testInclude?: string[];}) { return defineConfig({ test: { name: options.name, include: options.testInclude ?? ["src/**/*.test.ts"], passWithNoTests: true, // Don't fail empty packages testTimeout: 10000, hookTimeout: 10000, }, });}The passWithNoTests: true is crucial. It allows packages to exist without tests initially, enabling us to add tests incrementally rather than as a gate.
The Testing Pyramid in Practice
Our test distribution:
/\ / \ E2E Tests (Playwright) /----\ 5% - Critical user journeys / \ /--------\ Integration Tests / \ 15% - API endpoints, DB operations /------------\ / \ Unit Tests /----------------\ 80% - Functions, utilities, componentsUnit Tests: The Foundation
Fast, isolated, focused on pure logic:
describe("calculateAlienation", () => { it("identifies clear risk when alienation exceeds threshold", () => { const result = calculateAlienation({ controlResponses: mockControlData, testResponses: mockTestData, segments: [{ id: "gen-z", name: "Gen Z" }], threshold: 4, });
expect(result[0].verdict).toBe("clear_risk"); expect(result[0].alienatedProportion).toBeGreaterThan(0.15); });});Integration Tests: The Safety Net
Test real interactions between systems:
describe("Authentication Flow", () => { it("creates session on successful login", async () => { const response = await authClient.signIn.email({ password: "password123", });
expect(response.data?.session).toBeDefined(); });});E2E Tests: The User Journey
Full browser tests for critical paths:
test("user can log in and view dashboard", async ({ page }) => { await page.goto("/login"); await page.fill('[name="email"]', TEST_CLIENT_EMAIL); await page.fill('[name="password"]', TEST_CLIENT_PASSWORD); await page.click('button[type="submit"]');
await expect(page).toHaveURL("/dashboard"); await expect(page.locator("h1")).toContainText("Welcome");});Coverage as a Guide, Not a Goal
We track coverage but don’t chase arbitrary percentages. Coverage tells us where we lack tests, not whether we have enough. 100% coverage doesn’t mean 100% correct.
Practical Testing Patterns
Pattern 1: Test the Public API
// Don't test internal implementation// DO test exported functionsdescribe("@bts/results-ai", () => { it("exports expected tool names", async () => { const { RESULTS_TOOL_NAMES } = await import("../index"); expect(RESULTS_TOOL_NAMES).toBeDefined(); expect(RESULTS_TOOL_NAMES.analyze).toBe("analyze_results"); });});Pattern 2: Handle Async Timeouts
it("handles slow operations", async () => { // Dynamic imports can be slow in parallel test execution const { slowFunction } = await import("./slow-module"); expect(slowFunction()).toBeDefined();}, { timeout: 15000 }); // Explicit timeoutPattern 3: Case-Insensitive Assertions
it("renders email correctly", async () => { const text = await render(<Email {...props} />, { plainText: true }); // Email renderers may transform case expect(text.toLowerCase()).toContain(props.subject.toLowerCase());});The CI Integration
Our GitHub Actions workflow runs tests in parallel:
- name: Run tests with coverage run: bun run test:coverage
- name: Merge coverage reports run: bun run coverage:merge
- name: Add coverage to job summary run: cat coverage/coverage-report.md >> $GITHUB_STEP_SUMMARYCoverage reports appear directly in PR comments, making review easier.
Next up in Part 6: Claude Code as your AI pair programmer - custom commands and skills that encode your team’s workflows.