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.

Medium Python 3.9+ Aug 9, 2026 Git + Python 15 views 0 copies

Python code

24 lines
Python 3.9+
import 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

stdout
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

  1. Use `subprocess.Popen` with `communicate()` if you need streaming output instead of capture.
  2. 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

Run this sample

Open the browser IDE to tweak the example and see results without installing anything.

Open editor

More from Git + Python

Related tutorials and quizzes for this topic.