Initial commit
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Testing Strategy Analyzer
|
||||
|
||||
Analyzes React testing against Bulletproof React and Connor's standards:
|
||||
- Testing trophy distribution (70% integration, 20% unit, 10% E2E)
|
||||
- 80%+ coverage requirement
|
||||
- Semantic queries (getByRole preferred)
|
||||
- User behavior testing (not implementation details)
|
||||
- Test naming ("should X when Y")
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
def analyze(codebase_path: Path, metadata: Dict) -> List[Dict]:
|
||||
"""
|
||||
Analyze testing strategy and quality.
|
||||
|
||||
Args:
|
||||
codebase_path: Path to React codebase
|
||||
metadata: Project metadata from discovery phase
|
||||
|
||||
Returns:
|
||||
List of findings with severity and migration guidance
|
||||
"""
|
||||
findings = []
|
||||
|
||||
tech_stack = metadata.get('tech_stack', {})
|
||||
src_dir = codebase_path / 'src'
|
||||
|
||||
if not src_dir.exists():
|
||||
return findings
|
||||
|
||||
# Check for testing framework
|
||||
findings.extend(check_testing_framework(tech_stack))
|
||||
|
||||
# Check test coverage
|
||||
findings.extend(check_test_coverage(codebase_path))
|
||||
|
||||
# Analyze test distribution (unit vs integration vs E2E)
|
||||
findings.extend(analyze_test_distribution(codebase_path))
|
||||
|
||||
# Check test quality patterns
|
||||
findings.extend(check_test_quality(codebase_path))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_testing_framework(tech_stack: Dict) -> List[Dict]:
|
||||
"""Check for modern testing setup."""
|
||||
findings = []
|
||||
|
||||
has_test_framework = tech_stack.get('vitest') or tech_stack.get('jest')
|
||||
has_testing_library = tech_stack.get('testing-library')
|
||||
|
||||
if not has_test_framework:
|
||||
findings.append({
|
||||
'severity': 'critical',
|
||||
'category': 'testing',
|
||||
'title': 'No testing framework detected',
|
||||
'current_state': 'No Vitest or Jest found',
|
||||
'target_state': 'Use Vitest (modern, fast) or Jest for testing',
|
||||
'migration_steps': [
|
||||
'Install Vitest (recommended for Vite) or Jest',
|
||||
'Install @testing-library/react',
|
||||
'Configure test setup file',
|
||||
'Add test scripts to package.json',
|
||||
'Set up coverage reporting'
|
||||
],
|
||||
'effort': 'medium',
|
||||
})
|
||||
|
||||
if not has_testing_library:
|
||||
findings.append({
|
||||
'severity': 'high',
|
||||
'category': 'testing',
|
||||
'title': 'Testing Library not found',
|
||||
'current_state': 'No @testing-library/react detected',
|
||||
'target_state': 'Use Testing Library for user-centric testing',
|
||||
'migration_steps': [
|
||||
'Install @testing-library/react',
|
||||
'Install @testing-library/jest-dom for assertions',
|
||||
'Use render() and semantic queries (getByRole)',
|
||||
'Follow testing-library principles (test behavior, not implementation)'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_test_coverage(codebase_path: Path) -> List[Dict]:
|
||||
"""Check test coverage if available."""
|
||||
findings = []
|
||||
|
||||
# Look for coverage reports
|
||||
coverage_file = codebase_path / 'coverage' / 'coverage-summary.json'
|
||||
|
||||
if coverage_file.exists():
|
||||
try:
|
||||
with open(coverage_file, 'r') as f:
|
||||
coverage_data = json.load(f)
|
||||
total_coverage = coverage_data.get('total', {})
|
||||
line_coverage = total_coverage.get('lines', {}).get('pct', 0)
|
||||
branch_coverage = total_coverage.get('branches', {}).get('pct', 0)
|
||||
|
||||
if line_coverage < 80:
|
||||
findings.append({
|
||||
'severity': 'high',
|
||||
'category': 'testing',
|
||||
'title': f'Test coverage below 80% ({line_coverage:.1f}%)',
|
||||
'current_state': f'Line coverage: {line_coverage:.1f}%, Branch coverage: {branch_coverage:.1f}%',
|
||||
'target_state': 'Maintain 80%+ test coverage on all code',
|
||||
'migration_steps': [
|
||||
'Identify untested files and functions',
|
||||
'Prioritize testing critical paths (authentication, payment, data processing)',
|
||||
'Write integration tests first (70% of tests)',
|
||||
'Add unit tests for complex business logic',
|
||||
'Configure coverage thresholds in test config'
|
||||
],
|
||||
'effort': 'high',
|
||||
})
|
||||
elif line_coverage < 90:
|
||||
findings.append({
|
||||
'severity': 'medium',
|
||||
'category': 'testing',
|
||||
'title': f'Test coverage at {line_coverage:.1f}%',
|
||||
'current_state': f'Coverage is good but could be excellent (current: {line_coverage:.1f}%)',
|
||||
'target_state': 'Aim for 90%+ coverage for production-ready code',
|
||||
'migration_steps': [
|
||||
'Identify remaining untested code paths',
|
||||
'Focus on edge cases and error handling',
|
||||
'Ensure all critical features have 100% coverage'
|
||||
],
|
||||
'effort': 'medium',
|
||||
})
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
findings.append({
|
||||
'severity': 'high',
|
||||
'category': 'testing',
|
||||
'title': 'No coverage report found',
|
||||
'current_state': 'Cannot find coverage/coverage-summary.json',
|
||||
'target_state': 'Generate coverage reports to track test coverage',
|
||||
'migration_steps': [
|
||||
'Configure coverage in vitest.config.ts or jest.config.js',
|
||||
'Add --coverage flag to test script',
|
||||
'Set coverage thresholds (lines: 80, branches: 75)',
|
||||
'Add coverage/ to .gitignore',
|
||||
'Review coverage reports regularly'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def analyze_test_distribution(codebase_path: Path) -> List[Dict]:
|
||||
"""Analyze testing trophy distribution."""
|
||||
findings = []
|
||||
|
||||
# Count test files by type
|
||||
unit_tests = 0
|
||||
integration_tests = 0
|
||||
e2e_tests = 0
|
||||
|
||||
test_patterns = {
|
||||
'e2e': ['e2e/', '.e2e.test.', '.e2e.spec.', 'playwright/', 'cypress/'],
|
||||
'integration': ['.test.tsx', '.test.jsx', '.spec.tsx', '.spec.jsx'], # Component tests
|
||||
'unit': ['.test.ts', '.test.js', '.spec.ts', '.spec.js'], # Logic tests
|
||||
}
|
||||
|
||||
for test_file in codebase_path.rglob('*.{test,spec}.{ts,tsx,js,jsx}'):
|
||||
test_path_str = str(test_file)
|
||||
|
||||
# E2E tests
|
||||
if any(pattern in test_path_str for pattern in test_patterns['e2e']):
|
||||
e2e_tests += 1
|
||||
# Integration tests (component tests with TSX/JSX)
|
||||
elif any(pattern in test_path_str for pattern in test_patterns['integration']):
|
||||
integration_tests += 1
|
||||
# Unit tests (pure logic, no JSX)
|
||||
else:
|
||||
unit_tests += 1
|
||||
|
||||
total_tests = unit_tests + integration_tests + e2e_tests
|
||||
|
||||
if total_tests > 0:
|
||||
int_pct = (integration_tests / total_tests) * 100
|
||||
unit_pct = (unit_tests / total_tests) * 100
|
||||
e2e_pct = (e2e_tests / total_tests) * 100
|
||||
|
||||
# Testing Trophy: 70% integration, 20% unit, 10% E2E
|
||||
if int_pct < 50: # Should be ~70%
|
||||
findings.append({
|
||||
'severity': 'medium',
|
||||
'category': 'testing',
|
||||
'title': 'Testing pyramid instead of testing trophy',
|
||||
'current_state': f'Distribution: {int_pct:.0f}% integration, {unit_pct:.0f}% unit, {e2e_pct:.0f}% E2E',
|
||||
'target_state': 'Testing Trophy: 70% integration, 20% unit, 10% E2E',
|
||||
'migration_steps': [
|
||||
'Write more integration tests (component + hooks + context)',
|
||||
'Test user workflows, not implementation details',
|
||||
'Reduce excessive unit tests of simple functions',
|
||||
'Keep E2E tests for critical user journeys only',
|
||||
'Use Testing Library for integration tests'
|
||||
],
|
||||
'effort': 'medium',
|
||||
})
|
||||
|
||||
if unit_pct > 40: # Should be ~20%
|
||||
findings.append({
|
||||
'severity': 'low',
|
||||
'category': 'testing',
|
||||
'title': 'Too many unit tests',
|
||||
'current_state': f'{unit_pct:.0f}% unit tests (target: ~20%)',
|
||||
'target_state': 'Focus on integration tests that provide more confidence',
|
||||
'migration_steps': [
|
||||
'Review unit tests - many could be integration tests',
|
||||
'Combine related unit tests into integration tests',
|
||||
'Keep unit tests only for complex business logic',
|
||||
'Test components with their hooks and context'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_test_quality(codebase_path: Path) -> List[Dict]:
|
||||
"""Check for test quality anti-patterns."""
|
||||
findings = []
|
||||
|
||||
brittle_test_patterns = []
|
||||
bad_query_usage = []
|
||||
bad_naming = []
|
||||
|
||||
for test_file in codebase_path.rglob('*.{test,spec}.{ts,tsx,js,jsx}'):
|
||||
try:
|
||||
with open(test_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for brittle tests (testing implementation)
|
||||
if 'getByTestId' in content:
|
||||
bad_query_usage.append(str(test_file))
|
||||
|
||||
# Check for testing exact counts (brittle)
|
||||
if re.search(r'expect\([^)]+\)\.toHaveLength\(\d+\)', content):
|
||||
brittle_test_patterns.append(str(test_file))
|
||||
|
||||
# Check test naming ("should X when Y")
|
||||
test_names = re.findall(r'(?:it|test)\s*\(\s*[\'"]([^\'"]+)[\'"]', content)
|
||||
for name in test_names:
|
||||
if not (name.startswith('should ') or 'when' in name.lower()):
|
||||
bad_naming.append((str(test_file), name))
|
||||
except:
|
||||
pass
|
||||
|
||||
if bad_query_usage:
|
||||
findings.append({
|
||||
'severity': 'medium',
|
||||
'category': 'testing',
|
||||
'title': f'Using getByTestId in {len(bad_query_usage)} test files',
|
||||
'current_state': 'Tests use getByTestId instead of semantic queries',
|
||||
'target_state': 'Use semantic queries: getByRole, getByLabelText, getByText',
|
||||
'migration_steps': [
|
||||
'Replace getByTestId with getByRole (most preferred)',
|
||||
'Use getByLabelText for form inputs',
|
||||
'Use getByText for user-visible content',
|
||||
'Only use getByTestId as last resort',
|
||||
'Add eslint-plugin-testing-library for enforcement'
|
||||
],
|
||||
'effort': 'medium',
|
||||
'affected_files': bad_query_usage[:5],
|
||||
})
|
||||
|
||||
if brittle_test_patterns:
|
||||
findings.append({
|
||||
'severity': 'low',
|
||||
'category': 'testing',
|
||||
'title': f'Brittle test patterns in {len(brittle_test_patterns)} files',
|
||||
'current_state': 'Tests check exact counts and DOM structure',
|
||||
'target_state': 'Test user behavior and outcomes, not exact DOM structure',
|
||||
'migration_steps': [
|
||||
'Avoid testing exact element counts',
|
||||
'Focus on user-visible behavior',
|
||||
'Test functionality, not implementation',
|
||||
'Allow flexibility in DOM structure'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
if len(bad_naming) > 5: # More than 5 tests with poor naming
|
||||
findings.append({
|
||||
'severity': 'low',
|
||||
'category': 'testing',
|
||||
'title': f'{len(bad_naming)} tests with unclear naming',
|
||||
'current_state': 'Test names don\'t follow "should X when Y" pattern',
|
||||
'target_state': 'Use descriptive names: "should display error when API fails"',
|
||||
'migration_steps': [
|
||||
'Rename tests to describe expected behavior',
|
||||
'Use pattern: "should [expected behavior] when [condition]"',
|
||||
'Make tests self-documenting',
|
||||
'Tests should read like requirements'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
return findings
|
||||
Reference in New Issue
Block a user