Git Hooks for Running Tests Before Push

8 min read

CI catches broken tests, but only after your push. By then, the bad commit is on the remote, the team's CI minutes are spent, and your build is the broken one in the dashboard. Git hooks are scripts Git runs automatically at specific points in your workflow — and the pre-push hook is exactly the gate you want before your laptop ships code to GitHub. This lesson covers raw Git hooks, the team-friendly Husky toolkit that makes them shareable, and the pragmatic question of "what's worth running locally vs leaving to CI."

What hooks are

A Git hook is a shell script that lives in .git/hooks/ and runs automatically when a specific Git event happens. The events have fixed names:

  • pre-commit — runs before git commit finalises. If the script exits non-zero, the commit is aborted.
  • commit-msg — runs after the message is written, before the commit is recorded. Used to enforce message format.
  • pre-push — runs before git push sends commits to the remote. Non-zero exit blocks the push.
  • post-merge, post-checkout, etc. — fire after the matching events.

Look in your repo:

ls .git/hooks/
applypatch-msg.sample
commit-msg.sample
fsmonitor-watchman.sample
post-update.sample
pre-applypatch.sample
pre-commit.sample
pre-merge-commit.sample
pre-push.sample
pre-rebase.sample
prepare-commit-msg.sample
push-to-checkout.sample
update.sample

Every file ends in .sample — Git ships templates but doesn't activate them. To activate, drop the .sample extension and make the file executable. From here, "the hook runs" means: Git invokes the script with arguments and reads its exit code.

A first pre-push hook

Want to run a quick smoke test before every push? Edit .git/hooks/pre-push (no extension):

#!/bin/sh
echo "Running smoke tests before push..."
npx cypress run --spec "cypress/e2e/smoke/**/*"
 
if [ $? -ne 0 ]; then
  echo "❌ Smoke tests failed. Push aborted."
  exit 1
fi
 
echo "✅ Smoke tests passed. Pushing..."
exit 0

Make it executable:

chmod +x .git/hooks/pre-push

Try a push:

git push
Running smoke tests before push...
[Cypress runs your smoke suite...]
❌ Smoke tests failed. Push aborted.
error: failed to push some refs

The push is blocked. Fix the test, push again. Smoke tests now run automatically — no more "I forgot to test before pushing."

The catch: hooks aren't versioned

The .git/ folder is not part of your repository. It's a local artefact created by Git on your machine. Your hand-rolled pre-push hook lives only on your laptop — your teammates' clones don't have it, and there's no git add that would change that.

You could write a README saying "everyone please copy this script into .git/hooks/pre-push" — and people forget on day one. The robust answer is Husky.

Husky — version-controlled hooks for the team

Husky is an npm package that solves the "hooks aren't shared" problem. It stores hooks in a tracked folder (.husky/) and configures Git to run them.

Install in your test repo:

npm install --save-dev husky
npx husky init
husky - install command is deprecated
husky - created .husky/
husky - created .husky/pre-commit

Two things just happened:

  1. A .husky/ folder appeared at the repo root. It IS tracked by Git.
  2. A prepare script was added to package.json so future npm installs configure Husky for new clones automatically.

Edit .husky/pre-commit (or create .husky/pre-push):

#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
 
npm run smoke

Commit and push the .husky/ folder. Anyone who clones the repo and runs npm install automatically gets the same hook. No README, no manual setup.

package.json should have a corresponding script:

{
  "scripts": {
    "smoke": "cypress run --spec 'cypress/e2e/smoke/**/*'",
    "prepare": "husky"
  }
}

Useful hooks for test projects

Three patterns most QA teams settle on:

pre-commit — fast static checks

Run things that take seconds and catch silly mistakes:

# .husky/pre-commit
npm run lint
npm run typecheck

ESLint catches unused variables and obvious typos; TypeScript's tsc --noEmit catches type drift. Both run in 2-5 seconds on a touched-files-only setup.

pre-push — smoke tests

Run your fastest meaningful end-to-end tests:

# .husky/pre-push
npm run smoke

10-30 seconds is the sweet spot. Faster, and it's not catching anything; slower, and people will start --no-verify-ing past it.

commit-msg — enforce conventions

If your team uses Conventional Commits (feat:, fix:, chore:), a commit-msg hook with commitlint rejects malformed messages:

# .husky/commit-msg
npx --no -- commitlint --edit "$1"

lint-staged — only check what you changed

