Complete rewrite of the senior-qa skill addressing all feedback from Issue #51: SKILL.md (444 lines): - Added proper YAML frontmatter with trigger phrases - Added Table of Contents - Focused on React/Next.js testing (Jest, RTL, Playwright) - 3 actionable workflows with numbered steps - Removed marketing language References (3 files, 2,625+ lines total): - testing_strategies.md: Test pyramid, coverage targets, CI/CD patterns - test_automation_patterns.md: Page Object Model, fixtures, mocking, async testing - qa_best_practices.md: Naming conventions, isolation, debugging strategies Scripts (3 files, 2,261+ lines total): - test_suite_generator.py: Scans React components, generates Jest+RTL tests - coverage_analyzer.py: Parses Istanbul/LCOV, identifies critical gaps - e2e_test_scaffolder.py: Scans Next.js routes, generates Playwright tests Documentation: - Updated engineering-team/README.md senior-qa section - Added README.md in senior-qa subfolder Resolves #51 Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
606 lines
19 KiB
Python
Executable File
606 lines
19 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Test Suite Generator
|
|
|
|
Scans React/TypeScript components and generates Jest + React Testing Library
|
|
test stubs with proper structure, accessibility tests, and common patterns.
|
|
|
|
Usage:
|
|
python test_suite_generator.py src/components/ --output __tests__/
|
|
python test_suite_generator.py src/ --include-a11y --scan-only
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import argparse
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Tuple, Set
|
|
from dataclasses import dataclass, field, asdict
|
|
from datetime import datetime
|
|
|
|
|
|
@dataclass
|
|
class ComponentInfo:
|
|
"""Information about a detected React component"""
|
|
name: str
|
|
file_path: str
|
|
component_type: str # 'functional', 'class', 'forwardRef', 'memo'
|
|
has_props: bool
|
|
props: List[str]
|
|
has_hooks: List[str]
|
|
has_context: bool
|
|
has_effects: bool
|
|
has_state: bool
|
|
has_callbacks: bool
|
|
exports: List[str]
|
|
imports: List[str]
|
|
|
|
|
|
@dataclass
|
|
class TestCase:
|
|
"""A single test case to generate"""
|
|
name: str
|
|
description: str
|
|
test_type: str # 'render', 'interaction', 'a11y', 'props', 'state'
|
|
code: str
|
|
|
|
|
|
@dataclass
|
|
class TestFile:
|
|
"""A complete test file to generate"""
|
|
component: ComponentInfo
|
|
test_cases: List[TestCase] = field(default_factory=list)
|
|
imports: Set[str] = field(default_factory=set)
|
|
|
|
|
|
class ComponentScanner:
|
|
"""Scans source files for React components"""
|
|
|
|
# Patterns for detecting React components
|
|
FUNCTIONAL_COMPONENT = re.compile(
|
|
r'^(?:export\s+)?(?:const|function)\s+([A-Z][a-zA-Z0-9]*)\s*[=:]?\s*(?:\([^)]*\)\s*(?::\s*[^=]+)?\s*=>|function\s*\([^)]*\))',
|
|
re.MULTILINE
|
|
)
|
|
|
|
ARROW_COMPONENT = re.compile(
|
|
r'^(?:export\s+)?const\s+([A-Z][a-zA-Z0-9]*)\s*=\s*(?:React\.)?(?:memo|forwardRef)?\s*\(',
|
|
re.MULTILINE
|
|
)
|
|
|
|
CLASS_COMPONENT = re.compile(
|
|
r'^(?:export\s+)?class\s+([A-Z][a-zA-Z0-9]*)\s+extends\s+(?:React\.)?(?:Component|PureComponent)',
|
|
re.MULTILINE
|
|
)
|
|
|
|
HOOK_PATTERN = re.compile(r'use([A-Z][a-zA-Z0-9]*)\s*\(')
|
|
PROPS_PATTERN = re.compile(r'(?:props\.|{\s*([^}]+)\s*}\s*=\s*props|:\s*([A-Z][a-zA-Z0-9]*Props))')
|
|
CONTEXT_PATTERN = re.compile(r'useContext\s*\(|\.Provider|\.Consumer')
|
|
EFFECT_PATTERN = re.compile(r'useEffect\s*\(|useLayoutEffect\s*\(')
|
|
STATE_PATTERN = re.compile(r'useState\s*\(|useReducer\s*\(|this\.state')
|
|
CALLBACK_PATTERN = re.compile(r'on[A-Z][a-zA-Z]*\s*[=:]|handle[A-Z][a-zA-Z]*\s*[=:]')
|
|
|
|
def __init__(self, source_path: Path, verbose: bool = False):
|
|
self.source_path = source_path
|
|
self.verbose = verbose
|
|
self.components: List[ComponentInfo] = []
|
|
|
|
def scan(self) -> List[ComponentInfo]:
|
|
"""Scan the source path for React components"""
|
|
extensions = {'.tsx', '.jsx', '.ts', '.js'}
|
|
|
|
for root, dirs, files in os.walk(self.source_path):
|
|
# Skip node_modules and test directories
|
|
dirs[:] = [d for d in dirs if d not in {'node_modules', '__tests__', 'test', 'tests', '.git'}]
|
|
|
|
for file in files:
|
|
if Path(file).suffix in extensions:
|
|
file_path = Path(root) / file
|
|
self._scan_file(file_path)
|
|
|
|
return self.components
|
|
|
|
def _scan_file(self, file_path: Path):
|
|
"""Scan a single file for components"""
|
|
try:
|
|
content = file_path.read_text(encoding='utf-8')
|
|
except Exception as e:
|
|
if self.verbose:
|
|
print(f"Warning: Could not read {file_path}: {e}")
|
|
return
|
|
|
|
# Skip test files
|
|
if '.test.' in file_path.name or '.spec.' in file_path.name:
|
|
return
|
|
|
|
# Skip files without JSX indicators
|
|
if 'return' not in content or ('<' not in content and 'jsx' not in content.lower()):
|
|
# Could still be a hook
|
|
if not self.HOOK_PATTERN.search(content):
|
|
return
|
|
|
|
# Find functional components
|
|
for match in self.FUNCTIONAL_COMPONENT.finditer(content):
|
|
name = match.group(1)
|
|
self._add_component(name, file_path, content, 'functional')
|
|
|
|
# Find arrow function components
|
|
for match in self.ARROW_COMPONENT.finditer(content):
|
|
name = match.group(1)
|
|
component_type = 'functional'
|
|
if 'memo(' in content:
|
|
component_type = 'memo'
|
|
elif 'forwardRef(' in content:
|
|
component_type = 'forwardRef'
|
|
self._add_component(name, file_path, content, component_type)
|
|
|
|
# Find class components
|
|
for match in self.CLASS_COMPONENT.finditer(content):
|
|
name = match.group(1)
|
|
self._add_component(name, file_path, content, 'class')
|
|
|
|
def _add_component(self, name: str, file_path: Path, content: str, component_type: str):
|
|
"""Add a component to the list if not already present"""
|
|
# Check if already added
|
|
for comp in self.components:
|
|
if comp.name == name and comp.file_path == str(file_path):
|
|
return
|
|
|
|
# Extract hooks used
|
|
hooks = list(set(self.HOOK_PATTERN.findall(content)))
|
|
|
|
# Extract prop names (simplified)
|
|
props = []
|
|
props_match = self.PROPS_PATTERN.search(content)
|
|
if props_match:
|
|
props_str = props_match.group(1) or ''
|
|
props = [p.strip().split(':')[0].strip() for p in props_str.split(',') if p.strip()]
|
|
|
|
# Extract imports
|
|
imports = re.findall(r"import\s+(?:{[^}]+}|[^;]+)\s+from\s+['\"]([^'\"]+)['\"]", content)
|
|
|
|
# Extract exports
|
|
exports = re.findall(r"export\s+(?:default\s+)?(?:const|function|class)\s+(\w+)", content)
|
|
|
|
component = ComponentInfo(
|
|
name=name,
|
|
file_path=str(file_path),
|
|
component_type=component_type,
|
|
has_props=bool(props) or 'props' in content.lower(),
|
|
props=props[:10], # Limit props
|
|
has_hooks=hooks[:10], # Limit hooks
|
|
has_context=bool(self.CONTEXT_PATTERN.search(content)),
|
|
has_effects=bool(self.EFFECT_PATTERN.search(content)),
|
|
has_state=bool(self.STATE_PATTERN.search(content)),
|
|
has_callbacks=bool(self.CALLBACK_PATTERN.search(content)),
|
|
exports=exports[:5],
|
|
imports=imports[:10]
|
|
)
|
|
|
|
self.components.append(component)
|
|
|
|
if self.verbose:
|
|
print(f" Found: {name} ({component_type}) in {file_path.name}")
|
|
|
|
|
|
class TestGenerator:
|
|
"""Generates Jest + React Testing Library test files"""
|
|
|
|
def __init__(self, include_a11y: bool = False, template: Optional[str] = None):
|
|
self.include_a11y = include_a11y
|
|
self.template = template
|
|
|
|
def generate(self, component: ComponentInfo) -> TestFile:
|
|
"""Generate a test file for a component"""
|
|
test_file = TestFile(component=component)
|
|
|
|
# Build imports
|
|
test_file.imports.add("import { render, screen } from '@testing-library/react';")
|
|
|
|
if component.has_callbacks:
|
|
test_file.imports.add("import userEvent from '@testing-library/user-event';")
|
|
|
|
if component.has_effects or component.has_state:
|
|
test_file.imports.add("import { waitFor } from '@testing-library/react';")
|
|
|
|
if self.include_a11y:
|
|
test_file.imports.add("import { axe, toHaveNoViolations } from 'jest-axe';")
|
|
|
|
# Add component import
|
|
relative_path = self._get_relative_import(component.file_path)
|
|
test_file.imports.add(f"import {{ {component.name} }} from '{relative_path}';")
|
|
|
|
# Generate test cases
|
|
test_file.test_cases.append(self._generate_render_test(component))
|
|
|
|
if component.has_props:
|
|
test_file.test_cases.append(self._generate_props_test(component))
|
|
|
|
if component.has_callbacks:
|
|
test_file.test_cases.append(self._generate_interaction_test(component))
|
|
|
|
if component.has_state:
|
|
test_file.test_cases.append(self._generate_state_test(component))
|
|
|
|
if self.include_a11y:
|
|
test_file.test_cases.append(self._generate_a11y_test(component))
|
|
|
|
return test_file
|
|
|
|
def _get_relative_import(self, file_path: str) -> str:
|
|
"""Get the relative import path for a component"""
|
|
path = Path(file_path)
|
|
# Remove extension
|
|
stem = path.stem
|
|
if stem == 'index':
|
|
return f"../{path.parent.name}"
|
|
return f"../{path.parent.name}/{stem}"
|
|
|
|
def _generate_render_test(self, component: ComponentInfo) -> TestCase:
|
|
"""Generate a basic render test"""
|
|
props_str = self._get_mock_props(component)
|
|
|
|
code = f''' it('renders without crashing', () => {{
|
|
render(<{component.name}{props_str} />);
|
|
}});
|
|
|
|
it('renders expected content', () => {{
|
|
render(<{component.name}{props_str} />);
|
|
// TODO: Add specific content assertions
|
|
// expect(screen.getByRole('...')).toBeInTheDocument();
|
|
}});'''
|
|
|
|
return TestCase(
|
|
name='render',
|
|
description='Basic render tests',
|
|
test_type='render',
|
|
code=code
|
|
)
|
|
|
|
def _generate_props_test(self, component: ComponentInfo) -> TestCase:
|
|
"""Generate props-related tests"""
|
|
props = component.props[:3] if component.props else ['prop1']
|
|
|
|
prop_tests = []
|
|
for prop in props:
|
|
prop_tests.append(f''' it('renders with {prop} prop', () => {{
|
|
render(<{component.name} {prop}="test-value" />);
|
|
// TODO: Assert that {prop} affects rendering
|
|
}});''')
|
|
|
|
code = '\n\n'.join(prop_tests)
|
|
|
|
return TestCase(
|
|
name='props',
|
|
description='Props handling tests',
|
|
test_type='props',
|
|
code=code
|
|
)
|
|
|
|
def _generate_interaction_test(self, component: ComponentInfo) -> TestCase:
|
|
"""Generate user interaction tests"""
|
|
code = f''' it('handles user interaction', async () => {{
|
|
const user = userEvent.setup();
|
|
const handleClick = jest.fn();
|
|
|
|
render(<{component.name} onClick={{handleClick}} />);
|
|
|
|
// TODO: Find the interactive element
|
|
const button = screen.getByRole('button');
|
|
await user.click(button);
|
|
|
|
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
}});
|
|
|
|
it('handles keyboard navigation', async () => {{
|
|
const user = userEvent.setup();
|
|
render(<{component.name} />);
|
|
|
|
// TODO: Add keyboard interaction tests
|
|
// await user.tab();
|
|
// expect(screen.getByRole('...')).toHaveFocus();
|
|
}});'''
|
|
|
|
return TestCase(
|
|
name='interaction',
|
|
description='User interaction tests',
|
|
test_type='interaction',
|
|
code=code
|
|
)
|
|
|
|
def _generate_state_test(self, component: ComponentInfo) -> TestCase:
|
|
"""Generate state-related tests"""
|
|
code = f''' it('updates state correctly', async () => {{
|
|
const user = userEvent.setup();
|
|
render(<{component.name} />);
|
|
|
|
// TODO: Trigger state change
|
|
// await user.click(screen.getByRole('button'));
|
|
|
|
// TODO: Assert state change is reflected in UI
|
|
await waitFor(() => {{
|
|
// expect(screen.getByText('...')).toBeInTheDocument();
|
|
}});
|
|
}});'''
|
|
|
|
return TestCase(
|
|
name='state',
|
|
description='State management tests',
|
|
test_type='state',
|
|
code=code
|
|
)
|
|
|
|
def _generate_a11y_test(self, component: ComponentInfo) -> TestCase:
|
|
"""Generate accessibility test"""
|
|
props_str = self._get_mock_props(component)
|
|
|
|
code = f''' it('has no accessibility violations', async () => {{
|
|
const {{ container }} = render(<{component.name}{props_str} />);
|
|
const results = await axe(container);
|
|
expect(results).toHaveNoViolations();
|
|
}});'''
|
|
|
|
return TestCase(
|
|
name='accessibility',
|
|
description='Accessibility tests',
|
|
test_type='a11y',
|
|
code=code
|
|
)
|
|
|
|
def _get_mock_props(self, component: ComponentInfo) -> str:
|
|
"""Generate mock props string for a component"""
|
|
if not component.has_props or not component.props:
|
|
return ''
|
|
|
|
# Return empty for simplicity, user should fill in
|
|
return ' {...mockProps}'
|
|
|
|
def format_test_file(self, test_file: TestFile) -> str:
|
|
"""Format the complete test file content"""
|
|
lines = []
|
|
|
|
# Imports
|
|
lines.append("import '@testing-library/jest-dom';")
|
|
for imp in sorted(test_file.imports):
|
|
lines.append(imp)
|
|
|
|
lines.append('')
|
|
|
|
# A11y setup if needed
|
|
if self.include_a11y:
|
|
lines.append('expect.extend(toHaveNoViolations);')
|
|
lines.append('')
|
|
|
|
# Mock props if component has props
|
|
if test_file.component.has_props:
|
|
lines.append('// TODO: Define mock props')
|
|
lines.append('const mockProps = {};')
|
|
lines.append('')
|
|
|
|
# Describe block
|
|
lines.append(f"describe('{test_file.component.name}', () => {{")
|
|
|
|
# Test cases grouped by type
|
|
test_types = {}
|
|
for test_case in test_file.test_cases:
|
|
if test_case.test_type not in test_types:
|
|
test_types[test_case.test_type] = []
|
|
test_types[test_case.test_type].append(test_case)
|
|
|
|
for test_type, cases in test_types.items():
|
|
for case in cases:
|
|
lines.append('')
|
|
lines.append(f' // {case.description}')
|
|
lines.append(case.code)
|
|
|
|
lines.append('});')
|
|
lines.append('')
|
|
|
|
return '\n'.join(lines)
|
|
|
|
|
|
class TestSuiteGenerator:
|
|
"""Main class for generating test suites"""
|
|
|
|
def __init__(
|
|
self,
|
|
source_path: str,
|
|
output_path: Optional[str] = None,
|
|
include_a11y: bool = False,
|
|
scan_only: bool = False,
|
|
verbose: bool = False,
|
|
template: Optional[str] = None
|
|
):
|
|
self.source_path = Path(source_path)
|
|
self.output_path = Path(output_path) if output_path else None
|
|
self.include_a11y = include_a11y
|
|
self.scan_only = scan_only
|
|
self.verbose = verbose
|
|
self.template = template
|
|
self.results = {
|
|
'status': 'success',
|
|
'source': str(self.source_path),
|
|
'components': [],
|
|
'generated_files': [],
|
|
'summary': {}
|
|
}
|
|
|
|
def run(self) -> Dict:
|
|
"""Execute the test suite generation"""
|
|
print(f"Scanning: {self.source_path}")
|
|
|
|
# Validate source path
|
|
if not self.source_path.exists():
|
|
raise ValueError(f"Source path does not exist: {self.source_path}")
|
|
|
|
# Scan for components
|
|
scanner = ComponentScanner(self.source_path, self.verbose)
|
|
components = scanner.scan()
|
|
|
|
print(f"Found {len(components)} React components")
|
|
|
|
if self.scan_only:
|
|
self._report_scan_results(components)
|
|
return self.results
|
|
|
|
# Generate tests
|
|
if not self.output_path:
|
|
# Default to __tests__ in source directory
|
|
self.output_path = self.source_path / '__tests__'
|
|
|
|
self.output_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
generator = TestGenerator(self.include_a11y, self.template)
|
|
|
|
total_tests = 0
|
|
for component in components:
|
|
test_file = generator.generate(component)
|
|
content = generator.format_test_file(test_file)
|
|
|
|
# Write test file
|
|
test_filename = f"{component.name}.test.tsx"
|
|
test_path = self.output_path / test_filename
|
|
|
|
test_path.write_text(content, encoding='utf-8')
|
|
|
|
test_count = len(test_file.test_cases)
|
|
total_tests += test_count
|
|
|
|
self.results['generated_files'].append({
|
|
'component': component.name,
|
|
'path': str(test_path),
|
|
'test_cases': test_count
|
|
})
|
|
|
|
print(f" {test_filename} ({test_count} test cases)")
|
|
|
|
# Store component info
|
|
self.results['components'] = [asdict(c) for c in components]
|
|
|
|
# Summary
|
|
self.results['summary'] = {
|
|
'total_components': len(components),
|
|
'total_files': len(self.results['generated_files']),
|
|
'total_test_cases': total_tests,
|
|
'output_directory': str(self.output_path)
|
|
}
|
|
|
|
print('')
|
|
print(f"Summary: {len(components)} test files, {total_tests} test cases")
|
|
|
|
return self.results
|
|
|
|
def _report_scan_results(self, components: List[ComponentInfo]):
|
|
"""Report scan results without generating tests"""
|
|
print('')
|
|
print("=" * 60)
|
|
print("COMPONENT SCAN RESULTS")
|
|
print("=" * 60)
|
|
|
|
# Group by type
|
|
by_type = {}
|
|
for comp in components:
|
|
comp_type = comp.component_type
|
|
if comp_type not in by_type:
|
|
by_type[comp_type] = []
|
|
by_type[comp_type].append(comp)
|
|
|
|
for comp_type, comps in sorted(by_type.items()):
|
|
print(f"\n{comp_type.upper()} COMPONENTS ({len(comps)}):")
|
|
for comp in comps:
|
|
hooks_str = f" [hooks: {', '.join(comp.has_hooks[:3])}]" if comp.has_hooks else ""
|
|
state_str = " [stateful]" if comp.has_state else ""
|
|
print(f" - {comp.name}{hooks_str}{state_str}")
|
|
print(f" {comp.file_path}")
|
|
|
|
print('')
|
|
print("=" * 60)
|
|
print(f"Total: {len(components)} components")
|
|
print("=" * 60)
|
|
|
|
self.results['components'] = [asdict(c) for c in components]
|
|
self.results['summary'] = {
|
|
'total_components': len(components),
|
|
'by_type': {k: len(v) for k, v in by_type.items()}
|
|
}
|
|
|
|
|
|
def main():
|
|
"""Main entry point"""
|
|
parser = argparse.ArgumentParser(
|
|
description="Generate Jest + React Testing Library test stubs for React components",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
# Scan and generate tests
|
|
python test_suite_generator.py src/components/ --output __tests__/
|
|
|
|
# Scan only (don't generate)
|
|
python test_suite_generator.py src/components/ --scan-only
|
|
|
|
# Include accessibility tests
|
|
python test_suite_generator.py src/ --include-a11y --output tests/
|
|
|
|
# Verbose output
|
|
python test_suite_generator.py src/components/ -v
|
|
"""
|
|
)
|
|
parser.add_argument(
|
|
'source',
|
|
help='Source directory containing React components'
|
|
)
|
|
parser.add_argument(
|
|
'--output', '-o',
|
|
help='Output directory for test files (default: <source>/__tests__/)'
|
|
)
|
|
parser.add_argument(
|
|
'--include-a11y',
|
|
action='store_true',
|
|
help='Include accessibility tests using jest-axe'
|
|
)
|
|
parser.add_argument(
|
|
'--scan-only',
|
|
action='store_true',
|
|
help='Scan and report components without generating tests'
|
|
)
|
|
parser.add_argument(
|
|
'--template',
|
|
help='Custom template file for test generation'
|
|
)
|
|
parser.add_argument(
|
|
'--verbose', '-v',
|
|
action='store_true',
|
|
help='Enable verbose output'
|
|
)
|
|
parser.add_argument(
|
|
'--json',
|
|
action='store_true',
|
|
help='Output results as JSON'
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
generator = TestSuiteGenerator(
|
|
args.source,
|
|
output_path=args.output,
|
|
include_a11y=args.include_a11y,
|
|
scan_only=args.scan_only,
|
|
verbose=args.verbose,
|
|
template=args.template
|
|
)
|
|
|
|
results = generator.run()
|
|
|
|
if args.json:
|
|
print(json.dumps(results, indent=2))
|
|
|
|
except Exception as e:
|
|
print(f"Error: {e}")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|