How to Run Tests in GitHub Actions
GitHub Actions workflows are YAML files stored in your repository at .github/workflows/. When an event matches a workflow's trigger (a push, a pull request, a schedule), GitHub spins up a fresh virtual machine, checks out your code, and executes the steps you define. The free tier includes 2,000 minutes per month for private repos and unlimited minutes for public repos on Linux runners, which is enough for most projects to run full test suites on every commit.
Create the Workflow File
Create the directory .github/workflows/ in your repository root if it does not exist, then create a file called test.yml. This file defines your entire testing pipeline. GitHub automatically detects and runs any .yml files in this directory.
The basic structure has three top-level keys: name (the workflow name shown in the Actions tab), on (the events that trigger the workflow), and jobs (the work to perform). A minimal workflow for a Node.js project looks like this:
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test
This workflow triggers on pushes to main and on pull requests targeting main. It checks out the code, installs Node.js 20, installs dependencies with npm ci (which is faster than npm install and respects the lockfile exactly), and runs the test command.
Configure Triggers and Environment
The on key controls when the workflow runs. For continuous testing, you want tests on every pull request (to catch problems before merging) and on pushes to main (to verify the merge did not break anything). You can also add a schedule trigger for nightly runs of slower test suites:
on:
push:
branches: [main, develop]
pull_request:
schedule:
- cron: '0 2 * * *' # Daily at 2 AM UTC
The runs-on key selects the runner image. ubuntu-latest is the standard choice for most projects because it is the fastest and cheapest runner. GitHub also offers macos-latest and windows-latest for platform-specific testing. For Python projects, replace setup-node with setup-python. For Java, use setup-java with the distribution and version specified.
Environment variables set at the job level apply to all steps. Use them for test configuration like database URLs, API keys for test services, or feature flags:
jobs:
test:
runs-on: ubuntu-latest
env:
NODE_ENV: test
DATABASE_URL: postgresql://localhost:5432/testdb
Cache Dependencies
Without caching, every workflow run downloads and installs all dependencies from scratch. For a Node.js project with a large dependency tree, that can add 30 to 60 seconds to every run. Caching stores the installed packages between runs and restores them when the lockfile has not changed.
The setup-node action has built-in caching support:
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
For Python with pip:
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
For more control, use the dedicated cache action. This example caches node_modules directly, which is faster than caching the npm cache directory because it skips the install step entirely when the cache hits:
- uses: actions/cache@v4
id: node-cache
with:
path: node_modules
key: node-modules-${{ hashFiles('package-lock.json') }}
- if: steps.node-cache.outputs.cache-hit != 'true'
run: npm ci
When the lockfile has not changed, the cache restores node_modules instantly and the npm ci step is skipped entirely. This optimization alone can cut pipeline time by 30 to 50 percent for dependency-heavy projects.
Run Unit and Integration Tests
Unit tests are the foundation of continuous testing and should run first because they are the fastest. Most test frameworks produce JUnit XML output that GitHub can parse and display in the Actions UI:
# Node.js with Jest
- run: npx jest --ci --reporters=default --reporters=jest-junit
env:
JEST_JUNIT_OUTPUT_DIR: ./reports
# Python with pytest
- run: pytest --junitxml=reports/results.xml
# Java with Maven
- run: mvn test
For integration tests that need a real database, GitHub Actions provides service containers. These are Docker containers that run alongside your test job and are accessible via localhost:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
The health check ensures the database is fully ready before tests start. Without it, tests might fail because PostgreSQL is still initializing when the test step begins. This service container approach gives you real database testing without Testcontainers, though Testcontainers provides more flexibility for complex scenarios.
Upload test results as artifacts so they are available for review even after the run finishes:
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: reports/
Add Browser Tests with Playwright
Playwright tests in GitHub Actions require installing browser binaries. Playwright provides a dedicated GitHub Action that handles this, or you can install them manually and cache them:
# Option 1: Manual install with caching
- name: Cache Playwright browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('package-lock.json') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps
- name: Install system dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps
# Run tests
- name: Run Playwright tests
run: npx playwright test
# Upload traces and reports on failure
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-traces
path: test-results/
The trace files are gold for debugging CI failures. Download them from the Actions artifacts, open them in Playwright's trace viewer (trace.playwright.dev), and step through the exact sequence of actions, screenshots, network requests, and console messages that led to the failure.
For larger test suites, use Playwright's built-in sharding to split tests across multiple GitHub Actions jobs that run in parallel:
jobs:
test:
strategy:
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
# ... setup steps ...
- run: npx playwright test --shard=${{ matrix.shard }}
This distributes your test files across four parallel jobs, cutting execution time by roughly 75 percent. Each shard gets a balanced portion of the test files based on Playwright's internal distribution logic.
Set Up Matrix Builds for Cross Platform Testing
The matrix strategy runs the same workflow across multiple configurations simultaneously. This is essential for libraries, frameworks, and applications that need to work across different runtime versions or operating systems:
jobs:
test:
strategy:
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test
This creates six parallel jobs: Node 18 on Ubuntu, Node 18 on Windows, Node 20 on Ubuntu, Node 20 on Windows, Node 22 on Ubuntu, and Node 22 on Windows. Each runs independently, so a failure on Windows with Node 18 does not stop the Ubuntu jobs from completing. The fail-fast: false option (add it under strategy) ensures all matrix combinations run even if some fail, which is useful for seeing the full picture of compatibility.
Matrix builds consume minutes from your quota proportionally. Six parallel jobs running for 3 minutes each consume 18 minutes of your quota, the same as running them sequentially. The advantage is wall clock time: you get all results in 3 minutes instead of 18.
Advanced Patterns
Conditional Test Stages
Not every test suite needs to run on every event. You can separate fast tests (run on every PR) from slow tests (run nightly or on main branch merges):
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test:unit
e2e-tests:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: unit-tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
In this configuration, unit tests run on every event. E2E tests only run when code is pushed to main, and only after unit tests pass. This keeps pull request feedback fast (unit tests in under a minute) while still running comprehensive tests before code reaches production.
Required Status Checks
To enforce that tests must pass before merging, configure branch protection rules in your repository settings. Navigate to Settings, Branches, and add a branch protection rule for main. Enable "Require status checks to pass before merging" and select your test workflow jobs. This prevents anyone from merging a pull request with failing tests, which is the enforcement mechanism that makes continuous testing a real quality gate rather than advisory feedback.
Secrets and Environment Variables
Tests that need API keys, database credentials, or other sensitive configuration should use GitHub Secrets. Store secrets in the repository settings (Settings, Secrets and variables, Actions) and reference them in workflows:
- run: npm run test:integration
env:
API_KEY: ${{ secrets.TEST_API_KEY }}
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
Secrets are not exposed in logs and are not available to workflows triggered by pull requests from forks, which prevents malicious forks from extracting sensitive values.
A production-ready GitHub Actions test pipeline caches dependencies, runs unit tests first for fast feedback, uses service containers for integration tests, shards Playwright tests across parallel jobs for E2E coverage, and enforces passing tests through branch protection rules.