"""Unit tests for devx.tools.check_test_coverage.""" from pathlib import Path from unittest.mock import MagicMock, patch from devx.tools.check_test_coverage import ( BUILTIN_RULES, DEFAULT_SKIP_EXTENSIONS, DEFAULT_TEST_INDICATORS, _changed_files, _find_missing_tests, _is_test_file, _load_rules, _resolve_test_path, _should_skip_file, main, ) class TestIsTestFile: def test_tests_dir(self) -> None: assert _is_test_file("tests/unit/test_foo.py", DEFAULT_TEST_INDICATORS) is True def test_test_prefix(self) -> None: assert _is_test_file("src/test_foo.py", DEFAULT_TEST_INDICATORS) is True def test_test_suffix(self) -> None: assert _is_test_file("src/foo_test.py", DEFAULT_TEST_INDICATORS) is True def test_non_test_file(self) -> None: assert _is_test_file("src/foo.py", DEFAULT_TEST_INDICATORS) is False class TestShouldSkipFile: def test_skips_dotfiles(self) -> None: assert _should_skip_file(".gitignore", [], DEFAULT_SKIP_EXTENSIONS) is True def test_skips_markdown(self) -> None: assert _should_skip_file("README.md", [], DEFAULT_SKIP_EXTENSIONS) is True def test_skips_yaml(self) -> None: assert _should_skip_file("config.yml", [], DEFAULT_SKIP_EXTENSIONS) is True def test_does_not_skip_python(self) -> None: assert _should_skip_file("src/foo.py", [], DEFAULT_SKIP_EXTENSIONS) is False def test_skips_by_pattern(self) -> None: assert _should_skip_file("src/__init__.py", ["__init__.py"], DEFAULT_SKIP_EXTENSIONS) is True def test_skips_by_glob_pattern(self) -> None: assert _should_skip_file("src/config.py", ["config.py"], DEFAULT_SKIP_EXTENSIONS) is True class TestResolveTestPath: def test_resolves_name(self, tmp_path: Path) -> None: result = _resolve_test_path("tests/unit/test_{name}", "src/foo.py", tmp_path) assert result == tmp_path / "tests" / "unit" / "test_foo" def test_resolves_module(self, tmp_path: Path) -> None: result = _resolve_test_path("tests/unit/test_{module}_{name}", "src/pkg/foo.py", tmp_path) assert result == tmp_path / "tests" / "unit" / "test_pkg_foo" def test_resolves_package_prefix(self, tmp_path: Path) -> None: result = _resolve_test_path( "tests/unit/test_{package_prefix}_{name}", "scripts/utils/secrets.py", tmp_path, ) assert result == tmp_path / "tests" / "unit" / "test_utils_secrets" def test_normalizes_hyphens(self, tmp_path: Path) -> None: result = _resolve_test_path("tests/test_{name}", "scripts/my-script.py", tmp_path) assert result == tmp_path / "tests" / "test_my_script" class TestFindMissingTests: def test_finds_missing_test(self, tmp_path: Path) -> None: files = ["scripts/foo.py"] rules = BUILTIN_RULES missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS) assert "scripts/foo.py" in missing def test_no_missing_when_test_exists(self, tmp_path: Path) -> None: (tmp_path / "scripts" / "tests").mkdir(parents=True) (tmp_path / "scripts" / "tests" / "test_foo.py").write_text("") files = ["scripts/foo.py"] rules = BUILTIN_RULES missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS) assert missing == {} def test_skips_test_files(self, tmp_path: Path) -> None: files = ["tests/unit/test_foo.py"] rules = BUILTIN_RULES missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS) assert missing == {} def test_skips_non_python_files(self, tmp_path: Path) -> None: files = ["README.md", "config.yml"] rules = BUILTIN_RULES missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS) assert missing == {} def test_no_rule_no_requirement(self, tmp_path: Path) -> None: files = ["unknown_type.xyz"] rules = BUILTIN_RULES missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS) assert missing == {} class TestChangedFiles: @patch("devx.tools.check_test_coverage.subprocess.run") def test_staged_only(self, mock_run: MagicMock, tmp_path: Path) -> None: mock_run.return_value = MagicMock(stdout="file1.py\nfile2.py\n", returncode=0) files = _changed_files(staged_only=True, repo_root=tmp_path) assert files == ["file1.py", "file2.py"] cmd = mock_run.call_args.args[0] assert "--cached" in cmd @patch("devx.tools.check_test_coverage.subprocess.run") def test_ci_mode(self, mock_run: MagicMock, tmp_path: Path) -> None: mock_run.return_value = MagicMock(stdout="file1.py\n", returncode=0) files = _changed_files(staged_only=False, repo_root=tmp_path) assert files == ["file1.py"] cmd = mock_run.call_args.args[0] assert "origin/master...HEAD" in cmd @patch("devx.tools.check_test_coverage.subprocess.run") def test_fallback_to_staged(self, mock_run: MagicMock, tmp_path: Path) -> None: # First call fails, second succeeds mock_run.side_effect = [ MagicMock(stdout="", returncode=1), MagicMock(stdout="file1.py\n", returncode=0), ] files = _changed_files(staged_only=False, repo_root=tmp_path) assert files == ["file1.py"] class TestLoadRules: def test_defaults_when_no_config(self) -> None: with patch("devx.tools.check_test_coverage._load_pyproject_devx", return_value={}): rules, skip, indicators, skip_ext = _load_rules() assert rules == BUILTIN_RULES assert skip == [] assert indicators == DEFAULT_TEST_INDICATORS assert skip_ext == DEFAULT_SKIP_EXTENSIONS def test_custom_rules(self) -> None: cfg = { "check_test_coverage": { "rules": [ { "source_pattern": "lib/*.py", "test_paths": ["tests/test_{name}"], "description": "Missing: tests/test_{name}", } ], "skip_patterns": ["__init__.py"], } } with patch("devx.tools.check_test_coverage._load_pyproject_devx", return_value=cfg): rules, skip, indicators, skip_ext = _load_rules() assert len(rules) == 1 assert rules[0]["source_pattern"] == "lib/*.py" assert "__init__.py" in skip def test_returns_defaults_when_cfg_not_dict(self) -> None: with patch( "devx.tools.check_test_coverage._load_pyproject_devx", return_value={"check_test_coverage": "not a dict"} ): rules, skip, indicators, skip_ext = _load_rules() assert rules == BUILTIN_RULES assert skip == [] def test_skip_extensions_not_list_returns_default(self) -> None: cfg = {"check_test_coverage": {"skip_extensions": "not a list"}} with patch("devx.tools.check_test_coverage._load_pyproject_devx", return_value=cfg): _, _, _, skip_ext = _load_rules() assert skip_ext == DEFAULT_SKIP_EXTENSIONS def test_test_paths_not_list_skips_rule(self, tmp_path: Path) -> None: files = ["scripts/foo.py"] rules = [ { "source_pattern": "scripts/*.py", "test_paths": "not a list", "description": "Missing test", } ] missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS) # Rule matches but test_paths is not a list, so it's skipped — no missing assert missing == {} class TestMain: def test_no_changed_files(self, tmp_path: Path) -> None: with ( patch("devx.tools.check_test_coverage._changed_files", return_value=[]), patch( "devx.tools.check_test_coverage._load_rules", return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS), ), patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path), ): assert main([]) == 0 def test_all_have_tests(self, tmp_path: Path) -> None: (tmp_path / "scripts" / "tests").mkdir(parents=True) (tmp_path / "scripts" / "tests" / "test_foo.py").write_text("") with ( patch("devx.tools.check_test_coverage._changed_files", return_value=["scripts/foo.py"]), patch( "devx.tools.check_test_coverage._load_rules", return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS), ), patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path), ): assert main([]) == 0 def test_missing_test_returns_1(self, tmp_path: Path) -> None: with ( patch("devx.tools.check_test_coverage._changed_files", return_value=["scripts/foo.py"]), patch( "devx.tools.check_test_coverage._load_rules", return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS), ), patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path), ): assert main([]) == 1 def test_warn_only_returns_0(self, tmp_path: Path) -> None: with ( patch("devx.tools.check_test_coverage._changed_files", return_value=["scripts/foo.py"]), patch( "devx.tools.check_test_coverage._load_rules", return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS), ), patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path), ): assert main(["--warn-only"]) == 0