Skip to content
End To End Tester

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.

yaml
# .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 0

See Playwright sharding for the sharding and merge mechanics in detail, and flaky tests for why the last step matters.

#Matrix builds

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

yaml
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 5

The 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

yaml
# 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:

yaml
# 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":

  1. if: always() on artefact uploads. A trace that is only uploaded on success is useless.
  2. A test reporter that annotates the pull request — see test reporting.
  3. 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?