Overview
Test workflows are YAML files in .github/workflows/ that run jobs on trigger events (gha (opens in new window)). This skill sets up the idiomatic patterns - matrix builds, sharding, service containers, JUnit reporting, trigger filtering, concurrency, and secrets. Start with the minimal pattern in Step 1.
When to use
How to use
Step 1 - Basic test workflow
# .github/workflows/test.yml
name: test
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: '24' }
- run: npm ci
- run: npm testThe minimal pattern: trigger on PR + push-to-main, install deps, run tests.
Step 2 - Matrix builds
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [22, 24]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: ${{ matrix.node }} }
- run: npm ci
- run: npm testPer workflow syntax (opens in new window), when fail-fast is true GitHub "will cancel all in-progress and queued jobs in the matrix if any job in the matrix fails", and "This property defaults to true". Set it to false explicitly so every OS and runtime reports. Matrix size is OS × Node = 3 × 2 = 6 jobs; per limits (opens in new window), "A job matrix can generate a maximum of 256 jobs per workflow run."
To let one experimental target fail without cancelling the rest, mark it with continue-on-error: ${{ matrix.experimental }}, which per workflow syntax (opens in new window) "applies to a single job".
Runner pinning, per-language runtime matrices (Node / Python / Java / .NET), OS-specific quirks (path separators, line endings, case sensitivity, shells), per-OS conditional steps and tests, and the tiered cost-management cadence are in references/os-matrix.md.
Step 3 - Sharding for parallel execution
For large suites:
jobs:
test:
strategy:
matrix:
shard: [1, 2, 3, 4]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npx jest --shard=${{ matrix.shard }}/44 parallel jobs, each running 1/4 of the test suite. Per jest-cli (opens in new window), --shard takes shardIndex/shardCount, both "1-based, positive numbers". Wall-clock time drops; billed minutes stay about the same plus each job's setup time. Playwright (--shard=1/4) and pytest-xdist splitting follow the same matrix shape.
Service containers and reporting
Wiring the service containers the tests depend on (PostgreSQL, Redis) and publishing JUnit XML as artifacts plus PR-check summaries are in references/services-and-reporting.md.
Step 6 - Retry policy
GitHub Actions doesn't ship native test-retry; use the framework's retry mechanism (e.g., Playwright's retries config) or a wrapper action such as nick-fields/retry (opens in new window), which takes timeout_minutes (or timeout_seconds), max_attempts and command:
- uses: nick-fields/retry@v3
with:
timeout_minutes: 10
max_attempts: 2
command: npm testUse sparingly - retries hide flake. Prefer flaky-test-quarantine (in the qa-flake-triage plugin).
Step 7 - Per-trigger filtering
on:
pull_request:
paths:
- 'src/**'
- 'tests/**'
- 'package.json'
push:
branches: [main]
paths-ignore:
- 'docs/**'
- '*.md'
schedule:
- cron: '0 4 * * *' # daily 4am UTC
workflow_dispatch: # manual trigger
inputs:
target_browser:
description: 'Browser to test'
type: choice
options: [chrome, firefox, safari]
default: chromePath filters skip workflows when only docs change - saves CI budget. Per workflow syntax (opens in new window), "Use the paths-ignore filter when you only want to exclude file path patterns"; to include and exclude in one list, use paths with ! prefixes.
Per events (opens in new window), "By default, scheduled workflows run in UTC", "The shortest interval you can run scheduled workflows is once every 5 minutes", "Scheduled workflows run on the latest commit on the default branch", and the event "can be delayed during periods of high loads". Don't schedule a nightly run at minute 0 if timing matters; pick an odd minute.
Per workflow syntax (opens in new window), the filter decides "if a workflow should run" from the changed files. A workflow that doesn't run posts no status for its jobs, so if a branch rule requires one of those jobs, a PR that only touches docs waits for a check that never arrives. Keep path filters off workflows whose jobs are required checks, or filter inside the job instead.
Step 8 - Concurrency control
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: trueWhen a PR receives multiple pushes, the older runs cancel - saves CI cost on superseded commits. Per workflow syntax (opens in new window), a new run only replaces a pending one by default; "To also cancel any currently running job or workflow in the same concurrency group, specify cancel-in-progress: true". Don't use this group on main pushes that deploy: a cancelled deploy can leave an environment half updated.
Step 9 - Secrets + env
env:
CI: true
steps:
- run: npm test
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}Secrets configured in repo settings; never committed.
Verify before merge
Run the workflow once before merging - push the branch or trigger it manually via workflow_dispatch (or dry-run locally with act).
Verify: the matrix expands into the expected job count (e.g. OS × Node = 3 × 2 = 6 jobs) and each job uploads its JUnit artifact. If a job fails on a missing secret, confirm NPM_TOKEN / TEST_DATABASE_URL exist under repo Settings -> Secrets and variables -> Actions, add any that are missing, and re-run the job.
Worked example
A Node service needs PR tests on Linux + macOS, plus a nightly full run against PostgreSQL.
Result: PR authors get fast cross-platform signal, the nightly catches DB-integration regressions, and failed runs still surface their JUnit report.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
fail-fast: true on matrix | First failure cancels all; lose multi-target signal. | fail-fast: false (Step 2). |
No concurrency group | PRs with rapid pushes pile CI runs. | Add concurrency cancel (Step 8). |
if: always() everywhere | Some steps shouldn't run on failure (deploy). | Selective if: always() for upload steps only (services-and-reporting). |
| Hardcoded secrets in YAML | Secret leak; revocation needed. | secrets.X references (Step 9). |
| Massive single workflow file | Hard to navigate; merge conflicts. | Split per concern (test.yml, deploy.yml, lint.yml). |
Missing actions/checkout step | Job can't access repo files. | First step always (Step 1). |
No timeout-minutes on the test job | A hung test burns up to the 360-minute default. | timeout-minutes: 20 (or the suite's p99 plus margin). |
| Path filter on a workflow that runs a required check | The check never reports; docs-only PRs stay blocked. | Filter inside the job, or keep the workflow unfiltered (Step 7). |
Limitations
References
Reference for github-actions-test-jobs: run tests across operating systems (Linux / macOS / Windows) and runtime versions (Node 18/20/22; Python 3.10/3.11/3.12; Java 17/21; .NET 6/8) with GitHub Actions matrix syntax, and address the OS-specific quirks (path separators, line endings, file permissions, shells) that break cross-platform suites.
When to use
For browsers specifically, see the qa-web-e2e plugin's playwright-testing (references/browser-matrix.md).
Step 1 - Define the OS matrix
# .github/workflows/os-matrix.yml
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]GitHub Actions provides:
| Runner | Use |
|---|---|
ubuntu-latest | Default; cheapest; most CI runs here. |
ubuntu-22.04 | Pin specific Ubuntu LTS. |
macos-latest | macOS; needed for iOS / Safari testing. |
macos-15 | Pin macOS version. |
windows-latest | Windows; tests Windows-specific paths. |
windows-2022 | Pin Windows version. |
Step 2 - Define the runtime matrix
Per language:
# Node.js
strategy:
matrix:
node: [18, 20, 22]
os: [ubuntu-latest, macos-latest, windows-latest]# Python
strategy:
matrix:
python: ['3.10', '3.11', '3.12']
os: [ubuntu-latest, macos-latest, windows-latest]# Java
strategy:
matrix:
java: [17, 21]
os: [ubuntu-latest, macos-latest, windows-latest]# .NET
strategy:
matrix:
dotnet: ['6.0.x', '8.0.x']
os: [ubuntu-latest, macos-latest, windows-latest]The full cross-product is 3 OSes × 3 runtimes = 9 jobs. For larger matrices, use include + exclude to skip uninteresting combinations.
Step 3 - Address OS-specific quirks
Path separators
// Bad - hardcoded /
const configPath = projectRoot + '/config/app.json';
// Good - path.join
const path = require('node:path');
const configPath = path.join(projectRoot, 'config', 'app.json');Line endings
# .gitattributes
*.sh text eol=lf
*.bat text eol=crlf
*.json textWithout .gitattributes, Windows users may commit CRLF; tests that compare output strings break.
Case sensitivity
// On Linux: import './Utils' fails if file is './utils'
// On macOS / Windows (default): both work
// Always match file case exactly:
import { foo } from './utils'; // matches utils.jsShell
- name: Run script (cross-platform)
shell: bash
run: ./scripts/setup.shshell: bash works on Linux + macOS + Windows (via Git Bash on Windows runners).
Step 4 - Per-OS conditional steps
When OS-specific setup is needed:
- name: Install Linux deps
if: runner.os == 'Linux'
run: sudo apt-get install -y libssl-dev
- name: Install macOS deps
if: runner.os == 'macOS'
run: brew install openssl
- name: Install Windows deps
if: runner.os == 'Windows'
run: choco install opensslStep 5 - Aggregate per-OS results
## OS / runtime matrix results - `<sha>`
| OS | Runtime | Tests | Pass | Fail | Time |
|-----------|----------|------:|-----:|-----:|-----:|
| Linux | Node 22 | 142 | 142 | 0 | 2m |
| Linux | Node 20 | 142 | 142 | 0 | 2m |
| Linux | Node 18 | 142 | 140 | 2 | 2m | ← Node 18 incompat
| macOS | Node 22 | 142 | 141 | 1 | 3m | ← macOS path issue
| macOS | Node 20 | 142 | 141 | 1 | 3m |
| Windows | Node 22 | 142 | 140 | 2 | 4m | ← Windows path issue
| ...Step 6 - Per-OS conditional tests
Some tests are OS-specific:
// jest.config.js
module.exports = {
testPathIgnorePatterns: process.platform === 'win32'
? ['unix-only.test.js']
: ['windows-only.test.js'],
};Or via test framework conditionals:
test.skipIf(process.platform === 'win32')('uses fork()', () => {
// POSIX-specific test
});Step 7 - Cost management
Matrix size grows multiplicatively. Manage cost:
| Tier | Cadence | Matrix size |
|---|---|---|
| Per-PR (smoke) | Per push | 1 × 1 = 1 job (Linux + latest runtime). |
| Per-merge to main | Per merge | 3 × 1 = 3 jobs (3 OSes + latest runtime). |
| Nightly | Cron | 3 × 3 = 9 jobs (full matrix). |
| Pre-release | Tag | Full matrix + extra exotic combinations. |
The "smoke matrix" per-PR keeps CI cheap; the full matrix runs less frequently.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Hardcoded / path separators | Breaks on Windows. | path.join (Step 3). |
fail-fast: true on the matrix | One OS fails; can't see others. | fail-fast: false. |
| Same matrix every commit | CI cost explodes; team disables. | Tiered cadence (Step 7). |
| Per-OS code in production | If/else by OS; high maintenance. | Cross-platform abstractions in production; OS-specific code in glue layer only. |
Skipping .gitattributes | CRLF / LF mixing; tests fail mysteriously. | Always set (Step 3). |
Limitations
References
GitHub Actions - service containers and reporting
View source (opens in a new tab) (opens in new window)Deeper recipes split out of github-actions-test-jobs SKILL.md: wiring the service containers the tests depend on, and publishing JUnit results as artifacts plus PR-check summaries.
Service containers (PostgreSQL, Redis, etc.)
jobs:
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports: [5432:5432]
redis:
image: redis:7
ports: [6379:6379]
steps:
- uses: actions/checkout@v7
- run: npm ci
- run: npm test
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/test
REDIS_URL: redis://localhost:6379GitHub Actions provides container-based services on Linux runners. Healthcheck options ensure tests don't start before the DB is ready.
JUnit reporting + artifacts
- run: npm test -- --reporters=default --reporters=jest-junit
- uses: actions/upload-artifact@v7
if: always()
with:
name: test-results
path: test-results/
- uses: dorny/test-reporter@v3
if: always()
with:
name: Test results
path: test-results/junit.xml
reporter: java-junitif: always() ensures artifacts upload even on test failure. The dorny/test-reporter action surfaces results in the PR check summary.
- Finance flagged our CI bill and I cannot tell which half of Marco's plan is real
- Security review flagged the tokens someone pasted into our workflow
- Our summary job reports "0 tests, 0 failures" on a run where 900 tests ran
- A run with six failing tests posted a green check and merged
- Windows users keep hitting a crash our CI has never once reproduced