Ai-setup caliber-testing
Writes Vitest tests following project patterns: __tests__/ directories, vi.mock() for module mocking with vi.hoisted() for test-time factories, global LLM mock from src/test/setup.ts, environment variable save/restore in beforeEach/afterEach, vi.clearAllMocks() lifecycle, and test file organization. Use when user says 'write tests', 'add test coverage', 'test this', creates *.test.ts files, or when test failures appear in CI. Do NOT use for non-test code or for debugging without writing tests.
git clone https://github.com/caliber-ai-org/ai-setup
T=$(mktemp -d) && git clone --depth=1 https://github.com/caliber-ai-org/ai-setup "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.claude/skills/caliber-testing" ~/.claude/skills/caliber-ai-org-ai-setup-caliber-testing-944497 && rm -rf "$T"
.claude/skills/caliber-testing/SKILL.mdCaliber Testing
Critical
- All test files MUST be placed in
directories parallel to source files:__tests__/src/[module]/__tests__/[module].test.ts - Register any new
directories in__tests__/
'svitest.config.ts
glob (already configured:include
)src/**/*.test.ts - NEVER mock
— it is the global LLM provider mock already applied to all testssrc/test/setup.ts - Environment variable tests MUST save
inprocess.env
, restore inbeforeEach
, and explicitly delete env vars to test absenceafterEach - Temporary file/directory cleanup MUST happen in
, not in individual test cleanup. UseafterEachfs.rmSync(dir, { recursive: true, force: true }) - When a test requires unmocking modules mocked in global setup, call
BEFORE the import statementvi.unmock('../module.js') - Run
locally andpnpm test
before committing to verify coverage thresholds (lines: 50, functions: 50, branches: 50, statements: 50)pnpm test:coverage
Instructions
Step 1: Create the test file in the correct directory
Create
src/[module]/__tests__/[module].test.ts. The parent source file is src/[module]/[module].ts.
Verify: The
__tests__ directory exists at the same level as the source file being tested.
Step 2: Import test framework
At the top of every test file, import from
vitest:
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
Add additional imports based on what you're testing:
- File system:
import fs from 'fs'; import path from 'path'; import os from 'os'; - Temporary files: Use
fs.mkdtempSync(path.join(os.tmpdir(), 'caliber-prefix-')) - Exec:
import { execSync } from 'child_process';
Verify: All required test utilities are imported before test definitions.
Step 3: Set up module mocking (if needed)
If testing a module that imports other modules you want to mock:
vi.mock('../config.js', () => ({ loadConfig: vi.fn(), writeConfigFile: vi.fn(), }));
For complex mocks with test-time factory functions (hoisted):
const { mockLoadConfig } = vi.hoisted(() => ({ mockLoadConfig: vi.fn(), })); vi.mock('../config.js', () => ({ loadConfig: () => mockLoadConfig(), }));
For unmocking global setup mocks (e.g., to test llm/index.js itself):
vi.unmock('../index.js');
Place all
vi.mock() and vi.unmock() calls BEFORE importing the module under test.
Verify: Mock declarations appear before the import of the module being tested.
Step 4: Organize tests in describe blocks
Group related tests with
describe():
describe('functionName', () => { it('returns X when Y', () => { // test body }); });
Verify: Each
it() test has a clear, complete assertion.
Step 5: Manage environment and process state
For tests that modify
process.env or process.argv:
describe('config tests', () => { const originalEnv = process.env; const originalArgv = process.argv; beforeEach(() => { process.env = { ...originalEnv }; // Copy, not reference process.argv = [...originalArgv]; delete process.env.SPECIFIC_VAR; // Explicitly remove vars to test absence }); afterEach(() => { process.env = originalEnv; process.argv = originalArgv; }); it('tests env var behavior', () => { process.env.MY_VAR = 'test'; // test code }); });
Verify:
beforeEach creates a copy of env/argv; afterEach restores originals; unused env vars are explicitly deleted with delete process.env.VAR.
Step 6: Manage temporary files and directories
For file system tests, create temporary directories and clean them up:
describe('file tree', () => { const dirs: string[] = []; afterEach(() => { for (const d of dirs) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} } dirs.length = 0; }); it('processes files', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'caliber-test-')); dirs.push(tmp); // use tmp directory }); });
Verify: All temporary directories are pushed to a cleanup array and removed in
afterEach.
Step 7: Handle mock state cleanup
Before each test, clear mock call history to avoid pollution between tests:
beforeEach(() => { vi.clearAllMocks(); // Reset all mock call counts and return values }); afterEach(() => { vi.restoreAllMocks(); // Restore original implementations vi.resetModules(); // Reset cached module imports (if you reload modules) });
Verify:
beforeEach calls vi.clearAllMocks() for providers and afterEach calls vi.restoreAllMocks().
Step 8: Test assertions
Write assertions using
expect(). Match the patterns from existing tests:
// Simple checks expect(value).toBe(expected); expect(array).toContain(item); expect(fn).toThrow('error message'); // Instance checks expect(obj).toBeInstanceOf(ClassName); // Mock checks expect(mockFn).toHaveBeenCalledTimes(1); expect(mockFn).toHaveBeenCalledWith(arg); // File system checks expect(fs.existsSync(path)).toBe(true);
Verify: Each test has at least one assertion and uses appropriate
expect() matchers.
Step 9: Run tests
Run tests locally before committing:
pnpm test # Run all tests in watch mode pnpm test:coverage # Check coverage thresholds pnpm test -- src/my/path/__tests__/my.test.ts # Run single test file
Verify: All tests pass and coverage thresholds are met (lines: 50%, functions: 50%, branches: 50%).
Examples
Example 1: Testing a module with environment variables (config.test.ts pattern)
User asks: "Write tests for my config loader that reads from env vars and a config file."
Actions taken:
- Create
src/lib/__tests__/config.test.ts - Import test framework and fs module
- Mock
andfsos - Save/restore
in beforeEach/afterEachprocess.env - Delete specific env vars to test absence
- Test each branch: env vars set, file exists, both, neither
Result:
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import fs from 'fs'; vi.mock('fs'); vi.mock('os', () => ({ default: { homedir: () => '/home/user' } })); import { loadConfig } from '../config.js'; describe('config', () => { const originalEnv = process.env; beforeEach(() => { vi.clearAllMocks(); process.env = { ...originalEnv }; delete process.env.ANTHROPIC_API_KEY; delete process.env.OPENAI_API_KEY; }); afterEach(() => { process.env = originalEnv; }); it('returns env config when ANTHROPIC_API_KEY is set', () => { process.env.ANTHROPIC_API_KEY = 'sk-ant-test'; const config = loadConfig(); expect(config?.provider).toBe('anthropic'); }); it('returns null when no env vars set', () => { expect(loadConfig()).toBeNull(); }); });
Example 2: Testing file system operations with temp cleanup (file-tree.test.ts pattern)
User asks: "Write tests for file tree analysis."
Actions taken:
- Create
src/fingerprint/__tests__/file-tree.test.ts - Set up a cleanup array for temp directories
- Create temp dirs in each test and push to cleanup array
- Clean up all temp dirs in afterEach
- Test with various file mtimes and directory structures
Result:
import { describe, it, expect, afterEach } from 'vitest'; import fs from 'fs'; import path from 'path'; import os from 'os'; import { getFileTree } from '../file-tree.js'; const dirs: string[] = []; afterEach(() => { for (const d of dirs) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} } dirs.length = 0; }); describe('getFileTree', () => { it('returns files sorted by mtime descending', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'caliber-ft-')); dirs.push(tmp); fs.writeFileSync(path.join(tmp, 'a.ts'), 'a'); fs.writeFileSync(path.join(tmp, 'b.ts'), 'b'); const tree = getFileTree(tmp); expect(tree).toHaveLength(2); }); });
Example 3: Testing with module mocking and factory functions (index.test.ts pattern)
User asks: "Write tests for the provider factory that instantiates different providers based on config."
Actions taken:
- Create
src/llm/__tests__/index.test.ts - Use
to test the real modulevi.unmock('../index.js') - Create mock classes in
vi.hoisted() - Mock dependencies (config, provider classes)
- Test provider selection logic
- Use
between tests to clear cached instancesresetProvider()
Result:
import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.unmock('../index.js'); const { mockLoadConfig, MockAnthropicProvider } = vi.hoisted(() => { class MockAnthropicProvider { config: unknown; call = vi.fn(); constructor(c: unknown) { this.config = c; } } return { mockLoadConfig: vi.fn(), MockAnthropicProvider, }; }); vi.mock('../config.js', () => ({ loadConfig: () => mockLoadConfig(), })); vi.mock('../anthropic.js', () => ({ AnthropicProvider: MockAnthropicProvider, })); import { getProvider, resetProvider } from '../index.js'; describe('getProvider', () => { beforeEach(() => { vi.clearAllMocks(); resetProvider(); }); it('creates AnthropicProvider for anthropic config', () => { mockLoadConfig.mockReturnValue({ provider: 'anthropic', model: 'claude-sonnet-4-6', apiKey: 'sk-test', }); const provider = getProvider(); expect(provider).toBeInstanceOf(MockAnthropicProvider); }); });
Common Issues
"Cannot find module" when running tests
- Cause:
called after the import statementvi.mock() - Fix: Move all
andvi.mock()
calls to the TOP of the file, before anyvi.unmock()
statementsimport
Environment variable persists across tests
- Cause:
assigns by reference instead of copying:beforeEachprocess.env = originalEnv - Fix: Create a copy in
:beforeEachprocess.env = { ...originalEnv }; delete process.env.VAR
Temporary files not cleaned up, filling disk
- Cause:
not called or temp paths not trackedafterEach - Fix: Use centralized cleanup array:
pushed to in tests, cleaned indirs: string[] = []
withafterEachfs.rmSync(..., { recursive: true, force: true })
Mock return value from previous test bleeds into next test
- Cause:
not called invi.clearAllMocks()beforeEach - Fix: Add
as first statement invi.clearAllMocks()beforeEach
"vi.mocked() is not a function" when accessing mock calls
- Cause: Trying to use
on a non-mocked modulevi.mocked() - Fix: Ensure the module is mocked with
before importing, then cast:vi.mock()const mockFn = vi.mocked(importedFn)
Test passes locally but fails in CI
- Cause: Tests rely on real file system or network (not mocked)
- Fix: Check if temp files are being created in real directories instead of mocked ones. Verify all external calls use
for fs/http/execvi.mock()
Coverage threshold failures: "lines not covered", "statements not covered"
- Cause: Branches or error paths not tested
- Fix: Run
locally to see untested lines. Addpnpm test:coverage
tests for error cases, edge conditions, and branches that return different valuesit() - Example: If
has 0% branch coverage, add tests: one with x=true, one with x=falseif (x) return 'a'; else return 'b';
"expected 1 error but got 0" when testing error throws
- Cause: Error not actually thrown, or thrown asynchronously
- Fix: For sync functions:
. For async:expect(() => fn()).toThrow('message')
or useexpect(async () => { await fn() }).rejects.toThrow('message')await expect(promise).rejects.toThrow()
Mock factory returns undefined
- Cause:
variables used before definition in the same blockvi.hoisted() - Fix: Ensure
block returns all factories, andvi.hoisted()
blocks use them after the hoisted definitionvi.mock()
Test flakes (sometimes passes, sometimes fails)
- Cause: Timing issues or file system race conditions in cleanup
- Fix: For file cleanup, use
in{ force: true }
. For timing, avoidrmSync
; usesetTimeout
andvi.useFakeTimers()
if needed. Check for leftover files from previous test runs invi.runAllTimers()beforeEach