Testing¶
Running the suite¶
python -m pytest -q # quick run
python -m pytest --cov --cov-report=term-missing # what CI runs
python -m pytest tests/test_ownership.py -v # one file
python -m pytest -k ownership # by name
Configuration lives in pyproject.toml:
[tool.pytest.ini_options]
addopts = "-ra"
testpaths = ["tests"]
[tool.coverage.run]
branch = true
source = ["gitintel"]
Coverage is branch-based over the gitintel package, with covered files skipped in the report.
There is no minimum coverage gate — CI fails on test failures, not on coverage numbers.
Current suite¶
| File | Covers | Status |
|---|---|---|
test_cli.py |
--version, python -m gitintel --help via Typer's CliRunner |
2 tests |
test_commits.py |
get_commits() against the checked-out repository |
1 test |
test_contributors.py |
analyze_contributors() |
1 test |
test_diff.py |
per-commit FileChange extraction |
1 test |
test_ownership.py |
analyze_ownership() |
1 test |
test_hotspots.py |
— | empty |
test_repository.py |
— | empty |
The two empty files are deliberate placeholders and are the easiest useful contribution to the
project: calculate_hotspots() is a pure function and needs no repository at all.
Two kinds of test¶
Pure domain tests (preferred)¶
Analysis functions take lists of dataclasses, so they can be tested without touching Git:
from datetime import datetime
from gitintel.analysis.hotspots import calculate_hotspots
from gitintel.models import Commit, FileChange
def test_no_owner_adds_risk():
commits = [
Commit(
hash="a" * 40,
author="Alice",
email="alice@example.com",
message="change",
date=datetime(2026, 1, 1),
changes=[FileChange(path="src/app.py", additions=10, deletions=2)],
)
]
changes = {
"src/app.py": {
"modifications": 25,
"lines_changed": 1200,
"contributors": 3,
}
}
hotspots = calculate_hotspots(commits, changes, ownership_map={})
assert hotspots[0].file_path == "src/app.py"
assert "No clear owner" in hotspots[0].reasons
Build the exact inputs a rule needs and assert on the score and reasons. Threshold values are documented in Metrics and scoring.
Repository-backed tests¶
The existing Git-layer tests open "." — the GitIntel checkout itself — and assert on shapes
rather than values:
Never assert on real commit counts, author names, or hashes: they change with every merge and CI checks out a different history depth than your machine. For value-level assertions, create a throwaway repository instead:
import pygit2
import pytest
@pytest.fixture
def repository(tmp_path):
repo = pygit2.init_repository(tmp_path / "repo")
signature = pygit2.Signature("Alice", "alice@example.com")
(tmp_path / "repo" / "app.py").write_text("print('hi')\n")
repo.index.add("app.py")
repo.index.write()
repo.create_commit(
"HEAD", signature, signature, "initial", repo.index.write_tree(), []
)
return repo
CI clones are shallow-ish
actions/checkout@v4 fetches a single commit by default, so repository-backed tests must
tolerate a history of length one. That is why the existing tests assert > 0 rather than a
specific count.
Writing a new test¶
- Put it in the file matching the module under test; create one if the module is new.
- Name it
test_<behaviour>, nottest_<function>. - Prefer pure inputs; fall back to
tmp_pathrepositories; use the checkout only for smoke tests. - Assert on behaviour a user could observe — a score, a reason string, an exit code — not on internal call order.
- For CLI behaviour, use
CliRunnerand assert onresult.exit_codeandresult.stdout.
Before opening a pull request¶
python -m ruff check src tests
python -m pytest --cov --cov-report=term-missing
python -m build --sdist --wheel --outdir dist
python -m twine check dist/*
Those four commands are exactly what CI runs on Python 3.11 through 3.14.