Testing
ClaudeAutoPM follows strict Test-Driven Development (TDD) methodology. All code changes MUST have tests written FIRST. This guide covers the testing approach, Jest configuration, and testing patterns.
TDD Philosophy
The TDD Cycle
Every implementation follows the Red-Green-Refactor cycle:
1. RED - Write a failing test that describes desired behavior
2. GREEN - Write minimum code to make the test pass
3. REFACTOR - Improve code while keeping tests greenTDD Rules
- Write failing tests FIRST - Before any implementation
- One test at a time - Focus on small, incremental development
- Minimum code to pass - Do not over-engineer
- 100% coverage for new code - All new features must be tested
- Tests as documentation - Tests describe expected behavior
Example TDD Workflow
javascript
// Step 1: RED - Write failing test
describe('EpicService', () => {
it('should return empty array when no epics exist', async () => {
const service = new EpicService();
const result = await service.list();
expect(result).toEqual([]);
});
});
// Step 2: GREEN - Minimum implementation
class EpicService {
async list() {
return [];
}
}
// Step 3: REFACTOR - Improve as needed
class EpicService {
constructor(repository) {
this.repository = repository;
}
async list() {
return this.repository?.getAll() ?? [];
}
}Test Framework: Jest
ClaudeAutoPM uses Jest for all testing. Jest provides:
- Fast parallel test execution
- Built-in mocking and spying
- Code coverage reporting
- Watch mode for development
Jest Configuration
The project uses multiple Jest configurations:
jest.config.quick.js - Quick tests for development:
javascript
module.exports = {
testEnvironment: 'node',
testMatch: ['**/test/**/*.test.js'],
testPathIgnorePatterns: ['/node_modules/', '/test/e2e/'],
coverageDirectory: 'coverage',
forceExit: true,
testTimeout: 10000
};jest.config.clean.js - Full test suite:
javascript
module.exports = {
testEnvironment: 'node',
testMatch: ['**/test/**/*.test.js'],
coverageDirectory: 'coverage',
collectCoverageFrom: ['lib/**/*.js'],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80
}
}
};Running Tests
Basic Commands
bash
# Run quick tests (default)
npm test
# Run with watch mode
npm test -- --watch
# Run specific test file
npm test -- test/core/PluginManager.test.js
# Run tests matching pattern
npm test -- --testNamePattern="should discover plugins"
# Run full test suite
npm run test:full
# Run with coverage
npm run test:coverageTest Categories
bash
# Unit tests
npm run test:unit
# Integration tests
npm run test:integration
# End-to-end tests
npm run test:e2e
# Security tests
npm run test:security
# Regression tests
npm run test:regression
# Installation tests
npm run test:install
# CLI tests
npm run test:cli
# Azure DevOps tests
npm run test:azure
# GitHub integration tests
npm run test:github:integrationComprehensive Testing
bash
# Run all test suites
npm run test:all
# Run comprehensive tests with report
npm run test:comprehensive
# CI/CD testing
npm run test:comprehensive:ciTest Directory Structure
test/
├── cli/ # CLI command tests
│ ├── autopm-commands.test.js
│ ├── basic-commands.test.js
│ ├── config-command.test.js
│ └── epic-command.test.js
├── core/ # Core functionality tests
│ └── PluginManager.test.js
├── e2e/ # End-to-end tests
│ └── critical-paths.test.js
├── installation/ # Installation tests
│ ├── package-based-install.test.js
│ └── install-scenarios.test.js
├── integration/ # Integration tests
│ ├── github-sync-integration.test.js
│ └── azure-sync-integration.test.js
├── jest-tests/ # Jest-specific tests
│ ├── ai-providers/
│ ├── config/
│ ├── errors/
│ └── scripts/
├── security/ # Security tests
│ ├── prompt-injection.test.js
│ └── integration.test.js
├── regression/ # Regression tests
│ └── critical-paths.test.js
└── helpers/ # Test helpers
└── test-utils.jsWriting Tests
Test File Naming
- Use
.test.jssuffix - Match source file structure
- Descriptive names:
PluginManager.test.js
Test Structure
javascript
const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals');
const PluginManager = require('../../lib/plugins/PluginManager');
describe('PluginManager', () => {
let manager;
beforeEach(() => {
// Setup before each test
manager = new PluginManager({
pluginDir: '/tmp/test-plugins'
});
});
afterEach(() => {
// Cleanup after each test
manager = null;
});
describe('initialize()', () => {
it('should discover plugins in node_modules', async () => {
await manager.initialize();
expect(manager.plugins.size).toBeGreaterThan(0);
});
it('should validate plugin compatibility', async () => {
await manager.initialize();
const plugins = manager.listPlugins();
plugins.forEach(plugin => {
expect(plugin).toHaveProperty('compatible');
});
});
});
describe('loadPlugin()', () => {
it('should throw error for non-existent plugin', async () => {
await expect(
manager.loadPlugin('non-existent-plugin')
).rejects.toThrow('Plugin not found');
});
});
});Async Testing
javascript
describe('AsyncService', () => {
it('should handle async operations', async () => {
const result = await service.fetchData();
expect(result).toBeDefined();
});
it('should handle async errors', async () => {
await expect(service.failingOperation()).rejects.toThrow();
});
it('should resolve promises', () => {
return expect(service.promiseMethod()).resolves.toBe('value');
});
});Mocking
javascript
// Mock modules
jest.mock('fs');
const fs = require('fs');
describe('FileService', () => {
beforeEach(() => {
// Reset mocks
jest.resetAllMocks();
});
it('should read file contents', () => {
fs.readFileSync.mockReturnValue('file content');
const service = new FileService();
const content = service.read('test.txt');
expect(content).toBe('file content');
expect(fs.readFileSync).toHaveBeenCalledWith('test.txt', 'utf-8');
});
});
// Spy on methods
describe('Logger', () => {
it('should log messages', () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
logger.info('test message');
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('test message'));
consoleSpy.mockRestore();
});
});Testing Events
javascript
describe('EventEmitter behavior', () => {
it('should emit events on discovery', async () => {
const discoveryHandler = jest.fn();
manager.on('discover:found', discoveryHandler);
await manager.discoverPlugins();
expect(discoveryHandler).toHaveBeenCalled();
expect(discoveryHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: expect.any(String)
})
);
});
});Test Patterns
Arrange-Act-Assert
javascript
it('should calculate progress correctly', () => {
// Arrange
const epic = new Epic({
tasks: [
{ status: 'closed' },
{ status: 'closed' },
{ status: 'open' }
]
});
// Act
const progress = epic.calculateProgress();
// Assert
expect(progress).toBe(66.67);
});Given-When-Then
javascript
describe('Epic status transitions', () => {
it('given a backlog epic, when started, then status is in-progress', () => {
// Given
const epic = new Epic({ status: 'backlog' });
// When
epic.start();
// Then
expect(epic.status).toBe('in-progress');
});
});Table-Driven Tests
javascript
describe('Status transitions', () => {
const testCases = [
{ from: 'backlog', action: 'start', expected: 'in-progress' },
{ from: 'in-progress', action: 'complete', expected: 'completed' },
{ from: 'completed', action: 'reopen', expected: 'in-progress' }
];
testCases.forEach(({ from, action, expected }) => {
it(`should transition from ${from} to ${expected} on ${action}`, () => {
const epic = new Epic({ status: from });
epic[action]();
expect(epic.status).toBe(expected);
});
});
});Error Testing
javascript
describe('Error handling', () => {
it('should throw on invalid input', () => {
expect(() => {
new Epic({ status: 'invalid' });
}).toThrow('Invalid status');
});
it('should throw specific error type', () => {
expect(() => {
service.process(null);
}).toThrow(ValidationError);
});
it('should include error details', () => {
try {
service.process({});
} catch (error) {
expect(error.message).toContain('required field');
expect(error.code).toBe('VALIDATION_ERROR');
}
});
});Integration Testing
GitHub Integration Tests
javascript
describe('GitHub Sync Integration', () => {
let provider;
beforeAll(() => {
// Skip if no GitHub token
if (!process.env.GITHUB_TOKEN) {
console.log('Skipping: GITHUB_TOKEN not set');
return;
}
provider = new GitHubProvider({
token: process.env.GITHUB_TOKEN
});
});
it('should sync issues to GitHub', async () => {
const result = await provider.syncIssue({
title: 'Test Issue',
body: 'Test body'
});
expect(result).toHaveProperty('number');
expect(result).toHaveProperty('url');
});
});Database Integration
javascript
describe('Database Integration', () => {
let db;
beforeAll(async () => {
db = await setupTestDatabase();
});
afterAll(async () => {
await db.cleanup();
});
beforeEach(async () => {
await db.clear();
});
it('should persist and retrieve data', async () => {
await db.insert({ name: 'test' });
const result = await db.find({ name: 'test' });
expect(result).toHaveLength(1);
});
});Testing CLI Commands
javascript
const { exec } = require('child_process');
const util = require('util');
const execPromise = util.promisify(exec);
describe('CLI Commands', () => {
it('should display help', async () => {
const { stdout } = await execPromise('node bin/autopm.js --help');
expect(stdout).toContain('ClaudeAutoPM');
expect(stdout).toContain('Commands:');
});
it('should show version', async () => {
const { stdout } = await execPromise('node bin/autopm.js --version');
expect(stdout).toMatch(/\d+\.\d+\.\d+/);
});
it('should handle invalid commands', async () => {
try {
await execPromise('node bin/autopm.js invalid-command');
} catch (error) {
expect(error.stderr).toContain('Unknown command');
}
});
});Coverage Requirements
Coverage Thresholds
javascript
// jest.config.js
module.exports = {
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};Running Coverage
bash
# Generate coverage report
npm run test:coverage
# View HTML report
open coverage/lcov-report/index.htmlImproving Coverage
- Identify uncovered code in coverage report
- Write tests for edge cases
- Test error paths
- Test boundary conditions
Testing Best Practices
DO
- Write tests before implementation (TDD)
- Use descriptive test names
- Test one thing per test
- Use meaningful assertions
- Clean up after tests
- Test edge cases and errors
- Keep tests fast and isolated
DO NOT
- Write tests after implementation
- Test implementation details
- Share state between tests
- Use sleep/timeouts when avoidable
- Ignore flaky tests
- Skip tests without good reason
Debugging Tests
Running with Debug Output
bash
# Verbose output
npm test -- --verbose
# Show console output
npm test -- --silent=false
# Debug single test
node --inspect-brk node_modules/.bin/jest --runInBand test/specific.test.jsCommon Issues
Test Timeout:
javascript
it('should complete long operation', async () => {
jest.setTimeout(30000); // Increase timeout
await longOperation();
}, 30000);Async Issues:
javascript
// Always await or return promises
it('should handle async', async () => {
await expect(asyncOp()).resolves.toBe('value');
});
// OR
it('should handle async', () => {
return asyncOp().then(result => {
expect(result).toBe('value');
});
});Mock Not Resetting:
javascript
afterEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
});Continuous Integration
GitHub Actions Integration
yaml
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm test
- run: npm run test:coveragePre-commit Testing
bash
# Via git hooks (setup once)
npm run setup:githooks
# Or run manually before commit
npm test && npm run validate:pathsNext Steps
- Contributing - Submit your tested code
- Plugin Development - Test plugins
- Architecture - Understand testable structure