diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..daab20d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + pull_request: + branches: [prd, dev, stg] + +jobs: + checkMeds: + name: Check Meds (merge every day) + runs-on: ubuntu-latest + steps: + - name: Check Meds + uses: byuawsfhtl/MedsAction@v1.0.0 + + checkStandard: + name: Python Standard Check + runs-on: ubuntu-latest + steps: + - name: Check Standard + uses: byuawsfhtl/PythonStandardAction@v1.2.0 + + checkTestCoverage: + name: Test Coverage Check + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Check Test Coverage + uses: ./ diff --git a/.standardignore b/.standardignore new file mode 100644 index 0000000..6988e72 --- /dev/null +++ b/.standardignore @@ -0,0 +1 @@ +**/tests \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..184c4eb --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + ".", + "-p", + "test_*.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/README.md b/README.md index 9c7b294..32c7bb3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,88 @@ -# RLL_Template -This is a template repository for future computer vision projects +# Test Coverage Action + +A GitHub Action that scans repositories for tests, calculates test coverage, and reports coverage percentage. + +## Features + +- **🔍 Auto-discovery**: Finds test files using configurable patterns +- **📊 Coverage calculation**: Uses Python's `coverage.py` tool for accurate metrics +- **⚙️ Flexible configuration**: Customizable coverage thresholds and paths +- **📈 Multiple report formats**: Terminal, HTML, XML, and JSON output +- **🚫 Smart exclusions**: Excludes test files and specified paths from coverage +- **✅ CI integration**: Seamless GitHub Actions workflow integration + +## Usage + +Add this step to your GitHub Actions workflow: + +```yaml +- name: Check Test Coverage + uses: ./ + with: + minimum_coverage: '80' # Required coverage percentage + test_paths: 'tests/,**/test_*.py' # Where to find tests + source_paths: '.' # Source code to analyze + exclude_paths: 'tests/,setup.py' # Paths to exclude + fail_on_low_coverage: 'true' # Fail if below threshold. For older repos that are building coverage, change this to false + report_format: 'term' # Report format (term/html/xml/json) +``` + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `minimum_coverage` | Minimum coverage percentage (0-100) | No | `80` | +| `test_paths` | Comma-separated test directories/files | No | `tests/,test/,**/test_*.py,**/tests.py` | +| `source_paths` | Comma-separated source directories | No | `.` | +| `exclude_paths` | Comma-separated paths to exclude | No | `tests/,test/,**/test_*.py,**/tests.py,setup.py,conftest.py` | +| `fail_on_low_coverage` | Fail action if coverage below minimum | No | `true` | +| `report_format` | Coverage report format | No | `term` | + +## Outputs + +| Output | Description | +|--------|-------------| +| `coverage_percentage` | Calculated test coverage percentage | +| `coverage_report` | Path to generated coverage report | +| `tests_found` | Number of test files discovered | + +## Examples + +### Basic Usage +```yaml +- name: Test Coverage Check + uses: ./ +``` + +### Custom Configuration +```yaml +- name: Test Coverage Check + uses: ./ + with: + minimum_coverage: '90' + test_paths: 'my_tests/,unit_tests/' + source_paths: 'src/,lib/' + exclude_paths: 'tests/,migrations/' + report_format: 'html' +``` + +### Generate HTML Report +```yaml +- name: Test Coverage Check + uses: ./ + with: + report_format: 'html' + +- name: Upload Coverage Report + uses: actions/upload-artifact@v3 + with: + name: coverage-report + path: htmlcov/ +``` + +## Development + +The action consists of: +- `action.yml` - Action metadata and interface +- `TestChecker.py` - Main coverage checking logic +- `requirements.txt` - Python dependencies diff --git a/TestChecker.py b/TestChecker.py new file mode 100644 index 0000000..f6d1286 --- /dev/null +++ b/TestChecker.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python3 +""" +Test Coverage Checker for GitHub Actions +Scans repository for tests, calculates coverage, and reports results. +""" + +import argparse +import os +import sys +import subprocess +import glob +import json +from typing import List, Tuple + + +class CoverageChecker: + """Main class for test coverage checking functionality.""" + + def __init__(self, args: argparse.Namespace) -> None: + """ + Initialize the CoverageChecker class. + + Args: + args: argparse.Namespace object containing command line arguments + + Returns: + None + """ + self.minimum_coverage = float(args.minimum_coverage) + self.test_paths = [p.strip() for p in args.test_paths.split(',') if p.strip()] + self.source_paths = [p.strip() for p in args.source_paths.split(',') if p.strip()] + self.exclude_paths = [p.strip() for p in args.exclude_paths.split(',') if p.strip()] + self.fail_on_low_coverage = args.fail_on_low_coverage.lower() == 'true' + self.report_format = args.report_format + self.workspace_path = os.getcwd() + + def find_test_files(self) -> list[str]: + """ + Discover test files in the repository. + + Args: + None + + Returns: + List[str]: List of test files found in the repository + """ + test_files = [] + + print("Discovering test files...") + + for test_path in self.test_paths: + # Handle glob patterns + if '*' in test_path: + matches = glob.glob(test_path, recursive=True) + test_files.extend(matches) + else: + self._handle_file_paths(test_path, test_files) + + # Remove duplicates and non-existent files + test_files = list(set([f for f in test_files if os.path.isfile(f)])) + + # Convert to relative paths for display + relative_paths = [os.path.relpath(f, self.workspace_path).replace(os.sep, '/') for f in test_files] + + print(f"Found {len(test_files)} test files:") + for relative_path in relative_paths: + print(f" • {relative_path}") + + return test_files + + def _handle_file_paths(self, test_path: str, test_files: list[str]) -> None: + """ + Handle file paths for test discovery. + + Args: + test_path: str, the path to the test file or directory + test_files: List[str], the list of test files found so far + + Returns: + None + """ + full_path = os.path.join(self.workspace_path, test_path) + if os.path.isdir(full_path): + self._find_tests_in_dir(full_path, test_files) + elif os.path.isfile(full_path) and full_path.endswith('.py'): + test_files.append(full_path) + + def _find_tests_in_dir(self, full_path: str, test_files: list[str]) -> None: + """ + Find tests in a directory. + + Args: + full_path: str, the path to the directory to search + test_files: List[str], the list of test files found so far + + Returns: + None + """ + for root, _, files in os.walk(full_path): + for file in files: + if (file.startswith('test_') and file.endswith('.py')) or \ + (file.endswith('_test.py')) or \ + (file == 'tests.py'): + test_files.append(os.path.join(root, file)) + + def build_coverage_command(self, test_files: list[str]) -> list[str]: + """ + Build the coverage command to run tests with coverage collection. + + Args: + test_files: List[str], the list of test files to run + + Returns: + List[str]: The coverage command to run tests with coverage collection + """ + # Create source include pattern + source_include = [] + for source_path in self.source_paths: + if source_path == '.': + source_include.append('--source=.') + else: + source_include.append(f'--source={source_path}') + + # Create exclude pattern + exclude_patterns = [] + for exclude_path in self.exclude_paths: + exclude_patterns.extend(['--omit', exclude_path]) + + # Build the command + cmd = ['coverage', 'run'] + source_include + exclude_patterns + + # Add pytest runner if test files found, otherwise use unittest discovery + if test_files: + cmd.extend(['-m', 'pytest'] + test_files) + else: + cmd.extend(['-m', 'unittest', 'discover']) + + return cmd + + def run_tests_with_coverage(self, test_files: list[str]) -> tuple[bool, str]: + """ + Run tests with coverage collection. + + Args: + test_files: List[str], the list of test files to run + + Returns: + Tuple[bool, str]: A tuple containing a boolean indicating success and a string containing the test output + """ + print("\nRunning tests with coverage...") + + # Build and run coverage command + cmd = self.build_coverage_command(test_files) + print(f"Command: {' '.join(cmd)}") + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + cwd=self.workspace_path + ) + + print("Test output:") + print(result.stdout) + if result.stderr: + print("Errors:") + print(result.stderr) + + # Check if tests passed (return code 0 means success) + if result.returncode != 0: + print(f"Error: Tests failed with return code {result.returncode}") + return False, result.stdout + result.stderr + + print("Success: All tests passed!") + return True, result.stdout + result.stderr + + except subprocess.CalledProcessError as e: + print(f"Error: Error running tests: {e}") + return False, f"Error running tests: {str(e)}" + except FileNotFoundError: + print("Error: Coverage tool not found. Make sure 'coverage' is installed.") + return False, "Coverage tool not found" + + def generate_coverage_report(self) -> tuple[float, str]: + """ + Generate and parse coverage report. + + Args: + None + + Returns: + Tuple[float, str]: A tuple containing the coverage percentage and the coverage report + """ + print("\nGenerating coverage report...") + + # Generate JSON report for parsing + json_cmd = ['coverage', 'json', '-o', 'coverage.json'] + try: + subprocess.run(json_cmd, check=True, cwd=self.workspace_path) + except subprocess.CalledProcessError as e: + print(f"Error: Error generating JSON report: {e}") + return 0.0, "" + + # Parse JSON report + coverage_file = os.path.join(self.workspace_path, 'coverage.json') + if not os.path.exists(coverage_file): + print("Error: Coverage JSON file not found") + return 0.0, "" + + try: + with open(coverage_file, 'r') as f: + coverage_data = json.load(f) + + total_coverage = coverage_data.get('totals', {}).get('percent_covered', 0.0) + + except (json.JSONDecodeError, KeyError) as e: + print(f"Error: Error parsing coverage JSON: {e}") + return 0.0, "" + + # Generate human-readable report + report_cmd = ['coverage', 'report'] + if self.report_format == 'html': + report_cmd = ['coverage', 'html', '-d', 'htmlcov'] + elif self.report_format == 'xml': + report_cmd = ['coverage', 'xml', '-o', 'coverage.xml'] + + try: + result = subprocess.run( + report_cmd, + capture_output=True, + text=True, + cwd=self.workspace_path + ) + report_output = result.stdout + except subprocess.CalledProcessError: + report_output = "Could not generate detailed report" + + return total_coverage, report_output + + def set_github_outputs(self, coverage_percentage: float, tests_found: int) -> None: + """ + Set GitHub Action outputs. + + Args: + coverage_percentage: float, the coverage percentage + tests_found: int, the number of tests found + + Returns: + None + """ + github_output = os.environ.get('GITHUB_OUTPUT') + if github_output: + try: + self._report_file_path(github_output, coverage_percentage, tests_found) + except Exception as e: + print(f"Error: Could not set GitHub outputs: {e}") + + def _report_file_path(self, github_output: str, coverage_percentage: float, tests_found: int) -> None: + """ + Set the report file path. + + Args: + github_output: str, the path to the GitHub output file + coverage_percentage: float, the coverage percentage + tests_found: int, the number of tests found + + Returns: + None + """ + with open(github_output, 'a') as f: + f.write(f"coverage_percentage={coverage_percentage:.2f}\n") + f.write(f"tests_found={tests_found}\n") + + # Set report file path based on format + if self.report_format == 'html': + f.write(f"coverage_report=htmlcov/index.html\n") + elif self.report_format == 'xml': + f.write(f"coverage_report=coverage.xml\n") + elif self.report_format == 'json': + f.write(f"coverage_report=coverage.json\n") + else: + f.write(f"coverage_report=terminal_output\n") + + print("Success: GitHub Action outputs set") + + def run(self) -> int: + """ + Main execution method. + + Args: + None + + Returns: + int: The exit code + """ + print("Starting Test Coverage Check...") + print(f" Minimum coverage required: {self.minimum_coverage}%") + print(f" Test paths: {', '.join(self.test_paths)}") + print(f" Source paths: {', '.join(self.source_paths)}") + print(f" Exclude paths: {', '.join(self.exclude_paths)}") + print(f" Report format: {self.report_format}") + + # Step 1: Find test files + test_files = self.find_test_files() + + if not test_files: + print("Warning: No test files found!") + self.set_github_outputs(0.0, 0) + if self.fail_on_low_coverage: + return 1 + return 0 + + # Step 2: Run tests with coverage + success, test_output = self.run_tests_with_coverage(test_files) + if not success: + print("Error: Tests failed or could not be run") + self.set_github_outputs(0.0, len(test_files)) + return 1 + + # Step 3: Generate coverage report + coverage_percentage, report_output = self.generate_coverage_report() + + # Step 4: Display results + print(f"\nCoverage Results:") + print(f" Total Coverage: {coverage_percentage:.2f}%") + print(f" Required Coverage: {self.minimum_coverage}%") + print(f"\n{report_output}") + + # Step 5: Set GitHub outputs + self.set_github_outputs(coverage_percentage, len(test_files)) + + # Step 6: Check if coverage meets requirements + if coverage_percentage < self.minimum_coverage: + print(f"Error: Coverage {coverage_percentage:.2f}% is below required {self.minimum_coverage}%") + if self.fail_on_low_coverage: + return 1 + else: + print("Warning: Continuing despite low coverage (fail_on_low_coverage=false)") + else: + print(f"Success: Coverage {coverage_percentage:.2f}% meets requirement of {self.minimum_coverage}%") + + return 0 + + +def main() -> int: + """ + Main entry point. + + Args: + None + + Returns: + int: The exit code + """ + parser = argparse.ArgumentParser(description='Test Coverage Checker for GitHub Actions') + + parser.add_argument('--minimum-coverage', + default='80', + help='Minimum coverage percentage required (0-100)') + parser.add_argument('--test-paths', + default='tests/,test/,**/test_*.py,**/tests.py', + help='Comma-separated list of test directories/files to include') + parser.add_argument('--source-paths', + default='.', + help='Comma-separated list of source directories to analyze') + parser.add_argument('--exclude-paths', + default='tests/,test/,**/test_*.py,**/tests.py,setup.py,conftest.py', + help='Comma-separated list of paths to exclude from coverage') + parser.add_argument('--fail-on-low-coverage', + default='true', + help='Whether to fail the action if coverage is below minimum') + parser.add_argument('--report-format', + default='term', + choices=['term', 'html', 'xml', 'json'], + help='Coverage report format') + + args = parser.parse_args() + + checker = CoverageChecker(args) + exit_code = checker.run() + + return exit_code + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..d71fa45 --- /dev/null +++ b/action.yml @@ -0,0 +1,64 @@ +name: 'Python Test Coverage Checker' +description: 'Scans repository for tests, calculates test coverage, and reports coverage percentage' +author: 'RLL TreeGrowth Team' + +inputs: + minimum_coverage: + description: 'Minimum coverage percentage required (0-100)' + required: false + default: '80' + test_paths: + description: 'Comma-separated list of test directories/files to include' + required: false + default: 'tests/,test/,**/test_*.py,**/tests.py' + source_paths: + description: 'Comma-separated list of source directories to analyze' + required: false + default: '.' + exclude_paths: + description: 'Comma-separated list of paths to exclude from coverage' + required: false + default: 'tests/,test/,**/test_*.py,**/tests.py,setup.py,conftest.py' + fail_on_low_coverage: + description: 'Whether to fail the action if coverage is below minimum' + required: false + default: 'false' + report_format: + description: 'Coverage report format (term, html, xml, json)' + required: false + default: 'term' + +outputs: + coverage_percentage: + description: 'The calculated test coverage percentage' + value: ${{ steps.coverage.outputs.coverage_percentage }} + coverage_report: + description: 'Path to the coverage report file' + value: ${{ steps.coverage.outputs.coverage_report }} + tests_found: + description: 'Number of test files found' + value: ${{ steps.coverage.outputs.tests_found }} + +runs: + using: 'composite' + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + shell: bash + run: pip install -r ${{ github.action_path }}/requirements.txt + + - name: Run Test Coverage Check + id: coverage + shell: bash + run: | + python ${{ github.action_path }}/TestChecker.py \ + --minimum-coverage "${{ inputs.minimum_coverage }}" \ + --test-paths "${{ inputs.test_paths }}" \ + --source-paths "${{ inputs.source_paths }}" \ + --exclude-paths "${{ inputs.exclude_paths }}" \ + --fail-on-low-coverage "${{ inputs.fail_on_low_coverage }}" \ + --report-format "${{ inputs.report_format }}" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..53d106e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +coverage>=7.3.0 +pytest>=7.4.0 +pytest-cov>=4.1.0 +argparse>=1.4.0 \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/src/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..38bc727 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests package for TestCoverageAction \ No newline at end of file diff --git a/tests/test_checker.py b/tests/test_checker.py new file mode 100644 index 0000000..70d3ff9 --- /dev/null +++ b/tests/test_checker.py @@ -0,0 +1,514 @@ +#!/usr/bin/env python3 +""" +Tests for TestChecker.py +""" + +import unittest +import os +import sys +import tempfile +import shutil +from unittest.mock import Mock, patch, mock_open +from argparse import Namespace +import subprocess + +# Add the parent directory to the path so we can import TestChecker +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from TestChecker import CoverageChecker, main + + +class TestCoverageChecker(unittest.TestCase): + """Test the CoverageChecker class.""" + + def setUp(self): + """Set up test fixtures.""" + self.test_args = Namespace( + minimum_coverage='80', + test_paths='tests/,**/test_*.py', + source_paths='.', + exclude_paths='tests/,setup.py', + fail_on_low_coverage='true', + report_format='term' + ) + self.checker = CoverageChecker(self.test_args) + + def test_init_with_valid_args(self): + """Test initialization with valid arguments.""" + self.assertEqual(self.checker.minimum_coverage, 80.0) + self.assertEqual(self.checker.test_paths, ['tests/', '**/test_*.py']) + self.assertEqual(self.checker.source_paths, ['.']) + self.assertEqual(self.checker.exclude_paths, ['tests/', 'setup.py']) + self.assertTrue(self.checker.fail_on_low_coverage) + self.assertEqual(self.checker.report_format, 'term') + + def test_init_with_false_fail_on_low_coverage(self): + """Test initialization with fail_on_low_coverage set to false.""" + args = Namespace( + minimum_coverage='70', + test_paths='test/', + source_paths='src/', + exclude_paths='', + fail_on_low_coverage='false', + report_format='html' + ) + checker = CoverageChecker(args) + + self.assertEqual(checker.minimum_coverage, 70.0) + self.assertFalse(checker.fail_on_low_coverage) + self.assertEqual(checker.report_format, 'html') + + def test_init_handles_empty_paths(self): + """Test initialization handles empty path strings correctly.""" + args = Namespace( + minimum_coverage='90', + test_paths='tests/, , **/test_*.py,', # Empty spaces and trailing comma + source_paths='., ,src/', # Empty space + exclude_paths='', # Empty string + fail_on_low_coverage='true', + report_format='json' + ) + checker = CoverageChecker(args) + + # Should filter out empty strings + self.assertEqual(checker.test_paths, ['tests/', '**/test_*.py']) + self.assertEqual(checker.source_paths, ['.', 'src/']) + self.assertEqual(checker.exclude_paths, []) + + +class TestFileDiscovery(unittest.TestCase): + """Test file discovery methods.""" + + def setUp(self): + """Set up test fixtures with a temporary directory.""" + self.test_dir = tempfile.mkdtemp() + self.original_cwd = os.getcwd() + os.chdir(self.test_dir) + + # Create test file structure + os.makedirs('tests', exist_ok=True) + os.makedirs('src', exist_ok=True) + os.makedirs('other', exist_ok=True) + + # Create test files + open('tests/test_example.py', 'w').close() + open('tests/helper_test.py', 'w').close() + open('src/test_from_src.py', 'w').close() + open('other/tests.py', 'w').close() + open('not_a_test.py', 'w').close() + + self.test_args = Namespace( + minimum_coverage='80', + test_paths='tests/,**/test_*.py,**/tests.py', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='term' + ) + self.checker = CoverageChecker(self.test_args) + + def tearDown(self): + """Clean up test fixtures.""" + os.chdir(self.original_cwd) + shutil.rmtree(self.test_dir) + + def test_find_test_files_with_directory(self): + """Test finding test files in a directory.""" + test_files = self.checker.find_test_files() + + # Should find test files in tests/ directory and matching patterns + expected_files = { + 'tests/test_example.py', + 'tests/helper_test.py', + 'src/test_from_src.py', + 'other/tests.py' + } + + # Normalize path separators for cross-platform compatibility + found_files = {os.path.relpath(f, self.test_dir).replace(os.sep, '/') for f in test_files} + self.assertEqual(found_files, expected_files) + + def test_find_test_files_with_glob_pattern(self): + """Test finding test files using glob patterns.""" + args = Namespace( + minimum_coverage='80', + test_paths='**/test_*.py', # Only glob pattern + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='term' + ) + checker = CoverageChecker(args) + + test_files = checker.find_test_files() + + expected_files = { + 'tests/test_example.py', + 'src/test_from_src.py' # Only files matching test_*.py pattern + } + found_files = {os.path.relpath(f, self.test_dir).replace(os.sep, '/') for f in test_files} + self.assertEqual(found_files, expected_files) + + def test_find_test_files_no_matches(self): + """Test behavior when no test files are found.""" + args = Namespace( + minimum_coverage='80', + test_paths='nonexistent/', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='term' + ) + checker = CoverageChecker(args) + + test_files = checker.find_test_files() + self.assertEqual(test_files, []) + + def test_find_test_files_specific_file(self): + """Test finding a specific test file.""" + args = Namespace( + minimum_coverage='80', + test_paths='tests/test_example.py', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='term' + ) + checker = CoverageChecker(args) + + test_files = checker.find_test_files() + self.assertEqual(len(test_files), 1) + self.assertTrue(test_files[0].endswith('tests/test_example.py')) + + +class TestCoverageCommands(unittest.TestCase): + """Test coverage command building.""" + + def setUp(self): + """Set up test fixtures.""" + self.test_args = Namespace( + minimum_coverage='80', + test_paths='tests/', + source_paths='src/', + exclude_paths='tests/,setup.py', + fail_on_low_coverage='true', + report_format='term' + ) + self.checker = CoverageChecker(self.test_args) + + def test_build_coverage_command_with_test_files(self): + """Test building coverage command when test files are provided.""" + test_files = ['tests/test_example.py', 'tests/test_helper.py'] + cmd = self.checker.build_coverage_command(test_files) + + expected_parts = [ + 'coverage', 'run', + '--source=src/', + '--omit', 'tests/', + '--omit', 'setup.py', + '-m', 'pytest', + 'tests/test_example.py', + 'tests/test_helper.py' + ] + + self.assertEqual(cmd, expected_parts) + + def test_build_coverage_command_no_test_files(self): + """Test building coverage command when no test files are provided.""" + test_files = [] + cmd = self.checker.build_coverage_command(test_files) + + expected_parts = [ + 'coverage', 'run', + '--source=src/', + '--omit', 'tests/', + '--omit', 'setup.py', + '-m', 'unittest', 'discover' + ] + + self.assertEqual(cmd, expected_parts) + + def test_build_coverage_command_current_directory_source(self): + """Test building coverage command with current directory as source.""" + args = Namespace( + minimum_coverage='80', + test_paths='tests/', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='term' + ) + checker = CoverageChecker(args) + + test_files = ['tests/test_example.py'] + cmd = checker.build_coverage_command(test_files) + + self.assertIn('--source=.', cmd) + + +class TestCoverageReporting(unittest.TestCase): + """Test coverage report generation.""" + + def setUp(self): + """Set up test fixtures.""" + self.test_args = Namespace( + minimum_coverage='80', + test_paths='tests/', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='term' + ) + self.checker = CoverageChecker(self.test_args) + + @patch('subprocess.run') + @patch('os.path.exists') + @patch('builtins.open', mock_open(read_data='{"totals": {"percent_covered": 85.5}}')) + def test_generate_coverage_report_success(self, mock_exists, mock_subprocess): + """Test successful coverage report generation.""" + mock_exists.return_value = True + mock_subprocess.return_value = Mock(stdout="Coverage report", stderr="") + + coverage_percentage, report_output = self.checker.generate_coverage_report() + + self.assertEqual(coverage_percentage, 85.5) + self.assertEqual(report_output, "Coverage report") + + @patch('subprocess.run') + def test_generate_coverage_report_json_error(self, mock_subprocess): + """Test coverage report generation when JSON generation fails.""" + mock_subprocess.side_effect = subprocess.CalledProcessError(1, 'coverage') + + coverage_percentage, report_output = self.checker.generate_coverage_report() + + self.assertEqual(coverage_percentage, 0.0) + self.assertEqual(report_output, "") + + @patch('subprocess.run') + @patch('os.path.exists') + def test_generate_coverage_report_missing_file(self, mock_exists, mock_subprocess): + """Test coverage report generation when JSON file is missing.""" + mock_exists.return_value = False + mock_subprocess.return_value = Mock() + + coverage_percentage, report_output = self.checker.generate_coverage_report() + + self.assertEqual(coverage_percentage, 0.0) + self.assertEqual(report_output, "") + + @patch('subprocess.run') + @patch('os.path.exists') + @patch('builtins.open', mock_open(read_data='invalid json')) + def test_generate_coverage_report_invalid_json(self, mock_exists, mock_subprocess): + """Test coverage report generation with invalid JSON.""" + mock_exists.return_value = True + mock_subprocess.return_value = Mock() + + coverage_percentage, report_output = self.checker.generate_coverage_report() + + self.assertEqual(coverage_percentage, 0.0) + + +class TestGitHubOutputs(unittest.TestCase): + """Test GitHub Actions output handling.""" + + def setUp(self): + """Set up test fixtures.""" + self.test_args = Namespace( + minimum_coverage='80', + test_paths='tests/', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='term' + ) + self.checker = CoverageChecker(self.test_args) + + @patch.dict(os.environ, {'GITHUB_OUTPUT': '/tmp/github_output'}) + @patch('builtins.open', mock_open()) + def test_set_github_outputs_terminal(self): + """Test setting GitHub outputs for terminal format.""" + with patch('builtins.open', mock_open()) as mock_file: + self.checker.set_github_outputs(85.5, 5) + + mock_file.assert_called_once_with('/tmp/github_output', 'a') + handle = mock_file() + + expected_calls = [ + unittest.mock.call.write('coverage_percentage=85.50\n'), + unittest.mock.call.write('tests_found=5\n'), + unittest.mock.call.write('coverage_report=terminal_output\n') + ] + + handle.write.assert_has_calls(expected_calls) + + @patch.dict(os.environ, {'GITHUB_OUTPUT': '/tmp/github_output'}) + @patch('builtins.open', mock_open()) + def test_set_github_outputs_html(self): + """Test setting GitHub outputs for HTML format.""" + args = Namespace( + minimum_coverage='80', + test_paths='tests/', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='html' + ) + checker = CoverageChecker(args) + + with patch('builtins.open', mock_open()) as mock_file: + checker.set_github_outputs(90.0, 3) + + handle = mock_file() + handle.write.assert_any_call('coverage_report=htmlcov/index.html\n') + + def test_set_github_outputs_no_env(self): + """Test setting GitHub outputs when GITHUB_OUTPUT is not set.""" + # Should not raise an exception + self.checker.set_github_outputs(75.0, 2) + + +class TestMainWorkflow(unittest.TestCase): + """Test the main workflow and integration.""" + + @patch('TestChecker.CoverageChecker.find_test_files') + @patch('TestChecker.CoverageChecker.set_github_outputs') + def test_run_no_test_files_fail_on_low_coverage(self, mock_set_outputs, mock_find_files): + """Test run method when no test files found and fail_on_low_coverage is True.""" + mock_find_files.return_value = [] + + args = Namespace( + minimum_coverage='80', + test_paths='tests/', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='term' + ) + checker = CoverageChecker(args) + + exit_code = checker.run() + + self.assertEqual(exit_code, 1) + mock_set_outputs.assert_called_once_with(0.0, 0) + + @patch('TestChecker.CoverageChecker.find_test_files') + @patch('TestChecker.CoverageChecker.set_github_outputs') + def test_run_no_test_files_continue_on_low_coverage(self, mock_set_outputs, mock_find_files): + """Test run method when no test files found and fail_on_low_coverage is False.""" + mock_find_files.return_value = [] + + args = Namespace( + minimum_coverage='80', + test_paths='tests/', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='false', + report_format='term' + ) + checker = CoverageChecker(args) + + exit_code = checker.run() + + self.assertEqual(exit_code, 0) + mock_set_outputs.assert_called_once_with(0.0, 0) + + @patch('TestChecker.CoverageChecker.find_test_files') + @patch('TestChecker.CoverageChecker.run_tests_with_coverage') + @patch('TestChecker.CoverageChecker.generate_coverage_report') + @patch('TestChecker.CoverageChecker.set_github_outputs') + def test_run_successful_coverage(self, mock_set_outputs, mock_generate_report, mock_run_tests, mock_find_files): + """Test successful run with coverage above threshold.""" + mock_find_files.return_value = ['test_example.py'] + mock_run_tests.return_value = (True, "Tests passed") + mock_generate_report.return_value = (85.0, "Coverage report") + + args = Namespace( + minimum_coverage='80', + test_paths='tests/', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='term' + ) + checker = CoverageChecker(args) + + exit_code = checker.run() + + self.assertEqual(exit_code, 0) + mock_set_outputs.assert_called_once_with(85.0, 1) + + @patch('TestChecker.CoverageChecker.find_test_files') + @patch('TestChecker.CoverageChecker.run_tests_with_coverage') + @patch('TestChecker.CoverageChecker.generate_coverage_report') + def test_run_low_coverage_fail(self, mock_generate_report, mock_run_tests, mock_find_files): + """Test run with coverage below threshold and fail_on_low_coverage=True.""" + mock_find_files.return_value = ['test_example.py'] + mock_run_tests.return_value = (True, "Tests passed") + mock_generate_report.return_value = (60.0, "Coverage report") + + args = Namespace( + minimum_coverage='80', + test_paths='tests/', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='term' + ) + checker = CoverageChecker(args) + + exit_code = checker.run() + + self.assertEqual(exit_code, 1) + + @patch('TestChecker.CoverageChecker.find_test_files') + @patch('TestChecker.CoverageChecker.run_tests_with_coverage') + def test_run_test_execution_failure(self, mock_run_tests, mock_find_files): + """Test run when test execution fails.""" + mock_find_files.return_value = ['test_example.py'] + mock_run_tests.return_value = (False, "Test execution failed") + + args = Namespace( + minimum_coverage='80', + test_paths='tests/', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='term' + ) + checker = CoverageChecker(args) + + exit_code = checker.run() + + self.assertEqual(exit_code, 1) + + +class TestMainFunction(unittest.TestCase): + """Test the main function and argument parsing.""" + + @patch('sys.argv', ['TestChecker.py', '--minimum-coverage', '90']) + @patch('TestChecker.CoverageChecker.run') + def test_main_with_custom_coverage(self, mock_run): + """Test main function with custom minimum coverage.""" + mock_run.return_value = 0 + + exit_code = main() + + self.assertEqual(exit_code, 0) + mock_run.assert_called_once() + + @patch('sys.argv', ['TestChecker.py']) + @patch('TestChecker.CoverageChecker.run') + def test_main_with_defaults(self, mock_run): + """Test main function with default arguments.""" + mock_run.return_value = 0 + + exit_code = main() + + self.assertEqual(exit_code, 0) + mock_run.assert_called_once() + + +if __name__ == '__main__': + # Set up test discovery and run tests + unittest.main(verbosity=2) \ No newline at end of file diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py new file mode 100644 index 0000000..febcf21 --- /dev/null +++ b/tests/test_edge_cases.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +""" +Edge case tests for TestChecker.py +""" + +import unittest +import os +import sys +import subprocess +from unittest.mock import Mock, patch, mock_open +from argparse import Namespace + +# Add the parent directory to the path so we can import TestChecker +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from TestChecker import CoverageChecker + + +class TestEdgeCases(unittest.TestCase): + """Test edge cases and error scenarios.""" + + def test_invalid_minimum_coverage_string(self): + """Test handling of invalid minimum coverage values.""" + args = Namespace( + minimum_coverage='invalid', + test_paths='tests/', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='term' + ) + + with self.assertRaises(ValueError): + CoverageChecker(args) + + def test_negative_minimum_coverage(self): + """Test handling of negative minimum coverage.""" + args = Namespace( + minimum_coverage='-10', + test_paths='tests/', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='term' + ) + + checker = CoverageChecker(args) + self.assertEqual(checker.minimum_coverage, -10.0) + +class TestSubprocessErrors(unittest.TestCase): + """Test subprocess execution errors.""" + + def setUp(self): + """Set up test fixtures.""" + self.test_args = Namespace( + minimum_coverage='80', + test_paths='tests/', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='term' + ) + self.checker = CoverageChecker(self.test_args) + + @patch('subprocess.run') + def test_run_tests_subprocess_error(self, mock_subprocess): + """Test handling of subprocess.CalledProcessError during test execution.""" + mock_subprocess.side_effect = subprocess.CalledProcessError(1, 'coverage') + + success, output = self.checker.run_tests_with_coverage(['test_file.py']) + + self.assertFalse(success) + self.assertIn("Error running tests", output) + + @patch('subprocess.run') + def test_run_tests_file_not_found(self, mock_subprocess): + """Test handling of FileNotFoundError when coverage tool is missing.""" + mock_subprocess.side_effect = FileNotFoundError("coverage command not found") + + success, output = self.checker.run_tests_with_coverage(['test_file.py']) + + self.assertFalse(success) + self.assertEqual(output, "Coverage tool not found") + + +class TestReportFormats(unittest.TestCase): + """Test different report format handling.""" + + @patch('subprocess.run') + @patch('os.path.exists') + @patch('builtins.open', mock_open(read_data='{"totals": {"percent_covered": 85.0}}')) + def test_html_report_format(self, mock_exists, mock_subprocess): + """Test HTML report format generation.""" + args = Namespace( + minimum_coverage='80', + test_paths='tests/', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='html' + ) + checker = CoverageChecker(args) + + mock_exists.return_value = True + mock_subprocess.return_value = Mock(stdout="HTML report generated") + + coverage_percentage, report_output = checker.generate_coverage_report() + + self.assertEqual(coverage_percentage, 85.0) + self.assertEqual(report_output, "HTML report generated") + + # Check that the correct command was called for HTML + calls = mock_subprocess.call_args_list + html_call = next((call for call in calls if 'html' in str(call)), None) + self.assertIsNotNone(html_call) + + @patch('subprocess.run') + @patch('os.path.exists') + @patch('builtins.open', mock_open(read_data='{"totals": {"percent_covered": 72.5}}')) + def test_xml_report_format(self, mock_exists, mock_subprocess): + """Test XML report format generation.""" + args = Namespace( + minimum_coverage='80', + test_paths='tests/', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='xml' + ) + checker = CoverageChecker(args) + + mock_exists.return_value = True + mock_subprocess.return_value = Mock(stdout="XML report generated") + + coverage_percentage, report_output = checker.generate_coverage_report() + + self.assertEqual(coverage_percentage, 72.5) + self.assertEqual(report_output, "XML report generated") + + +class TestComplexFileStructures(unittest.TestCase): + """Test complex file and directory structures.""" + + def test_deeply_nested_test_files(self): + """Test finding test files in deeply nested directories.""" + args = Namespace( + minimum_coverage='80', + test_paths='**/test_*.py', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='term' + ) + checker = CoverageChecker(args) + + # Mock glob.glob to simulate deeply nested files + with patch('glob.glob') as mock_glob: + mock_glob.return_value = [ + 'level1/level2/level3/test_deep.py', + 'another/path/test_nested.py' + ] + + # Mock os.path.isfile to return True for our test files + with patch('os.path.isfile', return_value=True): + test_files = checker.find_test_files() + + # Convert to relative paths for comparison + found_files = {os.path.relpath(f, checker.workspace_path).replace(os.sep, '/') for f in test_files} + + self.assertEqual(len(test_files), 2) + self.assertIn('level1/level2/level3/test_deep.py', found_files) + self.assertIn('another/path/test_nested.py', found_files) + + def test_mixed_file_and_directory_paths(self): + """Test handling mixed file and directory paths in test_paths.""" + args = Namespace( + minimum_coverage='80', + test_paths='tests/,specific/test_file.py,**/test_*.py', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='term' + ) + checker = CoverageChecker(args) + + with patch('os.path.isdir') as mock_isdir, \ + patch('os.path.isfile') as mock_isfile, \ + patch('os.walk') as mock_walk, \ + patch('glob.glob') as mock_glob: + + # Setup mocks + mock_isdir.side_effect = lambda path: path.endswith('tests/') + mock_isfile.side_effect = lambda path: path.endswith('.py') + mock_walk.return_value = [ + ('tests', [], ['test_from_dir.py', 'not_a_test_file.py']) + ] + mock_glob.return_value = ['other/test_glob.py'] + + test_files = checker.find_test_files() + + # Convert absolute paths to relative paths for comparison + found_files = {os.path.relpath(f, checker.workspace_path).replace(os.sep, '/') for f in test_files} + + # Should find files from directory walk, specific file, and glob + expected_files = { + 'tests/test_from_dir.py', + 'specific/test_file.py', + 'other/test_glob.py' + } + self.assertEqual(found_files, expected_files) + + +class TestErrorRecovery(unittest.TestCase): + """Test error recovery and graceful degradation.""" + + def test_partial_coverage_data_missing(self): + """Test handling when coverage JSON has missing data.""" + args = Namespace( + minimum_coverage='80', + test_paths='tests/', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='term' + ) + checker = CoverageChecker(args) + + # Test with missing 'totals' key + with patch('subprocess.run'), \ + patch('os.path.exists', return_value=True), \ + patch('builtins.open', mock_open(read_data='{"summary": "no totals key"}')): + + coverage_percentage, report_output = checker.generate_coverage_report() + + self.assertEqual(coverage_percentage, 0.0) + + # Test with missing 'percent_covered' key + with patch('subprocess.run'), \ + patch('os.path.exists', return_value=True), \ + patch('builtins.open', mock_open(read_data='{"totals": {"lines_covered": 100}}')): + + coverage_percentage, report_output = checker.generate_coverage_report() + + self.assertEqual(coverage_percentage, 0.0) + + @patch('subprocess.run') + def test_report_generation_failure_recovery(self, mock_subprocess): + """Test recovery when detailed report generation fails.""" + args = Namespace( + minimum_coverage='80', + test_paths='tests/', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='term' + ) + checker = CoverageChecker(args) + + # Mock successful JSON generation but failed detailed report + def side_effect(*args, **kwargs): + if 'json' in args[0]: + return Mock() # Success for JSON + else: + raise subprocess.CalledProcessError(1, 'coverage report') + + mock_subprocess.side_effect = side_effect + + with patch('os.path.exists', return_value=True), \ + patch('builtins.open', mock_open(read_data='{"totals": {"percent_covered": 75.0}}')): + + coverage_percentage, report_output = checker.generate_coverage_report() + + self.assertEqual(coverage_percentage, 75.0) + self.assertEqual(report_output, "Could not generate detailed report") + + +class TestGitHubActionsIntegration(unittest.TestCase): + """Test GitHub Actions specific functionality.""" + + @patch.dict(os.environ, {}, clear=True) + def test_github_outputs_missing_env_var(self): + """Test behavior when GITHUB_OUTPUT environment variable is missing.""" + args = Namespace( + minimum_coverage='80', + test_paths='tests/', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='term' + ) + checker = CoverageChecker(args) + + # Should not raise an exception + checker.set_github_outputs(85.0, 5) + + @patch.dict(os.environ, {'GITHUB_OUTPUT': '/tmp/readonly_file'}) + @patch('builtins.open') + def test_github_outputs_file_write_error(self, mock_open_func): + """Test handling of file write errors for GitHub outputs.""" + mock_open_func.side_effect = PermissionError("Permission denied") + + args = Namespace( + minimum_coverage='80', + test_paths='tests/', + source_paths='.', + exclude_paths='', + fail_on_low_coverage='true', + report_format='term' + ) + checker = CoverageChecker(args) + + # Should not raise an exception, just print error + checker.set_github_outputs(85.0, 5) + + +if __name__ == '__main__': + unittest.main(verbosity=2) \ No newline at end of file