GitHub Actions for Testing: Run Unit Tests Automatically

GitHub Actions for Testing: Run Unit Tests Automatically

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 ci installs exactly what is in package-lock.json, which is faster and more reproducible than npm install in CI.
  • The matrix runs the suite on Node 22 and 24, the current LTS lines. Drop versions your project does not support.
  • cache: npm caches 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

SettingWhy it matters
on: push: branches: [main] + pull_requestTests every PR and every merge to main without running twice for every push to a PR branch
permissions: contents: readLeast privilege: a test job has no reason to write to your repo
concurrency + cancel-in-progressPushing again to a PR cancels the now-stale run, saving minutes
timeout-minutesA hung test fails in minutes instead of burning up to 6 hours of runner time
fail-fast: falseOne matrix version failing does not cancel the others, so you see which versions break
cache: on setup-* actionsReuses 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_SUMMARY is 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: npm needs a committed package-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

How do I run unit tests in GitHub Actions?

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.

How do I run tests against multiple language versions?

Use a matrix strategy: list the versions under strategy.matrix and the job runs once per version in parallel.

Where do I see test results?

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.

Should failing tests block merging?

Yes. Make the test job a required status check in branch protection so a red run prevents merge.

You Might Also Like