TESTING.md
October 12, 2025 · View on GitHub
This document provides comprehensive testing guidelines for the spec-kit-sdk project, combining industry best practices with project-specific patterns.
Testing Philosophy
FIRST Principles
All tests should follow the FIRST principles:
- Fast: Tests should run quickly
- Isolated: Tests should not depend on each other
- Repeatable: Tests should produce consistent results in any environment
- Self-checking: Tests should have clear pass/fail outcomes
- Timely: Tests should be written alongside production code
Testing Approach
We follow a hybrid approach combining Classical TDD and Mockist TDD:
- Classical TDD: Use real objects when possible, focus on final state
- Mockist TDD: Use test doubles for external dependencies and complex collaborations
Test Double Patterns
Based on Martin Fowler's Test Double taxonomy, we use these patterns:
1. Dummy Objects
Objects passed around but never actually used. Typically fill parameter lists.
// Example: Dummy callback that's never called
const dummyCallback = () => {}
await someFunction(data, dummyCallback)
2. Fake Objects
Working implementations with shortcuts unsuitable for production.
Example: MockSyncAdapter (plugins/sync/test/fixtures/mock-adapter.ts:5)
export class MockSyncAdapter extends SyncAdapter {
private mockSpecs = new Map<string, any>() // In-memory storage instead of real sync
async push(spec: SpecDocument): Promise<RemoteRef> {
// Simplified implementation for testing
this.mockSpecs.set(spec.name, { id: Math.random(), title: spec.name })
return { id: mockData.id, type: 'parent' }
}
}
3. Stubs
Provide predetermined answers to calls made during tests.
// Example: Stub that returns predefined values
class StubAuthService {
async checkAuth(): Promise<boolean> {
return true // Always returns true
}
}
4. Spies
Stubs that record information about how they were called.
// Example: Tracking calls in our test doubles
class SpyGitHubClient {
public createIssueCalls: Array<{ title: string, body: string }> = []
async createIssue(title: string, body: string): Promise<number> {
this.createIssueCalls.push({ title, body }) // Records the call
return 123
}
}
5. Mocks
Pre-programmed with expectations of the calls they should receive.
Example: EnhancedMockGitHubClient (plugins/sync/test/mocks/github-client.mock.ts:16)
export class EnhancedMockGitHubClient extends GitHubClient {
// Call tracking for behavior verification
public createIssueCalls: Array<{ title: string, body: string, labels?: string[] }> = []
public updateIssueCalls: Array<{ number: number, updates: GitHubIssueUpdate }> = []
// Error injection capabilities
private methodErrorMap = new Map<string, Error>()
setMethodError(methodName: string, error: Error): void {
this.methodErrorMap.set(methodName, error)
}
override async createIssue(title: string, body: string, labels?: string[]): Promise<number> {
this.checkMethodError('createIssue') // Can throw expected errors
this.createIssueCalls.push({ title, body, labels })
return this.mockCreateIssueResult ?? this.nextIssueId++
}
}
Verification Strategies
State Verification
Verify the final state after an operation.
test('should update issue state', async () => {
// Arrange
const mockClient = new EnhancedMockGitHubClient()
mockClient.setMockIssue(123, { number: 123, state: 'OPEN' })
// Act
await mockClient.closeIssue(123)
// Assert - Check final state
const issue = await mockClient.getIssue(123)
expect(issue?.state).toBe('CLOSED')
})
Behavior Verification
Verify the interactions between objects.
test('should call GitHub API with correct parameters', async () => {
// Arrange
const mockClient = new MockGitHubClient()
const adapter = new GitHubAdapter({ owner: 'test', repo: 'test' })
adapter.client = mockClient
// Act
await adapter.push(mockSpec)
// Assert - Check behavior
expect(mockClient.createIssueCalls).toHaveLength(1)
expect(mockClient.createIssueCalls[0]).toEqual({
title: 'Test Spec',
body: expect.stringContaining('This is a test'),
labels: ['spec']
})
})
Test Structure
Naming Conventions
Use descriptive test names that clearly indicate the scenario and expected behavior. Group related tests using describe blocks to organize by method or functionality.
// ✅ Good - Group by method, clear scenario descriptions
describe('GitHubAdapter', () => {
describe('push', () => {
test('should create GitHub issue when spec is valid', async () => {})
test('should handle missing labels gracefully', async () => {})
})
describe('getLabels', () => {
test('should fallback to file type when document type missing from config', () => {})
test('should combine common and type labels correctly', () => {})
})
})
// ❌ Avoid - unclear or too generic
test('test push', async () => {})
test('labels work correctly', () => {})
Arrange-Act-Assert (AAA) Pattern
Structure tests in three clear sections:
test('should_combine_common_and_type_labels', () => {
// Arrange
const adapter = new GitHubAdapter({
owner: 'test',
repo: 'test',
labels: {
spec: ['spec', 'feature'],
common: ['project', 'epic']
}
})
// Act
const result = adapter.getLabels('spec')
// Assert
expect(result).toEqual(['project', 'epic', 'spec', 'feature'])
})
Test Organization
Group related tests using nested describe blocks:
describe('GitHubAdapter', () => {
describe('Label configuration', () => {
test('should use default labels when no config provided', () => {})
test('should use single string label from config', () => {})
test('should use array labels from config', () => {})
})
describe('Repository configuration', () => {
test('should pass owner and repo to GitHubClient', () => {})
})
})
Best Practices
Test Independence
Each test should be independent and not rely on other tests:
describe('GitHubAdapter', () => {
let adapter: GitHubAdapter
let mockClient: MockGitHubClient
beforeEach(() => {
mockClient = new MockGitHubClient()
mockClient.reset() // Clean state for each test
})
})
Error Testing
Test both success and failure scenarios:
test('should handle authentication failure', async () => {
// Arrange
const mockClient = new EnhancedMockGitHubClient()
mockClient.setMockAuthResult(false)
// Act & Assert
await expect(adapter.authenticate()).resolves.toBe(false)
})
test('should handle API errors gracefully', async () => {
// Arrange
const mockClient = new EnhancedMockGitHubClient()
mockClient.setMethodError('createIssue', new Error('API Error'))
// Act & Assert
await expect(adapter.push(mockSpec)).rejects.toThrow('API Error')
})
Private Method Testing
Don't test private methods directly. Test them through public interfaces:
// ❌ Don't do this
test('private method works', () => {
// @ts-expect-error - accessing private method
expect(adapter.privateMethod()).toBe(expected)
})
// ✅ Do this instead
test('public method that uses private method works', () => {
const result = adapter.publicMethod()
expect(result).toBe(expected)
})
Anti-Patterns to Avoid
1. Magic Strings and Numbers
// ❌ Avoid
expect(result.id).toBe(123)
expect(result.status).toBe('open')
// ✅ Better
const EXPECTED_ISSUE_ID = 123
const ISSUE_STATUS_OPEN = 'open'
expect(result.id).toBe(EXPECTED_ISSUE_ID)
expect(result.status).toBe(ISSUE_STATUS_OPEN)
2. Testing Implementation Details
// ❌ Avoid - testing internal structure
expect(adapter.client.owner).toBe('test-owner')
// ✅ Better - testing behavior
const result = await adapter.push(spec)
expect(result.id).toBeDefined()
3. Multiple Acts per Test
// ❌ Avoid
test('multiple operations', async () => {
await adapter.createIssue() // First act
await adapter.updateIssue() // Second act - confusing
})
// ✅ Better - separate tests
test('should create issue', async () => {
const result = await adapter.createIssue()
expect(result).toBeDefined()
})
test('should update issue', async () => {
const result = await adapter.updateIssue(123, updates)
expect(result).toBeUndefined()
})
4. Overly Complex Test Setup
// ❌ Avoid - complex setup obscures test intent
test('complex scenario', () => {
const spec = createComplexSpecWithMultipleFilesAndDependencies()
// ... 20 lines of setup
expect(result).toBe(expected)
})
// ✅ Better - simple, focused setup
test('should handle basic spec', () => {
const spec = createMockSpec('simple-feature')
expect(adapter.push(spec)).resolves.toBeDefined()
})
Integration Testing
Integration tests verify that multiple components work together correctly. Context Please includes comprehensive integration test suites for both the core indexing engine and the MCP server.
Test Organization
packages/
├── core/test/integration/ # Core integration tests
│ ├── indexing-workflow.integration.test.ts # Basic indexing (15 tests)
│ ├── search-workflow.integration.test.ts # Semantic search (18 tests)
│ ├── lifecycle.integration.test.ts # Collection lifecycle (17 tests)
│ ├── file-synchronizer.integration.test.ts # Merkle DAG sync (24 tests)
│ └── incremental-reindex.integration.test.ts # Incremental updates (19 tests)
└── mcp/test/integration/ # MCP server integration tests
└── tool-handlers.integration.test.ts # MCP tool handlers (30 tests)
Core Integration Tests
Core Integration Test Suite (93 tests)
Tests the complete indexing and search workflow using real components with fake vector database and embeddings, split across 5 test files.
Coverage:
- Collection management (create, drop, check existence)
- Hybrid collection creation with BM25 + dense vectors
- Document indexing with AST-based code splitting
- Semantic search with relevance scoring
- Hybrid search combining BM25 and vector similarity
- Extension-based filtering
- Error handling and edge cases
Example:
describe('Context Integration', () => {
let context: Context;
let fakeDb: FakeVectorDatabase;
let fakeEmbedding: FakeEmbedding;
beforeEach(() => {
fakeDb = new FakeVectorDatabase({ address: 'test' });
fakeEmbedding = new FakeEmbedding(128);
context = new TestContextBuilder()
.withEmbedding(fakeEmbedding)
.withVectorDatabase(fakeDb)
.build();
});
it('should index codebase and enable search', async () => {
const result = await context.indexCodebase(fixturesPath);
expect(result.status).toBe('completed');
const searchResults = await context.semanticSearch(
fixturesPath,
'user authentication',
5
);
expect(searchResults.results.length).toBeGreaterThan(0);
});
});
File Synchronizer Integration Tests (28 tests)
Tests Merkle DAG-based change detection for incremental indexing.
Coverage:
- File addition detection
- File modification detection
- File deletion detection
- Directory structure changes
- Snapshot persistence and recovery
- Cross-instance state sharing
- Ignore pattern handling
Key Pattern: Uses temporary directories with file I/O to test real file system changes.
Incremental Reindex Integration Tests (27 tests)
Tests the complete incremental reindexing workflow using reindexByChange.
Coverage:
- Adding new files and indexing their chunks
- Modifying existing files and updating chunks
- Deleting files and removing chunks
- Combined operations in single reindex
- Error handling for non-existent paths
- Corrupted snapshot recovery
- Large-scale change performance
Critical Pattern: Always call reindexByChange() after indexCodebase() to establish baseline snapshot:
beforeEach(async () => {
await fs.writeFile(path.join(testDir, 'existing.ts'), 'code');
await context.indexCodebase(testDir);
// CRITICAL: Create baseline snapshot
await context.reindexByChange(testDir);
});
it('should detect new files', async () => {
await fs.writeFile(path.join(testDir, 'new.ts'), 'new code');
const result = await context.reindexByChange(testDir);
expect(result.added).toBe(1);
});
MCP Integration Tests (30 tests)
Tests all MCP server tool handlers with realistic scenarios.
Coverage:
index_codebase: Background indexing with all parameterssearch_code: Semantic search with various queriesclear_index: Collection cleanup and error handlingget_indexing_status: Progress tracking and status reporting- Path resolution (relative to absolute)
- Extension and ignore pattern filtering
- Cloud synchronization simulation
Test Doubles:
FakeVectorDatabase: In-memory vector storage with cosine similarityFakeEmbedding: Deterministic embeddings based on content hashingFakeSnapshotManager: In-memory snapshot tracking for MCP stateTestToolHandlerBuilder: Fluent API for test setup
Example:
describe('MCP Tool Handlers Integration', () => {
let handlers: ToolHandlers;
let fakeDb: FakeVectorDatabase;
beforeEach(() => {
const setup = new TestToolHandlerBuilder().build();
handlers = setup.handlers;
fakeDb = setup.fakeDb;
});
it('should index and search codebase', async () => {
await handlers.handleIndexCodebase({
path: fixturesPath,
force: false
});
const result = await handlers.handleSearchCode({
path: fixturesPath,
query: 'user service',
limit: 5
});
expect(result.content[0].text).toContain('Found');
});
});
Running Integration Tests
# Run all integration tests (both core and MCP)
pnpm test:integration
# Run core integration tests only
cd packages/core && pnpm test:integration
# Run specific integration test file
cd packages/core && pnpm test test/integration/indexing-workflow.integration.test.ts
# Run with verbose output
cd packages/core && pnpm test test/integration/incremental-reindex.integration.test.ts --reporter=verbose
Integration Test Best Practices
-
Use Real File System: Create temporary directories for file-based tests
testDir = path.join(os.tmpdir(), `context-test-${Date.now()}`); await fs.mkdir(testDir, { recursive: true }); -
Clean Up Resources: Always clean up in
afterEachafterEach(async () => { await fs.rm(testDir, { recursive: true, force: true }); await FileSynchronizer.deleteSnapshot(testDir); fakeDb.reset(); }); -
Test Realistic Scenarios: Use actual code files, not minimal stubs
await fs.writeFile( path.join(testDir, 'service.ts'), 'export class UserService { authenticate() {} }' ); -
Verify End-to-End: Check final state, not intermediate steps
// Good: Verify final database state const docs = fakeDb.getStoredDocuments(collectionName); expect(docs.some(d => d.content.includes('UserService'))).toBe(true); // Avoid: Testing internal implementation details -
Handle Async Operations: Await all async operations properly
const result = await context.reindexByChange(testDir); expect(result.added).toBe(1);
Test Coverage Goals
- Core integration tests: 93 tests (100% passing)
- MCP integration tests: 30 tests (100% passing)
- Total: 123 integration tests
Integration tests complement unit tests by verifying that components work together correctly in realistic scenarios.
Project-Specific Guidelines
Using Our Test Doubles
- Use
EnhancedMockGitHubClientfor comprehensive GitHub API testing - Use
MockSyncAdapterfor sync engine testing - Use
MockSpecToIssueMapperfor mapping logic testing
Test File Organization
plugins/sync/test/
├── adapters/ # Adapter-specific tests
├── core/ # Core functionality tests
├── fixtures/ # Test data and helpers
├── mocks/ # Reusable test doubles
└── schemas/ # Schema validation tests
Running Tests
Context Please (pnpm/vitest):
# Run all tests (unit + integration)
pnpm test
# Run only integration tests
pnpm test:integration
# Run core package tests
cd packages/core && pnpm test
# Run MCP package tests
cd packages/mcp && pnpm test
# Run specific test file
pnpm test packages/core/test/integration/indexing-workflow.integration.test.ts
Test Configuration
Tests should be deterministic and independent. Our test doubles provide:
- State management: Track internal state for verification
- Error injection: Test error handling scenarios
- Call tracking: Verify behavior and interactions
- Reset functionality: Clean state between tests
XState Testing Patterns
For testing XState machines and actors in the workflow engine, follow these patterns:
Basic XState Testing Structure
Follow the Arrange-Act-Assert pattern specifically for XState actors:
describe('WorkflowMachine', () => {
test('should transition to next state on valid answer', () => {
// Arrange - Create actor and start it
const machine = createWorkflowMachine(mockWorkflowDefinition)
const actor = createActor(machine)
actor.start()
// Act - Send event to actor
actor.send({ type: 'SUBMIT_ANSWER', answer: 'yes' })
// Assert - Verify state and context
const snapshot = actor.getSnapshot()
expect(snapshot.value).toBe('nextQuestion')
expect(snapshot.context.answers).toContain('yes')
})
})
Testing State Transitions
Test that machines transition correctly between states:
test('should handle back navigation and reset answers', () => {
// Arrange
const actor = createActor(workflowMachine)
actor.start()
// Navigate forward first
actor.send({ type: 'SUBMIT_ANSWER', answer: 'option1' })
actor.send({ type: 'SUBMIT_ANSWER', answer: 'option2' })
// Act - Navigate back
actor.send({ type: 'GO_BACK' })
// Assert - Check state and context reset
const snapshot = actor.getSnapshot()
expect(snapshot.value).toBe('question1')
expect(snapshot.context.visitCounts.question2).toBe(0)
})
Testing Guards and Actions
Test guards (conditions) and actions separately:
describe('FEEL Guards', () => {
test('should evaluate FEEL expression correctly', () => {
// Arrange
const context = { answers: { age: 25, hasInsurance: true } }
const guard = createFeelGuard('age >= 18 and hasInsurance')
// Act
const result = guard({ context })
// Assert
expect(result).toBe(true)
})
})
describe('Context Actions', () => {
test('should record answer in context', () => {
// Arrange
const initialContext = { answers: {}, visitCounts: {} }
const action = recordAnswerAction
// Act
const newContext = action({
context: initialContext,
event: { type: 'SUBMIT_ANSWER', questionId: 'q1', answer: 'yes' }
})
// Assert
expect(newContext.answers.q1).toBe('yes')
})
})
Mocking External Services
Mock external dependencies like Firebase or API calls:
describe('Document Service', () => {
test('should calculate required documents', async () => {
// Arrange - Mock Firebase service
const mockStorageService = {
loadWorkflow: vi.fn().mockResolvedValue(mockWorkflowData),
saveExecution: vi.fn().mockResolvedValue({ id: 'exec-123' })
}
const service = new DocumentService(mockStorageService)
// Act
const documents = await service.calculateRequiredDocuments(context)
// Assert
expect(documents).toHaveLength(3)
expect(mockStorageService.loadWorkflow).toHaveBeenCalledOnce()
})
})
Testing Asynchronous Actors
For testing actors with async operations:
test('should handle async document loading', async () => {
// Arrange
const mockFetch = vi.fn().mockResolvedValue({
data: mockDocumentRequirements
})
const machine = createWorkflowMachine({
services: { loadDocuments: mockFetch }
})
const actor = createActor(machine)
actor.start()
// Act
actor.send({ type: 'LOAD_DOCUMENTS' })
// Wait for async operation
await new Promise(resolve => setTimeout(resolve, 0))
// Assert
const snapshot = actor.getSnapshot()
expect(snapshot.value).toBe('documentsLoaded')
expect(snapshot.context.requiredDocuments).toBeDefined()
})
Performance Testing for XState
Test performance requirements for state machines:
describe('Performance Requirements', () => {
test('should transition states in under 200ms', async () => {
// Arrange
const actor = createActor(complexWorkflowMachine)
actor.start()
// Act & Assert
const startTime = performance.now()
for (let i = 0; i < 100; i++) {
actor.send({ type: 'SUBMIT_ANSWER', answer: `answer-${i}` })
}
const endTime = performance.now()
const avgTransitionTime = (endTime - startTime) / 100
expect(avgTransitionTime).toBeLessThan(200)
})
})
XState Testing Best Practices
- Use Real Machines: Test with actual machine definitions, not mocks
- Test State and Context: Verify both the current state and context changes
- Mock External Effects: Use
vi.fn()for services, but keep machine logic real - Test Edge Cases: Include loop prevention, invalid transitions, etc.
- Snapshot Testing: Use snapshots for complex state structures
test('should maintain correct state structure', () => {
const actor = createActor(workflowMachine)
actor.start()
actor.send({ type: 'SUBMIT_ANSWER', answer: 'test' })
expect(actor.getSnapshot()).toMatchSnapshot()
})