This document provides comprehensive testing guidelines for the wowpress-cs project, combining industry best practices with project-specific patterns.
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
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
Based on Martin Fowler's Test Double taxonomy, we use these patterns:
Objects passed around but never actually used. Typically fill parameter lists.
// Example: Dummy callback that's never called
function dummyCallback() {}
await someFunction(data, dummyCallback)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' }
}
}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
}
}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
}
}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++
}
}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')
})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'],
})
})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', () => {})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'])
})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', () => {})
})
})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
})
})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')
})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)
})// ❌ 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)// ❌ Avoid - testing internal structure
expect(adapter.client.owner).toBe('test-owner')
// ✅ Better - testing behavior
const result = await adapter.push(spec)
expect(result.id).toBeDefined()// ❌ 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()
})// ❌ 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()
})- Use
EnhancedMockGitHubClientfor comprehensive GitHub API testing - Use
MockSyncAdapterfor sync engine testing - Use
MockSpecToIssueMapperfor mapping logic testing
plugins/sync/test/
├── adapters/ # Adapter-specific tests
├── core/ # Core functionality tests
├── fixtures/ # Test data and helpers
├── mocks/ # Reusable test doubles
└── schemas/ # Schema validation tests
# Run all tests
bun test
# Run specific test file
bun test plugins/sync/test/adapters/github.adapter.test.ts
# Run with coverage
bun test --coverageTests 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