21.5 外掛測試與除錯
python
## 21.5.1 单元测试
### 测试框架配置
// jest.config.ts export default { preset: 'ts-jest', testEnvironment: 'node', roots: ['<rootDir>/src'], testMatch: ['**/**tests** /**/_.ts', '**/?(_.)+(spec|test).ts'], collectCoverageFrom: [ 'src/**/*.ts', '!src/** /_.d.ts', '!src/**/_.test.ts', '!src/**/_.spec.ts' ], coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80 } }, moduleNameMapper: { '^@/(._)$': '<rootDir>/src/$1' } };
### 基本单元测试
bash
typescript
// __tests__/plugin.test.ts
import { MyPlugin } from '../src/plugin';
describe('MyPlugin', () => {
let plugin: MyPlugin;
beforeEach(() => {
plugin = new MyPlugin();
});
afterEach(async () => {
try {
await plugin.cleanup();
} catch (error) {
// 忽略清理错误
}
});
describe('initialization', () => {
test('should initialize with correct configuration', async () => {
await plugin.initialize({
name: 'test-plugin',
version: '1.0.0',
description: 'Test plugin'
});
const info = plugin.getInfo();
expect(info.name).toBe('test-plugin');
expect(info.version).toBe('1.0.0');
});
test('should throw error if already initialized', async () => {
await plugin.initialize({});
await expect(plugin.initialize({})).rejects.toThrow();
});
});
describe('lifecycle', () => {
test('should start after initialization', async () => {
await plugin.initialize({});
await plugin.start();
const status = plugin.getStatus();
expect(status.enabled).toBe(true);
});
test('should stop after starting', async () => {
await plugin.initialize({});
await plugin.start();
await plugin.stop();
const status = plugin.getStatus();
expect(status.enabled).toBe(false);
});
});
});
### 工具测试
// __tests__/tools/greeting.test.ts
import { GreetingTool } from '../../src/tools/greeting';
describe('GreetingTool', () => {
let tool: GreetingTool;
beforeEach(() => {
tool = new GreetingTool();
});
describe('execute', () => {
test('should generate English greeting', async () => {
const result = await tool.execute(
{ name: 'World', language: 'english' },
{}
);
expect(result.success).toBe(true);
expect(result.data.greeting).toBe('Hello, World!');
expect(result.data.language).toBe('english');
});
test('should generate Chinese greeting', async () => {
const result = await tool.execute(
{ name: 'World', language: 'chinese' },
{}
);
expect(result.success).toBe(true);
expect(result.data.greeting).toBe('你好,World!');
});
test('should handle invalid language gracefully', async () => {
const result = await tool.execute(
{ name: 'World', language: 'invalid' },
{}
);
expect(result.success).toBe(true);
expect(result.data.greeting).toBe('Hello, World!'); // 默认英语
});
});
describe('validate', () => {
test('should validate required parameters', () => {
const result = tool.validate({});
expect(result.valid).toBe(false);
expect(result.errors).toContain('Missing required parameter: name');
});
test('should validate parameter types', () => {
const result = tool.validate({ name: 123 });
expect(result.valid).toBe(false);
expect(result.errors).toContain('Parameter name must be a string');
});
test('should pass valid parameters', () => {
const result = tool.validate({ name: 'World' });
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
});
});
### 命令测试
bash
typescript
// __tests__/commands/greet.test.ts
import { GreetCommand } from '../../src/commands/greet';
describe('GreetCommand', () => {
let command: GreetCommand;
beforeEach(() => {
command = new GreetCommand();
});
describe('execute', () => {
test('should greet informally by default', async () => {
const result = await command.execute(
['--name', 'World'],
{}
);
expect(result.success).toBe(true);
expect(result.output).toContain('Hey, World!');
});
test('should greet formally with flag', async () => {
const result = await command.execute(
['--name', 'World', '--formal'],
{}
);
expect(result.success).toBe(true);
expect(result.output).toContain('Good day, World.');
});
test('should handle missing name parameter', async () => {
const result = await command.execute([], {});
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
describe('parseArgs', () => {
test('should parse long arguments', () => {
const parsed = command.parseArgs(['--name', 'World', '--formal']);
expect(parsed.name).toBe('World');
expect(parsed.formal).toBe(true);
});
test('should parse short arguments', () => {
const parsed = command.parseArgs(['-n', 'World', '-f']);
expect(parsed.name).toBe('World');
expect(parsed.formal).toBe(true);
});
test('should use default values', () => {
const parsed = command.parseArgs(['--name', 'World']);
expect(parsed.name).toBe('World');
expect(parsed.formal).toBe(false);
});
});
describe('help', () => {
test('should generate help text', () => {
const help = command.help();
expect(help).toContain('Command: greet');
expect(help).toContain('Description:');
expect(help).toContain('Usage:');
expect(help).toContain('Options:');
});
});
});
### 钩子测试
// __tests__/hooks/logging.test.ts
import { LoggingHook } from '../../src/hooks/logging';
describe('LoggingHook', () => {
let hook: LoggingHook;
let consoleLogSpy: jest.SpyInstance;
beforeEach(() => {
hook = new LoggingHook();
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
});
afterEach(() => {
consoleLogSpy.mockRestore();
});
describe('execute', () => {
test('should log command execution', async () => {
const event = {
type: 'before_command',
data: {
command: 'greet',
args: ['--name', 'World']
},
timestamp: new Date()
};
const result = await hook.execute(event, {});
expect(result.success).toBe(true);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('Executing command: greet')
);
});
test('should not prevent default behavior', async () => {
const event = {
type: 'before_command',
data: {},
timestamp: new Date()
};
const result = await hook.execute(event, {});
expect(result.success).toBe(true);
expect(result.preventDefault).toBeUndefined();
});
});
});
## 21.5.2 集成测试
### 插件集成测试
bash
typescript
// __tests__/integration/plugin.integration.test.ts
import { MyPlugin } from '../../src/plugin';
describe('MyPlugin Integration', () => {
let plugin: MyPlugin;
beforeEach(async () => {
plugin = new MyPlugin();
await plugin.initialize({});
});
afterEach(async () => {
try {
await plugin.stop();
await plugin.cleanup();
} catch (error) {
// 忽略清理错误
}
});
describe('full lifecycle', () => {
test('should complete full lifecycle', async () => {
// 启动插件
await plugin.start();
// 验证插件运行中
let status = plugin.getStatus();
expect(status.enabled).toBe(true);
// 停止插件
await plugin.stop();
// 验证插件已停止
status = plugin.getStatus();
expect(status.enabled).toBe(false);
// 清理插件
await plugin.cleanup();
});
});
describe('tool integration', () => {
test('should execute tool through plugin', async () => {
await plugin.start();
const result = await plugin.toolManager.execute(
'greeting',
{ name: 'World' },
{}
);
expect(result.success).toBe(true);
expect(result.data.greeting).toBeDefined();
});
});
describe('command integration', () => {
test('should execute command through plugin', async () => {
await plugin.start();
const result = await plugin.commandManager.execute(
'greet',
['--name', 'World'],
{}
);
expect(result.success).toBe(true);
expect(result.output).toBeDefined();
});
});
describe('hook integration', () => {
test('should execute hooks through plugin', async () => {
await plugin.start();
const event = {
type: 'before_command',
data: {
command: 'greet',
args: ['--name', 'World']
},
timestamp: new Date()
};
const result = await plugin.hookManager.execute(
'before_command',
event,
{}
);
expect(result.success).toBe(true);
});
});
});
### 端到端测试
// __tests__/e2e/plugin.e2e.test.ts
import { MyPlugin } from '../../src/plugin';
describe('MyPlugin E2E', () => {
let plugin: MyPlugin;
beforeEach(async () => {
plugin = new MyPlugin();
await plugin.initialize({});
await plugin.start();
});
afterEach(async () => {
try {
await plugin.stop();
await plugin.cleanup();
} catch (error) {
// 忽略清理错误
}
});
test('should handle complete workflow', async () => {
// 1. 执行工具
const toolResult = await plugin.toolManager.execute(
'greeting',
{ name: 'World' },
{}
);
expect(toolResult.success).toBe(true);
// 2. 执行命令
const commandResult = await plugin.commandManager.execute(
'greet',
['--name', 'World'],
{}
);
expect(commandResult.success).toBe(true);
// 3. 验证插件状态
const status = plugin.getStatus();
expect(status.enabled).toBe(true);
});
test('should handle errors gracefully', async () => {
// 执行无效工具
const result = await plugin.toolManager.execute(
'invalid-tool',
{},
{}
);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
// 验证插件仍然运行
const status = plugin.getStatus();
expect(status.enabled).toBe(true);
});
});
## 21.5.3 测试工具和辅助函数
### Mock 工具
bash
typescript
// __tests__/utils/mocks.ts
import { PluginContext } from '@claude-code/plugin-sdk';
/**
* 创建 Mock 插件上下文
*/
export function createMockContext(): PluginContext {
return {
getService: jest.fn(),
setService: jest.fn(),
getData: jest.fn(),
setData: jest.fn(),
removeData: jest.fn(),
clearData: jest.fn()
};
}
/**
* 创建 Mock 工具管理器
*/
export function createMockToolManager() {
return {
register: jest.fn(),
unregister: jest.fn(),
getTool: jest.fn(),
getAllTools: jest.fn(),
execute: jest.fn()
};
}
/**
* 创建 Mock 命令管理器
*/
export function createMockCommandManager() {
return {
register: jest.fn(),
unregister: jest.fn(),
getCommand: jest.fn(),
getAllCommands: jest.fn(),
execute: jest.fn()
};
}
/**
* 创建 Mock 钩子管理器
*/
export function createMockHookManager() {
return {
register: jest.fn(),
unregister: jest.fn(),
getHooks: jest.fn(),
getAllHooks: jest.fn(),
execute: jest.fn()
};
}
### 测试辅助函数
// __tests__/utils/helpers.ts
import { MyPlugin } from '../../src/plugin';
/**
* 创建测试插件实例
*/
export async function createTestPlugin(): Promise<MyPlugin> {
const plugin = new MyPlugin();
await plugin.initialize({});
return plugin;
}
/**
* 创建并启动测试插件
*/
export async function createAndStartTestPlugin(): Promise<MyPlugin> {
const plugin = await createTestPlugin();
await plugin.start();
return plugin;
}
/**
* 清理测试插件
*/
export async function cleanupTestPlugin(plugin: MyPlugin): Promise<void> {
try {
await plugin.stop();
await plugin.cleanup();
} catch (error) {
// 忽略清理错误
}
}
/**
* 等待异步操作完成
*/
export function waitFor(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 重试函数
*/
export async function retry<T>(
fn: () => Promise<T>,
maxRetries: number = 3,
delay: number = 100
): Promise<T> {
let lastError: Error;
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (i < maxRetries - 1) {
await waitFor(delay);
}
}
}
throw lastError;
}
### 测试断言
bash
typescript
// __tests__/utils/assertions.ts
import { ToolResult, CommandResult } from '@claude-code/plugin-sdk';
/**
* 断言工具结果成功
*/
export function expectToolSuccess(result: ToolResult) {
expect(result.success).toBe(true);
expect(result.error).toBeUndefined();
}
/**
* 断言工具结果失败
*/
export function expectToolFailure(result: ToolResult) {
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
}
/**
* 断言命令结果成功
*/
export function expectCommandSuccess(result: CommandResult) {
expect(result.success).toBe(true);
expect(result.error).toBeUndefined();
expect(result.exitCode).toBe(0);
}
/**
* 断言命令结果失败
*/
export function expectCommandFailure(result: CommandResult) {
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
expect(result.exitCode).not.toBe(0);
}
/**
* 断言结果包含数据
*/
export function expectResultData(result: ToolResult | CommandResult) {
expect(result.data).toBeDefined();
expect(Object.keys(result.data).length).toBeGreaterThan(0);
}
## 21.5.4 调试技巧
### 使用 VS Code 调试
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Plugin",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
{
"type": "node",
"request": "launch",
"name": "Debug Tests",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "test:watch"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
{
"type": "node",
"request": "launch",
"name": "Debug Current Test",
"runtimeExecutable": "npm",
"runtimeArgs": ["test", "--", "${fileBasenameNoExtension}"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
]
}
### 日志调试
bash
typescript
// src/utils/logger.ts
export class DebugLogger {
private enabled: boolean;
constructor(enabled: boolean = process.env.DEBUG === 'true') {
this.enabled = enabled;
}
log(message: string, data?: any): void {
if (!this.enabled) {
return;
}
console.log(`[DEBUG] ${message}`, data || '');
}
error(message: string, error?: Error): void {
console.error(`[ERROR] ${message}`, error || '');
}
trace(message: string, data?: any): void {
if (!this.enabled) {
return;
}
console.trace(`[TRACE] ${message}`, data || '');
}
}
// 使用示例
const logger = new DebugLogger();
export class MyPlugin extends Plugin {
async initialize(config: PluginConfig): Promise<void> {
logger.log('Initializing plugin', { config });
try {
// 初始化逻辑
logger.log('Plugin initialized successfully');
} catch (error) {
logger.error('Failed to initialize plugin', error);
throw error;
}
}
}
### 性能分析
// src/utils/profiler.ts
export class Profiler {
private measurements: Map<string, number[]> = new Map();
/**
* 测量函数执行时间
*/
async measure<T>(name: string, fn: () => Promise<T>): Promise<T> {
const start = Date.now();
try {
return await fn();
} finally {
const duration = Date.now() - start;
if (!this.measurements.has(name)) {
this.measurements.set(name, []);
}
this.measurements.get(name)!.push(duration);
}
}
/**
* 获取测量结果
*/
getStats(name: string) {
const measurements = this.measurements.get(name);
if (!measurements || measurements.length === 0) {
return null;
}
const sum = measurements.reduce((a, b) => a + b, 0);
const avg = sum / measurements.length;
const min = Math.min(...measurements);
const max = Math.max(...measurements);
return {
count: measurements.length,
sum,
avg,
min,
max
};
}
/**
* 打印所有统计信息
*/
printStats(): void {
for (const [name, measurements] of this.measurements.entries()) {
const stats = this.getStats(name);
console.log(`[Profiler] ${name}:`, stats);
}
}
}
// 使用示例
const profiler = new Profiler();
export class MyPlugin extends Plugin {
async executeTool(name: string, params: any): Promise<ToolResult> {
return profiler.measure(`tool.${name}`, async () => {
return this.toolManager.execute(name, params, {});
});
}
}
### 错误追踪
bash
typescript
// src/utils/error-tracker.ts
export class ErrorTracker {
private errors: Error[] = [];
/**
* 追踪错误
*/
track(error: Error): void {
this.errors.push(error);
console.error('[ErrorTracker]', error);
}
/**
* 获取所有错误
*/
getErrors(): Error[] {
return [...this.errors];
}
/**
* 清除错误
*/
clear(): void {
this.errors = [];
}
/**
* 获取错误统计
*/
getStats() {
const errorTypes = new Map<string, number>();
for (const error of this.errors) {
const type = error.constructor.name;
errorTypes.set(type, (errorTypes.get(type) || 0) + 1);
}
return {
total: this.errors.length,
types: Object.fromEntries(errorTypes)
};
}
}
// 使用示例
const errorTracker = new ErrorTracker();
export class MyPlugin extends Plugin {
async executeTool(name: string, params: any): Promise<ToolResult> {
try {
return await this.toolManager.execute(name, params, {});
} catch (error) {
errorTracker.track(error);
throw error;
}
}
}
## 21.5.5 测试最佳实践
### 1. 测试命名
// 好的测试命名
describe('GreetingTool', () => {
describe('execute', () => {
test('should generate English greeting when language is English', async () => {
// 测试代码
});
test('should generate Chinese greeting when language is Chinese', async () => {
// 测试代码
});
});
});
// 不好的测试命名
describe('GreetingTool', () => {
test('test1', async () => {
// 测试代码
});
test('test2', async () => {
// 测试代码
});
});
### 2\. 测试隔离
bash
typescript
// 每个测试都应该独立运行
describe('MyPlugin', () => {
let plugin: MyPlugin;
beforeEach(() => {
// 每个测试前创建新实例
plugin = new MyPlugin();
});
afterEach(async () => {
// 每个测试后清理
await plugin.cleanup();
});
test('test 1', async () => {
// 不依赖其他测试
});
test('test 2', async () => {
// 不依赖其他测试
});
});
### 3. 测试覆盖率
// 确保测试覆盖所有代码路径
describe('GreetingTool', () => {
describe('execute', () => {
test('should handle English language', async () => {
// 覆盖 English 分支
});
test('should handle Chinese language', async () => {
// 覆盖 Chinese 分支
});
test('should handle Spanish language', async () => {
// 覆盖 Spanish 分支
});
test('should handle unknown language', async () => {
// 覆盖默认分支
});
});
});
### 4\. 测试速度
bash
typescript
// 使用 Mock 加速测试
describe('MyPlugin', () => {
test('should execute tool quickly', async () => {
// Mock 工具管理器
const mockToolManager = createMockToolManager();
mockToolManager.execute.mockResolvedValue({
success: true,
data: { result: 'mocked' }
});
plugin.toolManager = mockToolManager;
// 快速执行测试
const result = await plugin.executeTool('test', {});
expect(result.success).toBe(true);
});
});
### 5. 测试可维护性
// 使用辅助函数提高可维护性
describe('MyPlugin', () => {
test('should handle multiple tool executions', async () => {
const tools = ['tool1', 'tool2', 'tool3'];
for (const tool of tools) {
const result = await plugin.executeTool(tool, {});
expectToolSuccess(result);
}
});
});
## 21.5.6 运行测试
### 运行所有测试
bash
bash
# 运行所有测试
npm test
# 运行测试并生成覆盖率报告
npm run test -- --coverage
# 监听模式
npm run test:watch
### 运行特定测试
# 运行特定测试文件
npm test -- plugin.test.ts
# 运行特定测试套件
npm test -- --testNamePattern="MyPlugin"
# 运行特定测试
npm test -- --testNamePattern="should initialize successfully"
### 测试覆盖率
bash
bash
# 生成覆盖率报告
npm run test -- --coverage
# 查看覆盖率报告
open coverage/lcov-report/index.html
# 设置覆盖率阈值
npm run test -- --coverage --coverageThreshold='{"global":{"branches":80,"functions":80,"lines":80,"statements":80}}'
### CI/CD 集成
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.x, 16.x, 18.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Generate coverage
run: npm run test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v2
with:
files: ./coverage/lcov.info