Skip to main content
Glama
TestManager.js32.9 kB
/** * TestManager - Test Case and Test Execution Management * * Manages test cases, test executions, and test defects with full CRUD operations. * Supports test-task linking, test reporting, and analytics. * * Key Features: * - Test case lifecycle management * - Test execution tracking * - Defect management * - Test coverage analysis * - Integration with tasks, PRDs, and designs */ import { v4 as uuidv4 } from 'uuid'; import sqlite3 from 'sqlite3'; import { open } from 'sqlite'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); class TestManager { constructor() { const dbPath = path.resolve(__dirname, '../../data/workflow.db'); this.dbPath = dbPath; this.db = null; } async getDatabase() { if (!this.db) { this.db = await open({ filename: this.dbPath, driver: sqlite3.Database }); await this.db.exec('PRAGMA foreign_keys = ON'); } return this.db; } // Test case priorities and statuses TestCasePriority = { HIGH: 'High', MEDIUM: 'Medium', LOW: 'Low' }; TestCaseStatus = { DRAFT: 'draft', READY: 'ready', ACTIVE: 'active', DEPRECATED: 'deprecated' }; TestCaseType = { UNIT: 'unit', INTEGRATION: 'integration', SYSTEM: 'system', ACCEPTANCE: 'acceptance', REGRESSION: 'regression' }; TestExecutionStatus = { PASS: 'pass', FAIL: 'fail', BLOCKED: 'blocked', SKIPPED: 'skipped', PENDING: 'pending' }; DefectSeverity = { CRITICAL: 'Critical', HIGH: 'High', MEDIUM: 'Medium', LOW: 'Low' }; // ============================================= // Test Case Management // ============================================= /** * Create a new test case */ async createTestCase(testCaseData) { try { const db = await this.getDatabase(); const id = testCaseData.id || `test-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const now = new Date().toISOString(); // Normalize and validate data const normalizedData = { id, title: testCaseData.title || '', description: testCaseData.description || '', type: this.normalizeTestType(testCaseData.type), status: this.normalizeTestStatus(testCaseData.status), priority: this.normalizePriority(testCaseData.priority), task_id: testCaseData.task_id || null, design_id: testCaseData.design_id || null, prd_id: testCaseData.prd_id || null, preconditions: testCaseData.preconditions || '', test_steps: JSON.stringify(testCaseData.test_steps || []), expected_result: testCaseData.expected_result || '', test_data: JSON.stringify(testCaseData.test_data || {}), estimated_duration: testCaseData.estimated_duration || 0, complexity: testCaseData.complexity || 'Medium', automation_status: testCaseData.automation_status || 'manual', tags: JSON.stringify(testCaseData.tags || []), created_at: now, updated_at: now, created_by: testCaseData.created_by || 'system', version: 1 }; // Validate required fields if (!normalizedData.title.trim()) { throw new Error('테스트 케이스 제목은 필수입니다.'); } const result = await db.run(` INSERT INTO test_cases ( id, title, description, type, status, priority, task_id, design_id, prd_id, preconditions, test_steps, expected_result, test_data, estimated_duration, complexity, automation_status, tags, created_at, updated_at, created_by, version ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ normalizedData.id, normalizedData.title, normalizedData.description, normalizedData.type, normalizedData.status, normalizedData.priority, normalizedData.task_id, normalizedData.design_id, normalizedData.prd_id, normalizedData.preconditions, normalizedData.test_steps, normalizedData.expected_result, normalizedData.test_data, normalizedData.estimated_duration, normalizedData.complexity, normalizedData.automation_status, normalizedData.tags, normalizedData.created_at, normalizedData.updated_at, normalizedData.created_by, normalizedData.version ]); if (result.changes === 1) { const getResult = await this.getTestCase(id); if (getResult.success) { return { success: true, testCase: getResult.testCase, message: `테스트 케이스 "${normalizedData.title}" 생성 완료` }; } else { return getResult; } } else { throw new Error('테스트 케이스 생성에 실패했습니다.'); } } catch (error) { return { success: false, error: error.message, message: '테스트 케이스 생성 중 오류가 발생했습니다.' }; } } /** * Get test case by ID */ async getTestCase(testCaseId) { try { const db = await this.getDatabase(); const testCase = await db.get(` SELECT tc.*, COUNT(te.id) AS total_executions, COUNT(CASE WHEN te.status = 'pass' THEN 1 END) AS passed_executions, COUNT(CASE WHEN te.status = 'fail' THEN 1 END) AS failed_executions, MAX(te.execution_date) AS last_execution_date, (SELECT status FROM test_executions WHERE test_case_id = tc.id ORDER BY execution_date DESC LIMIT 1) AS last_execution_status FROM test_cases tc LEFT JOIN test_executions te ON tc.id = te.test_case_id WHERE tc.id = ? GROUP BY tc.id `, [testCaseId]); if (!testCase) { return { success: false, error: '테스트 케이스를 찾을 수 없습니다.', message: `ID "${testCaseId}"에 해당하는 테스트 케이스가 없습니다.` }; } // Parse JSON fields testCase.test_steps = this.parseJSON(testCase.test_steps, []); testCase.test_data = this.parseJSON(testCase.test_data, {}); testCase.tags = this.parseJSON(testCase.tags, []); // Add execution analytics testCase.analytics = { total_executions: testCase.total_executions || 0, passed_executions: testCase.passed_executions || 0, failed_executions: testCase.failed_executions || 0, pass_rate: testCase.total_executions > 0 ? Math.round((testCase.passed_executions / testCase.total_executions) * 100) : 0, last_execution_date: testCase.last_execution_date, last_execution_status: testCase.last_execution_status }; return { success: true, testCase, message: `테스트 케이스 "${testCase.title}" 조회 완료` }; } catch (error) { return { success: false, error: error.message, message: '테스트 케이스 조회 중 오류가 발생했습니다.' }; } } /** * List test cases with optional filtering */ async listTestCases(options = {}) { try { const db = await this.getDatabase(); let query = ` SELECT tc.*, COUNT(te.id) AS total_executions, COUNT(CASE WHEN te.status = 'pass' THEN 1 END) AS passed_executions, MAX(te.execution_date) AS last_execution_date, (SELECT status FROM test_executions WHERE test_case_id = tc.id ORDER BY execution_date DESC LIMIT 1) AS last_execution_status FROM test_cases tc LEFT JOIN test_executions te ON tc.id = te.test_case_id `; const conditions = []; const params = []; // Apply filters if (options.status) { conditions.push('tc.status = ?'); params.push(options.status); } if (options.type) { conditions.push('tc.type = ?'); params.push(options.type); } if (options.priority) { conditions.push('tc.priority = ?'); params.push(options.priority); } if (options.task_id) { conditions.push('tc.task_id = ?'); params.push(options.task_id); } if (options.design_id) { conditions.push('tc.design_id = ?'); params.push(options.design_id); } if (options.prd_id) { conditions.push('tc.prd_id = ?'); params.push(options.prd_id); } if (conditions.length > 0) { query += ' WHERE ' + conditions.join(' AND '); } query += ' GROUP BY tc.id ORDER BY tc.updated_at DESC'; const testCases = await db.all(query, params); // Process each test case testCases.forEach(testCase => { testCase.test_steps = this.parseJSON(testCase.test_steps, []); testCase.test_data = this.parseJSON(testCase.test_data, {}); testCase.tags = this.parseJSON(testCase.tags, []); testCase.analytics = { total_executions: testCase.total_executions || 0, passed_executions: testCase.passed_executions || 0, pass_rate: testCase.total_executions > 0 ? Math.round((testCase.passed_executions / testCase.total_executions) * 100) : 0, last_execution_status: testCase.last_execution_status }; }); // Generate summary statistics const statusBreakdown = testCases.reduce((acc, testCase) => { acc[testCase.status] = (acc[testCase.status] || 0) + 1; return acc; }, {}); const typeBreakdown = testCases.reduce((acc, testCase) => { acc[testCase.type] = (acc[testCase.type] || 0) + 1; return acc; }, {}); return { success: true, testCases, total: testCases.length, statusBreakdown, typeBreakdown, message: `테스트 케이스 ${testCases.length}개 조회 완료` }; } catch (error) { return { success: false, error: error.message, message: '테스트 케이스 목록 조회 중 오류가 발생했습니다.' }; } } /** * Update test case */ async updateTestCase(testCaseId, updates) { try { // Check if test case exists const existingResult = await this.getTestCase(testCaseId); if (!existingResult.success) { return existingResult; } const now = new Date().toISOString(); const updateFields = []; const params = []; // Build dynamic update query if (updates.title !== undefined) { updateFields.push('title = ?'); params.push(updates.title); } if (updates.description !== undefined) { updateFields.push('description = ?'); params.push(updates.description); } if (updates.type !== undefined) { updateFields.push('type = ?'); params.push(this.normalizeTestType(updates.type)); } if (updates.status !== undefined) { updateFields.push('status = ?'); params.push(this.normalizeTestStatus(updates.status)); } if (updates.priority !== undefined) { updateFields.push('priority = ?'); params.push(this.normalizePriority(updates.priority)); } if (updates.task_id !== undefined) { updateFields.push('task_id = ?'); params.push(updates.task_id); } if (updates.design_id !== undefined) { updateFields.push('design_id = ?'); params.push(updates.design_id); } if (updates.prd_id !== undefined) { updateFields.push('prd_id = ?'); params.push(updates.prd_id); } if (updates.preconditions !== undefined) { updateFields.push('preconditions = ?'); params.push(updates.preconditions); } if (updates.test_steps !== undefined) { updateFields.push('test_steps = ?'); params.push(JSON.stringify(updates.test_steps)); } if (updates.expected_result !== undefined) { updateFields.push('expected_result = ?'); params.push(updates.expected_result); } if (updates.test_data !== undefined) { updateFields.push('test_data = ?'); params.push(JSON.stringify(updates.test_data)); } if (updates.estimated_duration !== undefined) { updateFields.push('estimated_duration = ?'); params.push(updates.estimated_duration); } if (updates.complexity !== undefined) { updateFields.push('complexity = ?'); params.push(updates.complexity); } if (updates.automation_status !== undefined) { updateFields.push('automation_status = ?'); params.push(updates.automation_status); } if (updates.tags !== undefined) { updateFields.push('tags = ?'); params.push(JSON.stringify(updates.tags)); } // Always update timestamp and version updateFields.push('updated_at = ?'); params.push(now); updateFields.push('version = version + 1'); params.push(testCaseId); const db = await this.getDatabase(); const query = `UPDATE test_cases SET ${updateFields.join(', ')} WHERE id = ?`; const result = await db.run(query, params); if (result.changes === 1) { const getResult = await this.getTestCase(testCaseId); if (getResult.success) { return { success: true, testCase: getResult.testCase, message: `테스트 케이스 업데이트 완료` }; } else { return getResult; } } else { throw new Error('테스트 케이스 업데이트에 실패했습니다.'); } } catch (error) { return { success: false, error: error.message, message: '테스트 케이스 업데이트 중 오류가 발생했습니다.' }; } } /** * Delete test case */ async deleteTestCase(testCaseId) { try { // Check if test case exists const existingResult = await this.getTestCase(testCaseId); if (!existingResult.success) { return existingResult; } const testCaseTitle = existingResult.testCase.title; // Check if there are executions - warn user const db = await this.getDatabase(); const executionCount = await db.get( 'SELECT COUNT(*) as count FROM test_executions WHERE test_case_id = ?', [testCaseId] ); if (executionCount.count > 0) { console.warn(`Warning: Deleting test case with ${executionCount.count} executions`); } const result = await db.run('DELETE FROM test_cases WHERE id = ?', [testCaseId]); if (result.changes === 1) { return { success: true, deletedTestCase: testCaseTitle, message: `테스트 케이스 "${testCaseTitle}"이 성공적으로 삭제되었습니다` }; } else { throw new Error('테스트 케이스 삭제에 실패했습니다.'); } } catch (error) { return { success: false, error: error.message, message: '테스트 케이스 삭제 중 오류가 발생했습니다.' }; } } // ============================================= // Test Execution Management // ============================================= /** * Execute a test case and record results */ async executeTestCase(testCaseId, executionData) { try { // Verify test case exists const testCaseResult = await this.getTestCase(testCaseId); if (!testCaseResult.success) { return testCaseResult; } const id = executionData.id || `exec-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const now = new Date().toISOString(); const normalizedData = { id, test_case_id: testCaseId, execution_date: executionData.execution_date || now, executed_by: executionData.executed_by || 'system', environment: executionData.environment || 'development', status: this.normalizeExecutionStatus(executionData.status), actual_result: executionData.actual_result || '', notes: executionData.notes || '', defects_found: JSON.stringify(executionData.defects_found || []), actual_duration: executionData.actual_duration || 0, started_at: executionData.started_at || null, completed_at: executionData.completed_at || null, attachments: JSON.stringify(executionData.attachments || []), created_at: now, updated_at: now }; const db = await this.getDatabase(); const result = await db.run(` INSERT INTO test_executions ( id, test_case_id, execution_date, executed_by, environment, status, actual_result, notes, defects_found, actual_duration, started_at, completed_at, attachments, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ normalizedData.id, normalizedData.test_case_id, normalizedData.execution_date, normalizedData.executed_by, normalizedData.environment, normalizedData.status, normalizedData.actual_result, normalizedData.notes, normalizedData.defects_found, normalizedData.actual_duration, normalizedData.started_at, normalizedData.completed_at, normalizedData.attachments, normalizedData.created_at, normalizedData.updated_at ]); if (result.changes === 1) { const getResult = await this.getTestExecution(id); if (getResult.success) { return { success: true, execution: getResult.execution, message: `테스트 실행 완료 - 결과: ${normalizedData.status}` }; } else { return getResult; } } else { throw new Error('테스트 실행 기록에 실패했습니다.'); } } catch (error) { return { success: false, error: error.message, message: '테스트 실행 중 오류가 발생했습니다.' }; } } /** * Get test execution by ID */ async getTestExecution(executionId) { try { const db = await this.getDatabase(); const execution = await db.get(` SELECT te.*, tc.title AS test_case_title, tc.type AS test_case_type FROM test_executions te LEFT JOIN test_cases tc ON te.test_case_id = tc.id WHERE te.id = ? `, [executionId]); if (!execution) { return { success: false, error: '테스트 실행 기록을 찾을 수 없습니다.', message: `ID "${executionId}"에 해당하는 실행 기록이 없습니다.` }; } // Parse JSON fields execution.defects_found = this.parseJSON(execution.defects_found, []); execution.attachments = this.parseJSON(execution.attachments, []); return { success: true, execution, message: `테스트 실행 기록 조회 완료` }; } catch (error) { return { success: false, error: error.message, message: '테스트 실행 기록 조회 중 오류가 발생했습니다.' }; } } /** * Get test execution history for a test case */ async getTestExecutions(testCaseId, options = {}) { try { const db = await this.getDatabase(); let query = ` SELECT te.*, tc.title AS test_case_title FROM test_executions te LEFT JOIN test_cases tc ON te.test_case_id = tc.id WHERE te.test_case_id = ? `; const params = [testCaseId]; if (options.environment) { query += ' AND te.environment = ?'; params.push(options.environment); } query += ' ORDER BY te.execution_date DESC'; if (options.limit) { query += ' LIMIT ?'; params.push(options.limit); } const executions = await db.all(query, params); executions.forEach(execution => { execution.defects_found = this.parseJSON(execution.defects_found, []); execution.attachments = this.parseJSON(execution.attachments, []); }); return { success: true, executions, total: executions.length, message: `테스트 실행 기록 ${executions.length}개 조회 완료` }; } catch (error) { return { success: false, error: error.message, message: '테스트 실행 기록 조회 중 오류가 발생했습니다.' }; } } // ============================================= // Test Reporting and Analytics // ============================================= /** * Get test summary report */ async getTestSummary(options = {}) { try { const db = await this.getDatabase(); // Overall statistics const overall = await db.get(` SELECT COUNT(DISTINCT tc.id) AS total_test_cases, COUNT(DISTINCT CASE WHEN tc.status = 'active' THEN tc.id END) AS active_test_cases, COUNT(DISTINCT te.id) AS total_executions, COUNT(DISTINCT CASE WHEN te.status = 'pass' THEN te.id END) AS passed_executions, COUNT(DISTINCT CASE WHEN te.status = 'fail' THEN te.id END) AS failed_executions, COUNT(DISTINCT td.id) AS total_defects, COUNT(DISTINCT CASE WHEN td.status = 'open' THEN td.id END) AS open_defects FROM test_cases tc LEFT JOIN test_executions te ON tc.id = te.test_case_id LEFT JOIN test_defects td ON tc.id = td.test_case_id `); // Test case breakdown by status const statusBreakdown = await db.all(` SELECT status, COUNT(*) as count FROM test_cases GROUP BY status `); // Test case breakdown by type const typeBreakdown = await db.all(` SELECT type, COUNT(*) as count FROM test_cases GROUP BY type `); // Recent executions const recentExecutions = await db.all(` SELECT te.*, tc.title AS test_case_title FROM test_executions te LEFT JOIN test_cases tc ON te.test_case_id = tc.id ORDER BY te.execution_date DESC LIMIT 10 `); // Calculate pass rate const passRate = overall.total_executions > 0 ? Math.round((overall.passed_executions / overall.total_executions) * 100) : 0; return { success: true, summary: { overview: { ...overall, pass_rate: passRate, generated_at: new Date().toISOString() }, breakdowns: { status: statusBreakdown, type: typeBreakdown }, recent_executions: recentExecutions }, message: '테스트 요약 보고서 생성 완료' }; } catch (error) { return { success: false, error: error.message, message: '테스트 요약 보고서 생성 중 오류가 발생했습니다.' }; } } /** * Get test coverage by task */ async getTestCoverage(options = {}) { try { const db = await this.getDatabase(); const coverage = await db.all(` SELECT t.id AS task_id, t.title AS task_title, t.status AS task_status, COUNT(tc.id) AS test_cases_count, COUNT(CASE WHEN tc.status = 'active' THEN 1 END) AS active_test_cases, COUNT(DISTINCT te.id) AS total_executions, COUNT(CASE WHEN te.status = 'pass' THEN 1 END) AS passed_executions, CASE WHEN COUNT(tc.id) > 0 THEN ROUND((COUNT(CASE WHEN tc.status = 'active' THEN 1 END) * 100.0) / COUNT(tc.id), 2) ELSE 0 END AS test_readiness_percentage FROM tasks t LEFT JOIN test_cases tc ON t.id = tc.task_id LEFT JOIN test_executions te ON tc.id = te.test_case_id GROUP BY t.id HAVING COUNT(tc.id) > 0 ORDER BY test_readiness_percentage DESC `); return { success: true, coverage, total: coverage.length, message: `테스트 커버리지 분석 완료 - ${coverage.length}개 작업 분석` }; } catch (error) { return { success: false, error: error.message, message: '테스트 커버리지 분석 중 오류가 발생했습니다.' }; } } // ============================================= // Test-Task Linking // ============================================= /** * Link test case to task */ async linkTestToTask(testCaseId, taskId) { try { const updateResult = await this.updateTestCase(testCaseId, { task_id: taskId }); if (updateResult.success) { return { success: true, message: `테스트 케이스가 작업에 연결되었습니다` }; } else { return updateResult; } } catch (error) { return { success: false, error: error.message, message: '테스트-작업 연결 중 오류가 발생했습니다.' }; } } /** * Get tests by task */ async getTestsByTask(taskId) { try { return await this.listTestCases({ task_id: taskId }); } catch (error) { return { success: false, error: error.message, message: '작업별 테스트 조회 중 오류가 발생했습니다.' }; } } // ============================================= // Utility Methods // ============================================= normalizePriority(priority) { if (!priority) return this.TestCasePriority.MEDIUM; const normalized = priority.toLowerCase(); switch (normalized) { case 'high': case 'urgent': case 'critical': return this.TestCasePriority.HIGH; case 'low': case 'minor': return this.TestCasePriority.LOW; case 'medium': case 'normal': default: return this.TestCasePriority.MEDIUM; } } normalizeTestStatus(status) { if (!status) return this.TestCaseStatus.DRAFT; const normalized = status.toLowerCase(); switch (normalized) { case 'ready': return this.TestCaseStatus.READY; case 'active': return this.TestCaseStatus.ACTIVE; case 'deprecated': case 'inactive': return this.TestCaseStatus.DEPRECATED; case 'draft': default: return this.TestCaseStatus.DRAFT; } } normalizeTestType(type) { if (!type) return this.TestCaseType.UNIT; const normalized = type.toLowerCase(); switch (normalized) { case 'integration': return this.TestCaseType.INTEGRATION; case 'system': case 'e2e': return this.TestCaseType.SYSTEM; case 'acceptance': case 'uat': return this.TestCaseType.ACCEPTANCE; case 'regression': return this.TestCaseType.REGRESSION; case 'unit': default: return this.TestCaseType.UNIT; } } normalizeExecutionStatus(status) { if (!status) return this.TestExecutionStatus.PENDING; const normalized = status.toLowerCase(); switch (normalized) { case 'pass': case 'passed': case 'success': return this.TestExecutionStatus.PASS; case 'fail': case 'failed': case 'failure': return this.TestExecutionStatus.FAIL; case 'blocked': return this.TestExecutionStatus.BLOCKED; case 'skipped': case 'skip': return this.TestExecutionStatus.SKIPPED; case 'pending': default: return this.TestExecutionStatus.PENDING; } } parseJSON(jsonString, fallback = null) { try { return JSON.parse(jsonString || 'null') || fallback; } catch { return fallback; } } } export default TestManager;

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/foswmine/workflow-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server