How to Mock Git Pre-commit Hooks (black and ruff) in Python
Mock subprocess to test black and ruff pre-commit commands without actually running them, verifying exit codes.
Python code
24 linesimport sys
import subprocess
from unittest.mock import patch
def run_hook(command: list[str]) -> int:
with patch("subprocess.run") as mock_run:
mock_run.return_value.returncode = 0
mock_run.return_value.stdout = f"Mocked: {' '.join(command)}"
result = subprocess.run(command, capture_output=True, text=True)
print(result.stdout)
return result.returncode
if __name__ == "__main__":
commands = [
["black", "--check", "."],
["ruff", "check", "."],
]
exit_codes = []
for cmd in commands:
exit_codes.append(run_hook(cmd))
print(f"All pre-commit hooks passed: {all(code == 0 for code in exit_codes)}")
sys.exit(0)
Output
Mocked: black --check .
Mocked: ruff check .
All pre-commit hooks passed: True
How it works
The patch("subprocess.run") context manager replaces subprocess.run with a MagicMock, preventing real commands from executing. Setting mock_run.return_value.returncode = 0 simulates a successful hook exit; stdout is faked to show the command that would run. Each hook is exercised in isolation, and the final all() aggregates their return codes. This pattern is ideal for CI pipelines where you want to test hook behavior without needing black/ruff installed or hitting filesystem changes.
Common mistakes
- Forgetting to patch `subprocess.run` before the function call — you must patch at the call site, not import time.
- Not setting `mock_run.return_value.stdout` — you get a MagicMock string instead of readable output.
- Assuming real hooks run — this mocks them, so you test logic, not the actual linters.
Variations
- Use `subprocess.Popen` with `communicate()` if you need streaming output instead of capture.
- Add `side_effect` to mock different return codes for each command (e.g., one fails to test error handling).
Real-world use cases
- Simulating pre-commit hook execution in CI to validate hook scripts without installing linters.
- Testing a pre-commit wrapper that runs black, ruff, or mypy before verifying exit-code logic.
- Debugging hook configuration in local dev by mocking subprocess calls to avoid slow runs.
Sponsored
More from Git + Python
- Amend Last Commit Message in Python easy
- Bisect Good Bad Automation Script in Python easy
- Build a Simple Log Graph in Python easy
- Bump Semantic Version Git Tag in Python easy
- Count Unique Contributors from Git Shortlog in Python easy
- Create a Mock GitHub Release API in Python for Testing gh CLI easy
Keep learning
Related tutorials and quizzes for this topic.