QA Interview Hub · Knowledge base · Quizzes
CI/CD
GitHub Actions for a test framework
A workflow skeleton for Playwright in GitHub Actions
A workflow is a YAML file in .github/workflows/. It consists of jobs that by default run in parallel, each on its own runner. A job is a sequence of steps on one machine (a shared file system). A step is either run: (a shell command) or uses: (a ready-made action).
Triggers (on:): pull_request (a PR), push (with a branch filter), schedule (cron, always in UTC), workflow_dispatch (a manual run).
name: e2e
on:
pull_request:
push:
branches: [main]
schedule:
- cron: '0 2 * * *'
workflow_dispatch:
jobs:
e2e:
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: npx playwright install --with-deps chromium
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 14
What matters here: timeout-minutes protects against hangs (the default is 360 minutes); npm ci installs dependencies strictly from the lock file; if: ${{ !cancelled() }} uploads the report even after the tests failed, because by default a step has the condition success() and is skipped after a red step.
Cache, matrix, concurrency and sharding
Dependency cache. actions/setup-node@v4 with cache: npm caches the global npm cache (~/.npm) under a key derived from package-lock.json; npm ci still runs, but takes packages from disk. Caching node_modules itself is usually discouraged. For your own caches use actions/cache@v4 with a key that changes with the lock file: key: pw-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}. Playwright browsers live in ~/.cache/ms-playwright; after a cache hit the system dependencies still must be installed (npx playwright install-deps) because they are not cached.
Matrix. One job multiplied by parameters: browsers, Node versions, shard numbers. Each combination is a separate job on a separate runner. By default fail-fast: true: when one combination fails, the others still running are cancelled. For the full picture set fail-fast: false.
jobs:
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
# ... checkout, setup-node, npm ci, install browsers
- run: npx playwright test --shard=${{ matrix.shard }}/4 --reporter=blob
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: blob-report-${{ matrix.shard }}
path: blob-report
retention-days: 1
In upload-artifact@v4 artifact names within a run must be unique, hence the shard number in the name.
Concurrency. So that a new push to a PR cancels the outdated run:
concurrency:
group: e2e-${{ github.ref }}
cancel-in-progress: true
For main cancelling is usually not enabled (cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}) so as not to lose the result of a run on merged commits.
Secrets, service containers, GitLab CI and Jenkins
Secrets. They are stored in Settings → Secrets and variables, read in a workflow as ${{ secrets.API_TOKEN }} and masked in logs. Pass them through env: only to the step that needs them. For PRs from forks secrets are not passed (protection against theft); the dangerous pull_request_target with a checkout of foreign code should be avoided. Non-sensitive settings (environment URL, flags) are vars, not secrets.
Service containers. For integration tests that need a database, add services: to the job: the container starts before the steps and stops after the job.
jobs:
api-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: app_test
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/app_test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
The --health-* options make the job wait until the DB is really ready. If the job itself runs in a container (container:), the service is reached by its name (postgres); on a runner, via localhost and the port mapping.
Other systems: the same ideas. GitLab CI: .gitlab-ci.yml, stages, parallel: 4 instead of a matrix, cache:, artifacts: when: always, services:. Jenkins: a Jenkinsfile with stages and parallel, post { always { ... } } for reports, agents instead of runners. The syntax differs, the approach does not: triggers, cache, parallelism, artifacts, secrets.