github-actions-test-jobs

Use whenever the task is to run tests in GitHub Actions: a test workflow on pull requests, matrix builds across OS and runtime versions, splitting a suite across parallel runners, service containers such as Postgres or Redis, uploading JUnit reports, and trigger filters.

/plugin install qa-ci-integration@testland-qa

First time in Claude Code? Add the marketplace with /plugin marketplace add testland/qa

View source (opens in a new tab)

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

  • Project on GitHub.
  • Need to set up test CI for a new repo.
  • Existing GitHub Actions workflows need standardization.

How to use

  1. Add .github/workflows/test.yml triggered on pull_request and push to main; check out the repo and set up the runtime (Step 1).
  2. Install dependencies and run the suite (npm ci then npm test).
  3. Add a build matrix over OS and runtime versions with fail-fast: false for multi-target signal (Step 2), and shard large suites across parallel jobs (Step 3).
  4. Wire any service containers the tests need and emit JUnit XML, uploading it with if: always() - references/services-and-reporting.md.
  5. Filter triggers by path and add schedule / workflow_dispatch (Step 7); add a concurrency group so superseded pushes cancel (Step 8).
  6. Move tokens into secrets.* and reference them from step env (Step 9).
  7. Verify the workflow runs (see Verify below), then review the anti-patterns table before merging.

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 test

The 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 test

Per 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 }}/4

4 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 test

Use 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: chrome

Path 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: true

When 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.

  1. test.yml triggers on pull_request and push to main, with schedule: cron '0 4 * * *' for the nightly run.
  2. The test job runs a matrix of os: [ubuntu-latest, macos-latest] × node: [20, 22] with fail-fast: false - 4 jobs, and no target cancels another.
  3. A separate integration job (Linux only) declares a postgres:15 service with a pg_isready healthcheck and passes DATABASE_URL into npm test.
  4. Tests emit test-results/junit.xml; actions/upload-artifact@v7 with if: always() keeps the report even when tests fail, and dorny/test-reporter@v3 renders it in the PR check summary.
  5. A concurrency group keyed on the ref cancels superseded runs, so rapid pushes don't pile up billable minutes.

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-patternWhy it failsFix
fail-fast: true on matrixFirst failure cancels all; lose multi-target signal.fail-fast: false (Step 2).
No concurrency groupPRs with rapid pushes pile CI runs.Add concurrency cancel (Step 8).
if: always() everywhereSome steps shouldn't run on failure (deploy).Selective if: always() for upload steps only (services-and-reporting).
Hardcoded secrets in YAMLSecret leak; revocation needed.secrets.X references (Step 9).
Massive single workflow fileHard to navigate; merge conflicts.Split per concern (test.yml, deploy.yml, lint.yml).
Missing actions/checkout stepJob can't access repo files.First step always (Step 1).
No timeout-minutes on the test jobA 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 checkThe check never reports; docs-only PRs stay blocked.Filter inside the job, or keep the workflow unfiltered (Step 7).

Limitations

  • Runner cost. Per billing (opens in new window), "jobs that run on Windows and macOS runners hosted by GitHub cost more to run than jobs on Linux runners"; its example prices Linux at $0.006 per minute and Windows at $0.010. Run the full OS matrix on main or nightly, Linux on PRs.
  • Per-job 6h timeout. Per limits (opens in new window), "Each job in a workflow can run for up to 6 hours of execution time", and per workflow syntax (opens in new window) timeout-minutes defaults to 360. Set a much lower timeout-minutes on test jobs so a hung test fails in minutes, not hours.
  • Service containers Linux-only. Per workflow syntax (opens in new window), service containers need a Linux runner ("If you are using GitHub-hosted runners, you must use an Ubuntu runner").
  • No built-in JUnit XML viewer. Upload the XML as an artifact and render it with a reporter action such as dorny/test-reporter.

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

  • The product runs on multiple OSes (CLI tools, libraries, desktop apps).
  • The team supports multiple runtime versions.
  • A bug report says "works on Linux, broken on Windows."

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:

RunnerUse
ubuntu-latestDefault; cheapest; most CI runs here.
ubuntu-22.04Pin specific Ubuntu LTS.
macos-latestmacOS; needed for iOS / Safari testing.
macos-15Pin macOS version.
windows-latestWindows; tests Windows-specific paths.
windows-2022Pin 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 text

Without .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.js

Shell

- name: Run script (cross-platform)
  shell: bash
  run: ./scripts/setup.sh

shell: 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 openssl

Step 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:

TierCadenceMatrix size
Per-PR (smoke)Per push1 × 1 = 1 job (Linux + latest runtime).
Per-merge to mainPer merge3 × 1 = 3 jobs (3 OSes + latest runtime).
NightlyCron3 × 3 = 9 jobs (full matrix).
Pre-releaseTagFull matrix + extra exotic combinations.

The "smoke matrix" per-PR keeps CI cheap; the full matrix runs less frequently.

Anti-patterns

Anti-patternWhy it failsFix
Hardcoded / path separatorsBreaks on Windows.path.join (Step 3).
fail-fast: true on the matrixOne OS fails; can't see others.fail-fast: false.
Same matrix every commitCI cost explodes; team disables.Tiered cadence (Step 7).
Per-OS code in productionIf/else by OS; high maintenance.Cross-platform abstractions in production; OS-specific code in glue layer only.
Skipping .gitattributesCRLF / LF mixing; tests fail mysteriously.Always set (Step 3).

Limitations

  • GitHub Actions runner cost. Windows + macOS runners are more expensive than Linux; matrix design tradeoff.
  • Per-OS bugs may surface only at runtime. Static analysis catches some; integration tests are the safety net.
  • macOS-specific issues often only reproduce on real macOS; Linux CI doesn't catch them.
  • Older OS versions rarely available on hosted runners; need self-hosted for legacy.

References

  • GitHub Actions runners docs at docs.github.com/en/actions/using-github-hosted-runners.
  • playwright-testing (in the qa-web-e2e plugin, references/browser-matrix.md) - sibling: browser-specific.
  • browser-matrix-strategy-reference (in the qa-web-e2e plugin, references/compatibility-budget.md) - conventions for matrix sizing.
Reference

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:6379

GitHub 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-junit

if: 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
Blog

Related reading