Public Access
Post-merge / detect-type (push) Successful in 37s
Post-merge / validate-commit-msg (push) Successful in 42s
Post-merge / sync-wiki (push) Successful in 52s
Post-merge / vikunja (push) Successful in 49s
Post-merge / release (push) Successful in 59s
Post-merge / badges (push) Successful in 1m0s
Post-merge / configure-repo (push) Successful in 49s
240 lines
9.9 KiB
Python
240 lines
9.9 KiB
Python
"""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
|