Testing Git hooks before sharing them Jump to heading
A hook change reaches every developer the next time they install dependencies. If it is broken, it blocks everyone’s commits at once — or, worse, passes silently while doing nothing. Hooks are code, but they are rarely tested like code: someone edits .husky/pre-commit, commits once to see it work on their machine, and pushes. The failure appears on a colleague’s Mac with a different shell, or on a commit with a file name containing a space, or on a branch with no upstream. Testing hooks does not need special tools. A hook is a script with known inputs — arguments, standard input, the repository state — so it can be exercised in a throwaway repository with exactly those inputs. This page builds a small test setup that runs locally and in CI, within local hook configuration with Husky.
When to use this approach Jump to heading
- Your repository’s hooks contain logic beyond a single tool call — branching, parsing, conditions.
- A hook change once broke everyone’s commits, or silently stopped checking anything.
- Contributors use different operating systems or shells.
- You maintain server-side hooks too; the same approach is applied there in testing server-side hooks safely.
Step 1 — Move logic out of the hook file into a script Jump to heading
Keep the files in .husky/ as one-line wrappers that call scripts in a testable location. The wrapper is trivial; the script receives explicit arguments and can be run without Git calling it.
# .husky/commit-msg — wrapper only
sh scripts/hooks/check-commit-msg.sh "$1" #!/bin/sh
# scripts/hooks/check-commit-msg.sh <message-file>
set -eu
msg=$(sed '/^#/d' "$1" | head -1)
case "$msg" in
Merge*|Revert*|fixup!*|squash!*) exit 0 ;;
esac
echo "$msg" | grep -Eq '^(feat|fix|chore|docs|refactor|test|perf)(\([a-z0-9-]+\))?!?: .{3,}' || {
echo "commit message must follow 'type(scope): summary' — got: $msg" >&2
exit 1
} Step 2 — Test the script directly with fixture inputs Jump to heading
With logic in a script, most tests need no Git at all: write fixture message files and check the exit codes.
#!/bin/sh
# scripts/hooks/test-check-commit-msg.sh
set -u
pass=0; fail=0
check() { # check <expected-exit> <message>
f=$(mktemp); printf '%s\n' "$2" > "$f"
sh scripts/hooks/check-commit-msg.sh "$f" >/dev/null 2>&1; got=$?
if [ "$got" -eq "$1" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: expected $1, got $got for: $2"; fi
rm -f "$f"
}
check 0 "feat(export): schedule CSV exports"
check 0 "fix!: drop legacy discount field"
check 0 "Merge branch 'main' into feature/x"
check 1 "updated stuff"
check 1 "feat: x"
check 1 "FEAT(export): uppercase type"
echo "$pass passed, $fail failed"; [ "$fail" -eq 0 ] sh scripts/hooks/test-check-commit-msg.sh Step 3 — Test the hook end to end in a throwaway repository Jump to heading
Some behaviour only appears when Git calls the hook: the working directory, environment variables, staged state. Create a scratch repository, install the hooks there, and drive real commits.
#!/bin/sh
# scripts/hooks/test-e2e.sh — exercise hooks through real git commands
set -eu
root=$(pwd); tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
git -C "$tmp" init -q
git -C "$tmp" config user.email [email protected]; git -C "$tmp" config user.name test
cp -r "$root/.husky" "$root/scripts" "$tmp/"
git -C "$tmp" config core.hooksPath .husky
mkdir -p "$tmp/.husky/_" 2>/dev/null || true
# a commit with a good message must succeed
git -C "$tmp" commit -q --allow-empty -m "chore: e2e good message"
# a commit with a bad message must fail
if git -C "$tmp" commit -q --allow-empty -m "bad message" 2>/dev/null; then
echo "FAIL: bad message was accepted"; exit 1
fi
# file names with spaces must not break pre-commit
printf 'x\n' > "$tmp/a file with spaces.md"; git -C "$tmp" add -A
git -C "$tmp" commit -q -m "docs: file with spaces" && echo "e2e ok" Step 4 — Check portability Jump to heading
Hooks run under /bin/sh, which is dash on Debian and Ubuntu, bash in POSIX mode on macOS, and Git for Windows’ bundled shell on Windows. Bash-only syntax that works on one machine fails on another. Lint hook scripts with ShellCheck in POSIX mode.
shellcheck --shell=sh .husky/* scripts/hooks/*.sh
# Run the tests under dash to catch bashisms
dash scripts/hooks/test-check-commit-msg.sh Common traps include [[ ]], arrays, echo -e, process substitution and grep -P, none of which are POSIX.
Step 5 — Run the tests in CI on every hook change Jump to heading
Run hook tests whenever hook files or scripts change, on each operating system your team uses.
on:
pull_request:
paths: [".husky/**", "scripts/hooks/**"]
jobs:
hook-tests:
strategy: { matrix: { os: [ubuntu-latest, macos-latest, windows-latest] } }
runs-on: ${{ matrix.os }}
defaults: { run: { shell: sh } }
steps:
- uses: actions/checkout@v4
- run: sh scripts/hooks/test-check-commit-msg.sh
- run: sh scripts/hooks/test-e2e.sh Step 6 — Roll out risky hook changes gradually Jump to heading
For a hook change that could block commits widely — a new mandatory check — ship it in warn mode first: print the problem and exit 0, controlled by an environment variable. Switch to enforcing after a week of clean warnings, the same pattern as rolling out signature enforcement in warn-only mode.
# in the hook script
mode=${HOOK_MODE:-warn}
if ! check_passes; then
echo "hook check failed: ..." >&2
[ "$mode" = enforce ] && exit 1
fi
exit 0 Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Is there a framework for testing hooks? Jump to heading
Shell testing frameworks such as Bats work well and give nicer output than hand-written assertions. The plain-shell approach above has no dependencies, which suits small hook suites.
How do I test pre-push hooks, which read standard input? Jump to heading
Pipe the ref lines into the script exactly as Git would: printf 'refs/heads/main %s refs/heads/main %s\n' "$local" "$remote" | sh scripts/hooks/pre-push.sh origin url. The format is described in finding the new commits in a pre-push hook.
Should hook tests run on every pull request? Jump to heading
Only when hook files change, using a path filter. Running them on every pull request costs little, but tying them to hook changes keeps the signal clear.
Related Jump to heading
- Local Hook Configuration with Husky — the parent topic.
- Writing a Custom Local pre-commit Hook — the same discipline in the pre-commit framework.
- Debugging lint-staged Failures — what happens when untested hooks reach the team.
- Enforcing Issue Keys in Commit Messages — a message hook worth testing this way.