| |
| """ |
| Assert-to-Test Ratio Tracker for Atom Test Suite |
| |
| This script tracks the assert-to-test ratio to detect coverage gaming (high coverage, |
| few assertions). Tests with low assert counts (< 2) are flagged as low-quality. |
| |
| Industry Standard: 2-3 asserts per test (Google Testing Blog, Martin Fowler) |
| |
| Usage: |
| python assert_test_ratio_tracker.py tests/ --min-ratio 2.0 |
| python assert_test_ratio_tracker.py tests/ --format json --output report.json |
| python assert_test_ratio_tracker.py tests/test_governance.py --min-ratio 2.0 |
| |
| Exit Codes: |
| 0: All tests meet assert ratio threshold |
| 1: Average assert ratio below threshold (low-quality tests detected) |
| 2: Error in execution |
| """ |
|
|
| import argparse |
| import ast |
| import json |
| import sys |
| from pathlib import Path |
| from typing import List, Dict, Any, Optional |
|
|
|
|
| |
| DEFAULT_MIN_RATIO = 2.0 |
| DEFAULT_OUTPUT_FORMAT = "text" |
|
|
|
|
| class AssertCountVisitor(ast.NodeVisitor): |
| """ |
| AST visitor to count assert statements per test function. |
| |
| Identifies test functions (name starts with 'test_') and counts |
| assert statements within each function body. |
| """ |
|
|
| def __init__(self): |
| """Initialize visitor with empty test function list.""" |
| self.test_functions: List[Dict[str, Any]] = [] |
| self.current_function: Optional[Dict[str, Any]] = None |
| self._in_parameterized_test = False |
|
|
| def visit_FunctionDef(self, node: ast.FunctionDef) -> None: |
| """ |
| Visit function definition nodes. |
| |
| Args: |
| node: AST FunctionDef node |
| """ |
| |
| is_test = node.name.startswith("test_") |
|
|
| if is_test: |
| |
| self._in_parameterized_test = self._has_parametrize_decorator(node) |
|
|
| |
| self.current_function = { |
| "name": node.name, |
| "lineno": node.lineno, |
| "assert_count": 0, |
| "parameterized": self._in_parameterized_test |
| } |
|
|
| |
| self.generic_visit(node) |
|
|
| |
| if is_test and self.current_function is not None: |
| self.test_functions.append(self.current_function) |
|
|
| |
| self.current_function = None |
| self._in_parameterized_test = False |
|
|
| def visit_Assert(self, node: ast.Assert) -> None: |
| """ |
| Visit assert statement nodes. |
| |
| Args: |
| node: AST Assert node |
| """ |
| if self.current_function is not None: |
| self.current_function["assert_count"] += 1 |
|
|
| def _has_parametrize_decorator(self, node: ast.FunctionDef) -> bool: |
| """ |
| Check if function has pytest.mark.parametrize decorator. |
| |
| Args: |
| node: AST FunctionDef node |
| |
| Returns: |
| True if parametrize decorator found |
| """ |
| for decorator in node.decorator_list: |
| |
| if isinstance(decorator, ast.Attribute): |
| if (isinstance(decorator.value, ast.Attribute) and |
| decorator.value.attr == "mark" and |
| decorator.attr == "parametrize"): |
| return True |
| |
| if isinstance(decorator, ast.Call): |
| if (isinstance(decorator.func, ast.Attribute) and |
| isinstance(decorator.func.value, ast.Attribute) and |
| decorator.func.value.attr == "mark" and |
| decorator.func.attr == "parametrize"): |
| return True |
| return False |
|
|
|
|
| def analyze_test_file(file_path: Path) -> List[Dict[str, Any]]: |
| """ |
| Analyze a test file and count asserts per test function. |
| |
| Args: |
| file_path: Path to test file |
| |
| Returns: |
| List of test function dicts with assert counts |
| """ |
| try: |
| with open(file_path, 'r', encoding='utf-8') as f: |
| source_code = f.read() |
| except (IOError, UnicodeDecodeError) as e: |
| print(f"Warning: Could not read {file_path}: {e}", file=sys.stderr) |
| return [] |
|
|
| try: |
| tree = ast.parse(source_code, filename=str(file_path)) |
| except SyntaxError as e: |
| print(f"Warning: Syntax error in {file_path}: {e}", file=sys.stderr) |
| return [] |
|
|
| visitor = AssertCountVisitor() |
| visitor.visit(tree) |
|
|
| |
| for test_func in visitor.test_functions: |
| test_func["file"] = str(file_path) |
| |
| try: |
| test_func["file_short"] = str(file_path.relative_to(Path.cwd())) |
| except ValueError: |
| |
| test_func["file_short"] = str(file_path) |
|
|
| return visitor.test_functions |
|
|
|
|
| def calculate_assert_ratio( |
| tests: List[Dict[str, Any]], |
| min_ratio: float, |
| include_parameterized: bool = False |
| ) -> Dict[str, Any]: |
| """ |
| Calculate assert-to-test ratio and identify low-quality tests. |
| |
| Args: |
| tests: List of test function dicts |
| min_ratio: Minimum assert ratio threshold |
| include_parameterized: Include parameterized tests in calculation |
| |
| Returns: |
| Dict with ratio metrics and low-quality test list |
| """ |
| |
| if include_parameterized: |
| filtered_tests = tests |
| else: |
| filtered_tests = [t for t in tests if not t.get("parameterized", False)] |
|
|
| if not filtered_tests: |
| return { |
| "total_tests": 0, |
| "total_asserts": 0, |
| "avg_ratio": 0.0, |
| "low_quality_tests": [], |
| "parameterized_tests_excluded": len(tests) - len(filtered_tests) |
| } |
|
|
| |
| total_asserts = sum(t["assert_count"] for t in filtered_tests) |
| total_tests = len(filtered_tests) |
| avg_ratio = total_asserts / total_tests if total_tests > 0 else 0.0 |
|
|
| |
| low_quality_tests = [ |
| t for t in filtered_tests |
| if t["assert_count"] < min_ratio |
| ] |
|
|
| return { |
| "total_tests": total_tests, |
| "total_asserts": total_asserts, |
| "avg_ratio": round(avg_ratio, 2), |
| "low_quality_tests": low_quality_tests, |
| "parameterized_tests_excluded": len(tests) - len(filtered_tests) |
| } |
|
|
|
|
| def find_test_files(test_path: Path) -> List[Path]: |
| """ |
| Find all test files in directory recursively. |
| |
| Args: |
| test_path: Path to tests directory or file |
| |
| Returns: |
| List of test file paths |
| """ |
| if test_path.is_file(): |
| return [test_path] |
|
|
| |
| test_files = list(test_path.rglob("test_*.py")) |
| return test_files |
|
|
|
|
| def print_text_report( |
| ratio_metrics: Dict[str, Any], |
| min_ratio: float, |
| verbose: bool = False |
| ) -> None: |
| """ |
| Print text format report to stdout. |
| |
| Args: |
| ratio_metrics: Ratio calculation results |
| min_ratio: Minimum ratio threshold |
| verbose: Enable verbose output |
| """ |
| print("\n" + "=" * 70) |
| print("ASSERT-TO-TEST RATIO REPORT") |
| print("=" * 70) |
| print(f"\nTotal Tests: {ratio_metrics['total_tests']}") |
| print(f"Total Asserts: {ratio_metrics['total_asserts']}") |
| print(f"Average Asserts/Test: {ratio_metrics['avg_ratio']:.2f}") |
| print(f"Minimum Threshold: {min_ratio:.2f}") |
|
|
| if ratio_metrics.get("parameterized_tests_excluded", 0) > 0: |
| print(f"\n(Parameterized tests excluded: {ratio_metrics['parameterized_tests_excluded']})") |
|
|
| print("") |
|
|
| |
| low_quality = ratio_metrics["low_quality_tests"] |
| if low_quality: |
| print("-" * 70) |
| print("LOW-QUALITY TESTS DETECTED:") |
| print("-" * 70) |
| print(f"\nTests with < {min_ratio} assert(s): {len(low_quality)}\n") |
|
|
| |
| sorted_tests = sorted(low_quality, key=lambda t: t["assert_count"]) |
|
|
| |
| for test in sorted_tests[:10]: |
| file_short = test.get("file_short", test["file"]) |
| print(f" {file_short}::{test['name']} (line {test['lineno']})") |
| print(f" → {test['assert_count']} assert(s)") |
|
|
| if len(low_quality) > 10: |
| print(f"\n ... and {len(low_quality) - 10} more") |
|
|
| print("\n" + "=" * 70) |
| print("STATUS: LOW-QUALITY TESTS FOUND ✗") |
| print("=" * 70) |
| print("\nRECOMMENDED ACTIONS:") |
| print(" 1. Add more assertions to validate expected behavior") |
| print(" 2. Check for missing edge case testing") |
| print(" 3. Verify assertions actually test behavior (not just None checks)") |
| print(" 4. Consider splitting complex tests into multiple focused tests") |
| print("=" * 70 + "\n") |
| else: |
| print("=" * 70) |
| print("STATUS: ALL TESTS MEET QUALITY THRESHOLD ✓") |
| print("=" * 70 + "\n") |
|
|
| |
| if verbose and ratio_metrics['total_tests'] > 0: |
| print("\nVerbose Output:") |
| print("All test functions analyzed (excluding parameterized):") |
| print(f" Total: {ratio_metrics['total_tests']} tests") |
| print(f" Low-quality: {len(low_quality)} tests") |
| print(f" Quality: {((ratio_metrics['total_tests'] - len(low_quality)) / ratio_metrics['total_tests'] * 100):.1f}% pass rate\n") |
|
|
|
|
| def print_json_report( |
| ratio_metrics: Dict[str, Any], |
| output_path: Optional[Path] = None |
| ) -> None: |
| """ |
| Print or save JSON format report. |
| |
| Args: |
| ratio_metrics: Ratio calculation results |
| output_path: Optional path to save JSON file |
| """ |
| |
| output_data = { |
| "total_tests": ratio_metrics["total_tests"], |
| "total_asserts": ratio_metrics["total_asserts"], |
| "avg_ratio": ratio_metrics["avg_ratio"], |
| "low_quality_count": len(ratio_metrics["low_quality_tests"]), |
| "parameterized_excluded": ratio_metrics.get("parameterized_tests_excluded", 0), |
| "low_quality_tests": [ |
| { |
| "file": t.get("file_short", t["file"]), |
| "name": t["name"], |
| "lineno": t["lineno"], |
| "assert_count": t["assert_count"] |
| } |
| for t in ratio_metrics["low_quality_tests"] |
| ] |
| } |
|
|
| json_str = json.dumps(output_data, indent=2) |
|
|
| if output_path: |
| output_path.parent.mkdir(parents=True, exist_ok=True) |
| with open(output_path, 'w') as f: |
| f.write(json_str) |
| print(f"JSON report saved to: {output_path}\n") |
| else: |
| print(json_str + "\n") |
|
|
|
|
| def main(): |
| """Main entry point for assert-to-test ratio tracking.""" |
| parser = argparse.ArgumentParser( |
| description="Track assert-to-test ratio to detect coverage gaming", |
| formatter_class=argparse.RawDescriptionHelpFormatter, |
| epilog=""" |
| Examples: |
| python assert_test_ratio_tracker.py tests/ |
| python assert_test_ratio_tracker.py tests/ --min-ratio 2.0 --format json --output report.json |
| python assert_test_ratio_tracker.py tests/test_governance.py --min-ratio 3.0 |
| |
| Exit Codes: |
| 0: All tests meet assert ratio threshold |
| 1: Average assert ratio below threshold |
| 2: Error in execution |
| |
| Industry Standard: |
| 2-3 asserts per test (Google Testing Blog, Martin Fowler) |
| |
| How it works: |
| 1. Scans all test_*.py files recursively |
| 2. Parses Python AST to identify test functions |
| 3. Counts assert statements per test function |
| 4. Calculates average asserts per test |
| 5. Flags tests with < N asserts as low-quality |
| 6. Excludes parameterized tests (pytest.mark.parametrize) |
| |
| Coverage Gaming Detection: |
| - High coverage % + Low assert count = Tests execute code but don't validate behavior |
| - Example: 90% coverage with 1.0 avg asserts/test indicates low-quality tests |
| - Target: 2.0+ avg asserts/test for meaningful test coverage |
| """ |
| ) |
|
|
| parser.add_argument( |
| "test_path", |
| type=Path, |
| help="Path to tests directory or specific test file" |
| ) |
|
|
| parser.add_argument( |
| "--min-ratio", |
| type=float, |
| default=DEFAULT_MIN_RATIO, |
| help=f"Minimum asserts per test threshold (default: {DEFAULT_MIN_RATIO})" |
| ) |
|
|
| parser.add_argument( |
| "--format", |
| type=str, |
| choices=["text", "json"], |
| default=DEFAULT_OUTPUT_FORMAT, |
| help="Output format (default: text)" |
| ) |
|
|
| parser.add_argument( |
| "--output", |
| type=Path, |
| default=None, |
| help="Output path for JSON report (default: stdout)" |
| ) |
|
|
| parser.add_argument( |
| "--include-parameterized", |
| action="store_true", |
| help="Include parameterized tests in ratio calculation (default: excluded)" |
| ) |
|
|
| parser.add_argument( |
| "--verbose", |
| action="store_true", |
| help="Enable verbose output" |
| ) |
|
|
| args = parser.parse_args() |
|
|
| |
| if not args.test_path.exists(): |
| print(f"Error: Test path not found: {args.test_path}", file=sys.stderr) |
| sys.exit(2) |
|
|
| |
| test_files = find_test_files(args.test_path) |
|
|
| if not test_files: |
| print(f"Error: No test files found in: {args.test_path}", file=sys.stderr) |
| sys.exit(2) |
|
|
| print(f"Scanning {len(test_files)} test files...\n") |
|
|
| |
| all_tests = [] |
| for test_file in test_files: |
| tests = analyze_test_file(test_file) |
| if tests: |
| all_tests.extend(tests) |
|
|
| if not all_tests: |
| print("Error: No test functions found", file=sys.stderr) |
| sys.exit(2) |
|
|
| |
| ratio_metrics = calculate_assert_ratio( |
| all_tests, |
| args.min_ratio, |
| args.include_parameterized |
| ) |
|
|
| |
| if args.format == "text": |
| print_text_report(ratio_metrics, args.min_ratio, args.verbose) |
| else: |
| print_json_report(ratio_metrics, args.output) |
|
|
| |
| if ratio_metrics["avg_ratio"] < args.min_ratio: |
| sys.exit(1) |
| else: |
| sys.exit(0) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|