The friction with linters is "running on the whole repo every commit is slow." lint-staged runs them only on files you've staged:

npm install --save-dev lint-staged

In package.json:

{
  "lint-staged": {
    "*.{js,ts}": "eslint --fix",
    "*.{json,md}": "prettier --write"
  }
}

In .husky/pre-commit:

npx lint-staged

Now pre-commit only lints the two files you actually edited, in 200ms instead of 12 seconds.

What NOT to put in a local hook

The temptation is to run "everything" before push. Resist:

  • The full e2e suite — 10 minutes is too long. People will work around the hook (git push --no-verify) and the safety vanishes.
  • CI-only checks — anything that needs cloud secrets, a specific OS, or a clean container belongs in CI, not on each developer's laptop.
  • Network-dependent tests — flaky in cafés, slow on hotel WiFi.

Local hooks should be fast and deterministic. CI is for the heavy stuff. The split looks like:

LayerWhat runsTime budget
pre-commitLinter, type-check, formatter on staged files< 5 seconds
pre-pushSmoke tests (10-30 of your most critical tests)< 30 seconds
CI on PRFull unit + integration suite, lint over whole repo5-10 minutes
CI on mergeFull e2e suite, performance checks, builds15-30 minutes

The --no-verify escape hatch

Every Git hook has the same bypass:

git commit --no-verify
git push --no-verify

Both flags skip hooks entirely. Sometimes legitimately useful — your machine can't run the hook (Cypress isn't installed yet), or you're pushing a docs-only change. Don't make it a habit. A hook that gets --no-verify'd every time isn't doing its job; either the hook is too slow, or the team doesn't agree with it. Address the cause, not the symptom.

A pre-push flow at a glance

Step 1 of 5

git push

Developer runs git push origin feature/checkout-tests.

A real QA scenario — adding a smoke gate to a Cypress repo

Your team's Cypress repo just merged its 200th flaky push. You volunteer to add a smoke gate.

git switch -c chore/add-husky-presmoke
npm install --save-dev husky
npx husky init
echo 'npx cypress run --spec "cypress/e2e/smoke/**/*"' > .husky/pre-push
git add .husky package.json package-lock.json
git commit -m "Add pre-push hook to run smoke suite locally"
git push -u origin chore/add-husky-presmoke

Open the PR. Add a description that explains what runs, how long it takes, and how to bypass it for emergencies (--no-verify). Once merged, every teammate's next npm install activates the hook automatically. The 200th push that would have broken main is now caught on someone's laptop instead.

⚠️ Common mistakes

  • Putting hooks in .git/hooks/ and expecting teammates to have them. That folder is local-only. Always use Husky (or another hook manager) for anything you want shared with the team.
  • Running the whole test suite on pre-push. A 10-minute hook is a --no-verify factory. Pick a smoke subset; let CI run the rest. Smaller, faster gates with high signal beat slow gates that get bypassed.
  • Forgetting the executable bit. Manual hooks at .git/hooks/pre-push need chmod +x. If the file isn't executable, Git silently skips it — you'll think the hook works and it isn't running at all. Husky handles this for you, which is another reason to prefer it.

🎯 Practice task

Set up a real pre-push hook on a project. 25-30 minutes.

  1. In a Node-based repo (your qa-sandbox initialised with npm init -y, or a real test repo), install Husky: npm install --save-dev husky && npx husky init.
  2. Confirm .husky/ was created and that package.json has a prepare script.
  3. Create .husky/pre-push (overwrite the default) with one line: echo "pre-push hook running" && exit 0. Stage and commit.
  4. Run git push. Confirm you see "pre-push hook running" in the output.
  5. Replace the script with a deliberately failing command: exit 1. Try to push. Confirm Git blocks the push.
  6. Replace with something useful — npm run lint, or a fake test command like node -e "console.log('smoke ok')". Confirm it runs.
  7. lint-staged stretch: install lint-staged and configure it in package.json to format *.json files with Prettier on pre-commit. Edit a JSON file with bad formatting, commit, and confirm the formatter ran.
  8. Real stretch: add a pre-push hook to a real test project that runs your smoke suite. Time it. If it's > 30 seconds, prune the smoke set until it isn't.

That closes Chapter 5. You now know how to keep the repo clean, manage test data thoughtfully, and gate code locally before it ships. The capstone in Chapter 6 stitches it all together into a single end-to-end project.

// tip to track lessons you complete and pick up where you left off across devices.