Testing in GitHub Actions
A complete pipeline — unit tests, integration with containers, sharded Playwright, coverage and artefacts — plus the caching and concurrency settings that make it fast.
1 min read · updated 19 September 2026
A complete pipeline for a web application: fast checks first, expensive ones after, artefacts on failure.
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
# Cancel superseded runs on the same branch — a push five minutes later
# makes the earlier run irrelevant, and it is still consuming a runner.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
permissions:
contents: read
jobs:
# ---------------------------------------------------------------- #
# Fast checks. Everything else waits on these, so a typo fails in
# ninety seconds rather than after a full browser run.
# ---------------------------------------------------------------- #
check:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck # tsc --noEmit, not inside the test runner
unit:
needs: check
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm test -- --coverage --maxWorkers=50%
- uses: codecov/codecov-action@v4
with: { files: ./coverage/lcov.info }
# Publish results even when the step failed, or the report is missing
# exactly when it is needed.
- uses: dorny/test-reporter@v1
if: always()
with:
name: Unit tests
path: 'reports/junit.xml'
reporter: jest-junit
# ---------------------------------------------------------------- #
# Integration. Testcontainers needs nothing but Docker, which the
# runner already has.
# ---------------------------------------------------------------- #
integration:
needs: check
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run test:integration
# ---------------------------------------------------------------- #
# End-to-end, sharded across four runners.
# ---------------------------------------------------------------- #
e2e:
needs: check
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false # one shard failing must not hide the others
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
# Cache the browsers; the download is ~300MB and dominates a short run.
- name: Cache Playwright browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- run: npx playwright install --with-deps chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
- run: npx playwright install-deps chromium
if: steps.playwright-cache.outputs.cache-hit == 'true'
- run: npm run build
- run: npx playwright test --shard=${{ matrix.shard }}/4
env:
BASE_URL: http://127.0.0.1:3000
- uses: actions/upload-artifact@v4
if: always()
with:
name: blob-report-${{ matrix.shard }}
path: blob-report/
retention-days: 7
# One report from all four shards.
e2e-report:
needs: e2e
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- uses: actions/download-artifact@v4
with: { path: all-blobs, pattern: blob-report-*, merge-multiple: true }
- run: npx playwright merge-reports --reporter=html,json ./all-blobs
- uses: actions/upload-artifact@v4
with: { name: playwright-report, path: playwright-report/, retention-days: 14 }
# A test that only passed on retry is not a passing test.
- name: Fail on flaky
run: |
flaky=$(jq '[.suites[].specs[]? | select(.tests[].status == "flaky")] | length' report.json)
echo "flaky: $flaky"
test "$flaky" -eq 0See Playwright sharding for the sharding and merge mechanics in detail, and flaky tests for why the last step matters.
#Matrix builds
strategy:
fail-fast: false
matrix:
node: [20, 22]
os: [ubuntu-latest, windows-latest]
include:
- node: 22
os: ubuntu-latest
coverage: true # collect coverage once, not six times
exclude:
- node: 20
os: windows-latest
runs-on: ${{ matrix.os }}fail-fast: false is almost always what you want in a test matrix: the
default cancels every other combination on the first failure, which hides
whether the problem is specific to one of them.
#Service containers
An alternative to Testcontainers when one database serves a whole job:
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: test
ports: ['5432:5432']
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5The health options are essential — without them the job starts before Postgres is accepting connections and fails intermittently, which looks like a flaky test and is not.
Testcontainers is usually the better choice anyway: the container is defined in the test code, so it behaves identically on a laptop.
#Caching that actually helps
# Node modules: use setup-node's built-in cache, keyed on the lockfile.
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
# Anything else: key on the lockfile, restore-keys for a partial hit.
- uses: actions/cache@v4
with:
path: ~/.gradle/caches
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-${{ runner.os }}-A cache keyed on something that changes every commit is a cache that never hits and still costs upload time.
#Required checks
Making the pipeline a gate rather than a notification:
# A single job that depends on everything, so branch protection needs one
# entry rather than being updated whenever a job is added.
all-green:
needs: [check, unit, integration, e2e-report]
if: always()
runs-on: ubuntu-latest
steps:
- run: |
results='${{ join(needs.*.result, " ") }}'
echo "$results"
[[ "$results" != *failure* && "$results" != *cancelled* ]]Set all-green as the only required status check in branch protection.
#Reading failures
Three settings turn "the build is red" into "I know why":
if: always()on artefact uploads. A trace that is only uploaded on success is useless.- A test reporter that annotates the pull request — see test reporting.
trace: 'on-first-retry'in the Playwright config, so the trace viewer has something to open.
Common questions
- Should I use services containers or Testcontainers in GitHub Actions?
- Testcontainers, generally. The runner already has Docker, the containers are defined in the test code so they work identically on a laptop, and there is no separate health-check scripting in YAML. Service containers are fine for a single database shared by a whole job.
- How do I stop a workflow from running twice on a pull request?
- Trigger on push to your default branch and on pull_request only. Triggering on push for every branch plus pull_request runs everything twice for any branch with an open PR.
- How do I make CI fail when a test only passes on retry?
- Use a reporter that distinguishes the two. Playwright reports a retried-then-passed test as "flaky"; parse the JSON report and fail the job when the flaky count exceeds your threshold, rather than letting it pass silently.
Runnable samples for this page
last test results ↗- YAML
.github/workflows
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Playwright ShardingSplitting a browser suite across machines and merging the reports back — the mechanics, the prerequisites, and how to choose a shard count that is actually faster.
- Testing in Azure DevOps PipelinesA full YAML pipeline with stages, jobs, parallel test slicing and the best built-in test reporting of any CI platform.
- Testing in JenkinsDeclarative pipelines, parallel stages, agent control and JUnit reporting — how to run a modern test suite on the CI server you probably inherited.
- Code CoverageWhat the percentage measures, why it is a finding tool rather than a target, how to collect it in each ecosystem, and how to gate on it without causing harm.
- Test ReportingGetting results out of the runner and in front of people — JUnit XML, pull request annotations, per-test history, and the numbers worth publishing.
- TestcontainersRunning real databases, brokers and services as disposable containers from inside your test suite — the pattern that made integration testing cheap.
- Testing in Bitbucket PipelinesThe simplest CI model of the five — steps, caches, services and parallel groups — with the hard limits you need to design around.
- Testing in TeamCityBuild chains, real-time test reporting and the best flaky-test detection of any CI platform — configured as Kotlin DSL rather than clicked together.