
To run unit tests on every push and pull request, add one workflow file under .github/workflows/ that checks out your code, sets up your language, installs dependencies and runs your test command. Then make that job a required status check so a failing test blocks the merge.
Below are copy-paste workflows for Node.js, Python and Java using the current action versions, plus the settings that make them fast and reliable. Part of our complete Git and GitHub Actions guide. New to workflow syntax? Read GitHub Actions workflow files explained first.
Node.js (Jest, Vitest, Mocha)
Save as .github/workflows/tests.yml:
name: Tests
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
node-version: [22, 24]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test
npm ciinstalls exactly what is inpackage-lock.json, which is faster and more reproducible thannpm installin CI.- The matrix runs the suite on Node 22 and 24, the current LTS lines. Drop versions your project does not support.
cache: npmcaches the npm download cache between runs, keyed on your lockfile.
Python (pytest)
name: Tests
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
cache: pip
- run: pip install -r requirements.txt
- run: pytest --junitxml=test-results.xml
- uses: actions/upload-artifact@v7
if: always()
with:
name: test-results-${{ matrix.python-version }}
path: test-results.xml
--junitxml writes a standard test report, and if: always() uploads it even when tests fail, which is exactly when you want it. For projects managed with uv or Poetry, swap the install step for uv sync or poetry install.
Java (JUnit with Maven)
name: Tests
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: "21"
cache: maven
- run: mvn -B verify
mvn verify compiles, runs unit tests and runs any integration tests bound to the verify phase, in one command. Avoid running mvn install and then mvn test: install already runs the tests, so you would run the suite twice. -B (batch mode) keeps the logs readable. For Gradle, use cache: gradle and ./gradlew test.
What Each Setting Does
| Setting | Why it matters |
|---|---|
on: push: branches: [main] + pull_request | Tests every PR and every merge to main without running twice for every push to a PR branch |
permissions: contents: read | Least privilege: a test job has no reason to write to your repo |
concurrency + cancel-in-progress | Pushing again to a PR cancels the now-stale run, saving minutes |
timeout-minutes | A hung test fails in minutes instead of burning up to 6 hours of runner time |
fail-fast: false | One matrix version failing does not cancel the others, so you see which versions break |
cache: on setup-* actions | Reuses downloaded dependencies between runs |
Block Merges When Tests Fail
A red workflow does not stop a merge on its own. In your repository go to Settings → Rules → Rulesets (or classic Settings → Branches), target main, enable Require status checks to pass, and add your test job. With a matrix, each version shows up as its own check, for example test (22) and test (24), so add each one.
See Git best practices for branching and approvals for the full set of protection rules.
Seeing Results
- Pull request checks: each job appears at the bottom of the PR with a link to its logs.
- Artifacts: uploaded reports (like the pytest JUnit XML above) are downloadable from the run page.
- Job summary: anything a step appends to
$GITHUB_STEP_SUMMARYis rendered as Markdown on the run page, a handy place for a short pass/fail table.
Common Problems
- “Dependencies lock file is not found” from setup-node:
cache: npmneeds a committedpackage-lock.json. - Tests pass locally, fail in CI: usually a missing environment variable, a timezone or locale difference, or a test that depends on another test’s state. CI runs from a clean machine every time.
- The required check never reports: the job name in branch protection does not match the job (or matrix name) in the workflow, or a path filter skipped the workflow entirely.
- Slow runs: check that caching hits (the setup step logs “Cache restored”), and split long suites with a matrix of shards.
For security settings beyond permissions, see our GitHub Actions security best practices.
Frequently Asked Questions
Add a workflow that checks out the code, sets up your language runtime, installs dependencies and runs the test command on push and pull_request.
Use a matrix strategy: list the versions under strategy.matrix and the job runs once per version in parallel.
In the job logs, in the checks on the pull request, and, if you upload a report, in the run summary or as test-reporter annotations.
Yes. Make the test job a required status check in branch protection so a red run prevents merge.
You Might Also Like
- 👉 Day 1: Git Fundamentals
- 👉 Day 2: Basic Git Workflow
- 👉 Day 3: Branching and Merging
- 👉 Day 4: Remote Repositories
- 👉 Day 5: Advanced Git Operations
- 👉 Day 6: Git Workflows and Best Practices
- 👉 Day 7: Troubleshooting and Expert Techniques
- 👉 Advanced Git Commands You Need to Master (With Examples)
- 👉 Git Best Practices for Branching and Approvals
- 👉 GitHub CODEOWNERS & Permissions: Best Practices with Teams
- 👉 Git Hooks Explained: Automate Your Workflow with Examples
- 👉 Git Tags and Releases Best Practices: A Complete Guide