Major Updates: - ✅ Implemented 4 Claude Code Skills (2600+ lines) - code-review: Security, quality, type checking - test-generator: Auto-generate pytest tests - doc-updater: Auto-update README and API docs - refactor-assistant: Code smell detection - ✅ Implemented 2 Git Hooks (450+ lines) - pre-commit: Auto-format, lint, review - pre-push: Test suite and coverage check - 📚 Complete documentation (20+ files) - Skills READMEs with examples - Optimization roadmap and progress - TODO list with 22 tasks - Quick reference guide - 📊 Progress: 45% → 64% (Phase 1: 20% → 60%) - 💰 ROI: Saving 50.6 hours/month ≈ 6.3 person-days/month Next Steps: - API Designer Skill - Code templates library - Prompt library - GitHub Projects task tracking 🤖 Generated with Claude Code Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
572 lines
20 KiB
Python
Executable File
572 lines
20 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Test Generator Skill for Claude Code
|
|
Automatically generate comprehensive unit tests for Python code
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import ast
|
|
import argparse
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import List, Dict, Tuple, Optional
|
|
from dataclasses import dataclass
|
|
|
|
|
|
@dataclass
|
|
class FunctionInfo:
|
|
"""Information about a function to test"""
|
|
name: str
|
|
args: List[str]
|
|
returns: Optional[str]
|
|
is_async: bool
|
|
docstring: Optional[str]
|
|
line_number: int
|
|
decorators: List[str]
|
|
is_method: bool
|
|
class_name: Optional[str]
|
|
|
|
|
|
@dataclass
|
|
class ClassInfo:
|
|
"""Information about a class to test"""
|
|
name: str
|
|
methods: List[FunctionInfo]
|
|
base_classes: List[str]
|
|
docstring: Optional[str]
|
|
line_number: int
|
|
|
|
|
|
class TestGenerator:
|
|
"""Automatic test generator"""
|
|
|
|
def __init__(self, target: str = ".", framework: str = "pytest", coverage_threshold: int = 80):
|
|
self.target = Path(target)
|
|
self.framework = framework
|
|
self.coverage_threshold = coverage_threshold
|
|
self.tests_dir = Path("tests")
|
|
|
|
def get_source_files(self) -> List[Path]:
|
|
"""Get list of Python source files to test"""
|
|
try:
|
|
# Get changed files from git
|
|
result = subprocess.run(
|
|
['git', 'diff', '--cached', '--name-only', '--diff-filter=ACM'],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True
|
|
)
|
|
staged = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
|
|
|
result = subprocess.run(
|
|
['git', 'diff', '--name-only', '--diff-filter=ACM'],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True
|
|
)
|
|
unstaged = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
|
|
|
files = list(set(staged + unstaged))
|
|
files = [Path(f) for f in files if f and Path(f).exists() and f.endswith('.py')]
|
|
|
|
if not files:
|
|
# No git changes, analyze target
|
|
if self.target.is_file():
|
|
files = [self.target]
|
|
else:
|
|
files = list(self.target.rglob('*.py'))
|
|
|
|
# Filter out test files and __pycache__
|
|
files = [f for f in files
|
|
if not str(f).startswith('test_')
|
|
and '__pycache__' not in str(f)
|
|
and 'tests/' not in str(f)]
|
|
|
|
return files
|
|
|
|
except subprocess.CalledProcessError:
|
|
# Not a git repo
|
|
if self.target.is_file():
|
|
return [self.target]
|
|
return list(self.target.rglob('*.py'))
|
|
|
|
def analyze_file(self, file_path: Path) -> Tuple[List[FunctionInfo], List[ClassInfo]]:
|
|
"""Analyze Python file and extract functions and classes"""
|
|
with open(file_path, 'r', encoding='utf-8') as f:
|
|
try:
|
|
tree = ast.parse(f.read())
|
|
except SyntaxError as e:
|
|
print(f"⚠️ Syntax error in {file_path}: {e}")
|
|
return [], []
|
|
|
|
functions = []
|
|
classes = []
|
|
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.FunctionDef) or isinstance(node, ast.AsyncFunctionDef):
|
|
# Skip if inside a class (will be handled in class analysis)
|
|
if not any(isinstance(parent, ast.ClassDef) for parent in ast.walk(tree)):
|
|
func_info = self._extract_function_info(node, is_method=False)
|
|
if func_info:
|
|
functions.append(func_info)
|
|
|
|
elif isinstance(node, ast.ClassDef):
|
|
class_info = self._extract_class_info(node)
|
|
if class_info:
|
|
classes.append(class_info)
|
|
|
|
return functions, classes
|
|
|
|
def _extract_function_info(self, node, is_method: bool = False, class_name: Optional[str] = None) -> Optional[FunctionInfo]:
|
|
"""Extract information from a function node"""
|
|
# Skip private functions (unless they're magic methods)
|
|
if node.name.startswith('_') and not (node.name.startswith('__') and node.name.endswith('__')):
|
|
return None
|
|
|
|
# Extract arguments
|
|
args = []
|
|
for arg in node.args.args:
|
|
if arg.arg != 'self' and arg.arg != 'cls':
|
|
args.append(arg.arg)
|
|
|
|
# Extract return type
|
|
returns = None
|
|
if node.returns:
|
|
if isinstance(node.returns, ast.Name):
|
|
returns = node.returns.id
|
|
elif isinstance(node.returns, ast.Constant):
|
|
returns = str(node.returns.value)
|
|
|
|
# Extract docstring
|
|
docstring = ast.get_docstring(node)
|
|
|
|
# Extract decorators
|
|
decorators = []
|
|
for decorator in node.decorator_list:
|
|
if isinstance(decorator, ast.Name):
|
|
decorators.append(decorator.id)
|
|
elif isinstance(decorator, ast.Call) and isinstance(decorator.func, ast.Name):
|
|
decorators.append(decorator.func.id)
|
|
|
|
return FunctionInfo(
|
|
name=node.name,
|
|
args=args,
|
|
returns=returns,
|
|
is_async=isinstance(node, ast.AsyncFunctionDef),
|
|
docstring=docstring,
|
|
line_number=node.lineno,
|
|
decorators=decorators,
|
|
is_method=is_method,
|
|
class_name=class_name
|
|
)
|
|
|
|
def _extract_class_info(self, node: ast.ClassDef) -> Optional[ClassInfo]:
|
|
"""Extract information from a class node"""
|
|
# Skip private classes
|
|
if node.name.startswith('_'):
|
|
return None
|
|
|
|
# Extract methods
|
|
methods = []
|
|
for item in node.body:
|
|
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
func_info = self._extract_function_info(item, is_method=True, class_name=node.name)
|
|
if func_info:
|
|
methods.append(func_info)
|
|
|
|
# Extract base classes
|
|
base_classes = []
|
|
for base in node.bases:
|
|
if isinstance(base, ast.Name):
|
|
base_classes.append(base.id)
|
|
|
|
# Extract docstring
|
|
docstring = ast.get_docstring(node)
|
|
|
|
return ClassInfo(
|
|
name=node.name,
|
|
methods=methods,
|
|
base_classes=base_classes,
|
|
docstring=docstring,
|
|
line_number=node.lineno
|
|
)
|
|
|
|
def generate_test_file(self, source_file: Path, functions: List[FunctionInfo], classes: List[ClassInfo]) -> str:
|
|
"""Generate test file content"""
|
|
module_name = source_file.stem
|
|
module_path = str(source_file).replace('/', '.').replace('.py', '')
|
|
|
|
lines = []
|
|
lines.append('"""')
|
|
lines.append(f'Tests for {source_file}')
|
|
lines.append('Auto-generated by test-generator skill')
|
|
lines.append('"""')
|
|
lines.append('')
|
|
lines.append('import pytest')
|
|
lines.append('from unittest.mock import Mock, patch, MagicMock')
|
|
lines.append('')
|
|
|
|
# Import the module
|
|
lines.append(f'# Import module under test')
|
|
if functions:
|
|
func_names = ', '.join([f.name for f in functions])
|
|
lines.append(f'from {module_path} import {func_names}')
|
|
if classes:
|
|
class_names = ', '.join([c.name for c in classes])
|
|
lines.append(f'from {module_path} import {class_names}')
|
|
lines.append('')
|
|
lines.append('')
|
|
|
|
# Generate fixtures if needed
|
|
if classes:
|
|
lines.append('# Fixtures')
|
|
lines.append('@pytest.fixture')
|
|
lines.append('def sample_data():')
|
|
lines.append(' """Sample test data"""')
|
|
lines.append(' return {')
|
|
lines.append(' "test_string": "test",')
|
|
lines.append(' "test_int": 42,')
|
|
lines.append(' "test_list": [1, 2, 3],')
|
|
lines.append(' "test_dict": {"key": "value"}')
|
|
lines.append(' }')
|
|
lines.append('')
|
|
lines.append('')
|
|
|
|
# Generate tests for standalone functions
|
|
for func in functions:
|
|
lines.extend(self._generate_function_tests(func))
|
|
lines.append('')
|
|
|
|
# Generate tests for classes
|
|
for cls in classes:
|
|
lines.extend(self._generate_class_tests(cls))
|
|
lines.append('')
|
|
|
|
return '\n'.join(lines)
|
|
|
|
def _generate_function_tests(self, func: FunctionInfo) -> List[str]:
|
|
"""Generate test cases for a function"""
|
|
lines = []
|
|
|
|
# Test class for the function
|
|
test_class_name = f'Test{func.name.replace("_", " ").title().replace(" ", "")}'
|
|
lines.append(f'class {test_class_name}:')
|
|
lines.append(f' """{func.docstring or f"Tests for {func.name}"}"""')
|
|
lines.append('')
|
|
|
|
# Basic happy path test
|
|
lines.append(f' def test_{func.name}_success(self):')
|
|
lines.append(f' """Test {func.name} with valid inputs"""')
|
|
|
|
if func.is_async:
|
|
lines.append(' # TODO: Implement async test')
|
|
lines.append(f' # result = await {func.name}(...)')
|
|
else:
|
|
if func.args:
|
|
# Generate sample arguments
|
|
sample_args = ', '.join([self._generate_sample_arg(arg) for arg in func.args])
|
|
lines.append(f' # Arrange')
|
|
for arg in func.args:
|
|
lines.append(f' {arg} = {self._generate_sample_arg(arg)}')
|
|
lines.append('')
|
|
lines.append(f' # Act')
|
|
lines.append(f' result = {func.name}({", ".join(func.args)})')
|
|
else:
|
|
lines.append(f' # Act')
|
|
lines.append(f' result = {func.name}()')
|
|
|
|
lines.append('')
|
|
lines.append(f' # Assert')
|
|
if func.returns:
|
|
lines.append(f' assert result is not None')
|
|
lines.append(f' assert isinstance(result, {func.returns})')
|
|
else:
|
|
lines.append(f' # TODO: Add assertions')
|
|
lines.append('')
|
|
|
|
# Edge case tests
|
|
if func.args:
|
|
lines.append(f' def test_{func.name}_edge_cases(self):')
|
|
lines.append(f' """Test {func.name} with edge case inputs"""')
|
|
lines.append(' # TODO: Test with None, empty values, etc.')
|
|
lines.append(f' pass')
|
|
lines.append('')
|
|
|
|
# Error case test
|
|
lines.append(f' def test_{func.name}_error_handling(self):')
|
|
lines.append(f' """Test {func.name} error handling"""')
|
|
lines.append(' # TODO: Test with invalid inputs')
|
|
lines.append(f' # with pytest.raises(ValueError):')
|
|
lines.append(f' # {func.name}(invalid_input)')
|
|
lines.append(f' pass')
|
|
lines.append('')
|
|
|
|
# Mock test if function likely has external dependencies
|
|
if any(keyword in func.name.lower() for keyword in ['fetch', 'request', 'api', 'download', 'upload', 'send']):
|
|
lines.append(f' @patch("{func.name}")')
|
|
lines.append(f' def test_{func.name}_with_mock(self, mock_{func.name}):')
|
|
lines.append(f' """Test {func.name} with mocked dependencies"""')
|
|
lines.append(f' # Arrange')
|
|
lines.append(f' mock_{func.name}.return_value = Mock()')
|
|
lines.append('')
|
|
lines.append(f' # Act')
|
|
lines.append(f' # TODO: Call function with mocks')
|
|
lines.append('')
|
|
lines.append(f' # Assert')
|
|
lines.append(f' # mock_{func.name}.assert_called_once()')
|
|
lines.append(f' pass')
|
|
lines.append('')
|
|
|
|
return lines
|
|
|
|
def _generate_class_tests(self, cls: ClassInfo) -> List[str]:
|
|
"""Generate test cases for a class"""
|
|
lines = []
|
|
|
|
test_class_name = f'Test{cls.name}'
|
|
lines.append(f'class {test_class_name}:')
|
|
lines.append(f' """{cls.docstring or f"Tests for {cls.name}"}"""')
|
|
lines.append('')
|
|
|
|
# Fixture for class instance
|
|
lines.append(' @pytest.fixture')
|
|
lines.append(f' def instance(self):')
|
|
lines.append(f' """Create instance of {cls.name}"""')
|
|
lines.append(f' # TODO: Initialize with appropriate arguments')
|
|
lines.append(f' return {cls.name}()')
|
|
lines.append('')
|
|
|
|
# Test initialization
|
|
lines.append(f' def test_initialization(self, instance):')
|
|
lines.append(f' """Test {cls.name} initialization"""')
|
|
lines.append(f' assert instance is not None')
|
|
lines.append(f' assert isinstance(instance, {cls.name})')
|
|
lines.append('')
|
|
|
|
# Test each method
|
|
for method in cls.methods:
|
|
if method.name == '__init__':
|
|
continue
|
|
|
|
lines.append(f' def test_{method.name}(self, instance):')
|
|
lines.append(f' """Test {cls.name}.{method.name}"""')
|
|
|
|
if method.is_async:
|
|
lines.append(' # TODO: Implement async test')
|
|
else:
|
|
if method.args:
|
|
lines.append(f' # Arrange')
|
|
for arg in method.args:
|
|
lines.append(f' {arg} = {self._generate_sample_arg(arg)}')
|
|
lines.append('')
|
|
lines.append(f' # Act')
|
|
lines.append(f' result = instance.{method.name}({", ".join(method.args)})')
|
|
else:
|
|
lines.append(f' # Act')
|
|
lines.append(f' result = instance.{method.name}()')
|
|
|
|
lines.append('')
|
|
lines.append(f' # Assert')
|
|
lines.append(f' # TODO: Add assertions')
|
|
lines.append(f' pass')
|
|
lines.append('')
|
|
|
|
return lines
|
|
|
|
def _generate_sample_arg(self, arg_name: str) -> str:
|
|
"""Generate sample value based on argument name"""
|
|
arg_lower = arg_name.lower()
|
|
|
|
if 'id' in arg_lower:
|
|
return '"test-id-123"'
|
|
elif 'name' in arg_lower:
|
|
return '"test_name"'
|
|
elif 'path' in arg_lower or 'file' in arg_lower:
|
|
return '"test/path"'
|
|
elif 'url' in arg_lower:
|
|
return '"https://example.com"'
|
|
elif 'email' in arg_lower:
|
|
return '"test@example.com"'
|
|
elif 'count' in arg_lower or 'num' in arg_lower or 'size' in arg_lower:
|
|
return '42'
|
|
elif 'list' in arg_lower or 'items' in arg_lower:
|
|
return '[1, 2, 3]'
|
|
elif 'dict' in arg_lower or 'config' in arg_lower:
|
|
return '{"key": "value"}'
|
|
elif 'bool' in arg_lower or 'is_' in arg_lower or 'has_' in arg_lower:
|
|
return 'True'
|
|
else:
|
|
return '"test_value"'
|
|
|
|
def write_test_file(self, source_file: Path, content: str) -> Path:
|
|
"""Write test file to disk"""
|
|
# Determine test file path
|
|
test_filename = f'test_{source_file.name}'
|
|
test_path = self.tests_dir / test_filename
|
|
|
|
# Create tests directory if needed
|
|
self.tests_dir.mkdir(exist_ok=True)
|
|
|
|
# Write test file
|
|
with open(test_path, 'w', encoding='utf-8') as f:
|
|
f.write(content)
|
|
|
|
return test_path
|
|
|
|
def run_coverage(self, test_files: List[Path]) -> Dict:
|
|
"""Run coverage analysis"""
|
|
# Check if pytest-cov is available
|
|
result = subprocess.run(
|
|
['python', '-m', 'pytest', '--version'],
|
|
capture_output=True
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
return {
|
|
'error': 'pytest not installed. Install with: pip install pytest pytest-cov'
|
|
}
|
|
|
|
# Run tests with coverage
|
|
try:
|
|
result = subprocess.run(
|
|
['python', '-m', 'pytest', '--cov=.', '--cov-report=term-missing'] + [str(f) for f in test_files],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
return {
|
|
'success': result.returncode == 0,
|
|
'output': result.stdout,
|
|
'coverage_output': result.stderr
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
'error': f'Failed to run coverage: {e}'
|
|
}
|
|
|
|
def generate_tests(self) -> Dict:
|
|
"""Generate tests for all source files"""
|
|
print("🧪 Starting test generation...\n")
|
|
|
|
source_files = self.get_source_files()
|
|
print(f"📁 Found {len(source_files)} source files\n")
|
|
|
|
generated = []
|
|
|
|
for source_file in source_files:
|
|
print(f"📝 Analyzing {source_file}...")
|
|
|
|
functions, classes = self.analyze_file(source_file)
|
|
|
|
if not functions and not classes:
|
|
print(f" ⚠️ No testable functions or classes found")
|
|
continue
|
|
|
|
print(f" Found {len(functions)} functions, {len(classes)} classes")
|
|
|
|
# Generate test content
|
|
test_content = self.generate_test_file(source_file, functions, classes)
|
|
|
|
# Write test file
|
|
test_path = self.write_test_file(source_file, test_content)
|
|
|
|
generated.append({
|
|
'source': source_file,
|
|
'test': test_path,
|
|
'functions': len(functions),
|
|
'classes': len(classes)
|
|
})
|
|
|
|
print(f" ✅ Generated {test_path}")
|
|
|
|
return {
|
|
'generated': generated,
|
|
'total_files': len(generated)
|
|
}
|
|
|
|
def format_report(self, results: Dict) -> str:
|
|
"""Format generation results"""
|
|
report = []
|
|
report.append("\n" + "="*80)
|
|
report.append("🧪 TEST GENERATION REPORT")
|
|
report.append("="*80 + "\n")
|
|
|
|
report.append(f"Total Files: {results['total_files']}")
|
|
report.append(f"Framework: {self.framework}")
|
|
report.append(f"Target Coverage: {self.coverage_threshold}%\n")
|
|
|
|
if results['generated']:
|
|
report.append("Generated Tests:")
|
|
report.append("-" * 80)
|
|
|
|
for item in results['generated']:
|
|
report.append(f"\n📄 {item['source']}")
|
|
report.append(f" → {item['test']}")
|
|
report.append(f" Functions: {item['functions']}, Classes: {item['classes']}")
|
|
|
|
report.append("\n" + "="*80)
|
|
report.append("✅ Test generation completed!")
|
|
report.append("\nNext steps:")
|
|
report.append("1. Review generated tests and add assertions")
|
|
report.append("2. Run tests: pytest tests/")
|
|
report.append("3. Check coverage: pytest --cov=. --cov-report=html")
|
|
report.append("4. Iterate until coverage >= " + str(self.coverage_threshold) + "%")
|
|
report.append("="*80 + "\n")
|
|
|
|
return '\n'.join(report)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description='Automatic test generator',
|
|
formatter_class=argparse.RawDescriptionHelpFormatter
|
|
)
|
|
|
|
parser.add_argument('--target', '-t', default='.',
|
|
help='Target directory or file to generate tests for')
|
|
parser.add_argument('--framework', '-f',
|
|
choices=['pytest', 'unittest'],
|
|
default='pytest',
|
|
help='Test framework to use')
|
|
parser.add_argument('--coverage-threshold', '-c',
|
|
type=int,
|
|
default=80,
|
|
help='Target code coverage percentage')
|
|
parser.add_argument('--run-tests', '-r',
|
|
action='store_true',
|
|
help='Run tests after generation')
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Generate tests
|
|
generator = TestGenerator(
|
|
target=args.target,
|
|
framework=args.framework,
|
|
coverage_threshold=args.coverage_threshold
|
|
)
|
|
results = generator.generate_tests()
|
|
|
|
# Format and display report
|
|
report = generator.format_report(results)
|
|
print(report)
|
|
|
|
# Run tests if requested
|
|
if args.run_tests and results['generated']:
|
|
print("\n🚀 Running generated tests...\n")
|
|
test_files = [item['test'] for item in results['generated']]
|
|
coverage = generator.run_coverage(test_files)
|
|
|
|
if 'error' in coverage:
|
|
print(f"❌ {coverage['error']}")
|
|
else:
|
|
print(coverage['output'])
|
|
if coverage['success']:
|
|
print("✅ All tests passed!")
|
|
else:
|
|
print("❌ Some tests failed. Review and fix.")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|