Files
claude-skills-reference/engineering-team/senior-qa/scripts/test_suite_generator.py
Alireza Rezvani 6cd35fedd8 fix(skill): rewrite senior-qa with unique, actionable content (#51) (#95)
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>
2026-01-27 08:25:56 +01:00

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()