Running Tests in CI Pipelines
Running tests in a CI pipeline means describing, in a file the CI server reads, when tests run, on which machines, with which dependencies and what they leave behind. In GitHub Actions that file is a YAML workflow in .github/workflows/; GitLab CI (.gitlab-ci.yml) and Jenkins (Jenkinsfile) express the same ideas. One workflow can, for example, run 25 @smoke browser tests in Chromium on every pull request and all 180 tests in three browsers every night.
The CI/CD Testing guide showed what a pipeline produces: JUnit XML results, artifacts such as traces and screenshots, and suites split into parallel shards. This lesson is the hands-on side: the file that makes it happen.
Why it matters
Most tests in a CI team are run by the pipeline, so the workflow decides what evidence exists. A check that runs the wrong set, or a browser suite that only ever runs in Chromium, still shows green; a check that takes 40 minutes is one people stop waiting for.
Testers usually decide what the suite covers, so they need to change the workflow themselves: add a browser, move a slow suite to the nightly run, keep a failed job's report.
Anatomy of a pipeline file
YAML has three building blocks. A mapping is key: value; a list is lines starting with - , or the short form [chromium, firefox]; nesting is shown only by indentation with spaces. Tabs are not allowed in indentation, and one space too many or too few can move a key into another block. Text after # is a comment, and a value starting with * must be quoted, as in the cron '*/30 * * * *'.
The on key lists the triggers: pull_request when a pull request is opened, reopened or gets new commits, push filtered to main, and schedule with a cron expression. Its five fields are minute, hour, day of month, month and day of week, so '0 1 * * *' means 01:00 every day; schedules run on the default branch, in UTC unless the entry sets a timezone. Each job under jobs runs on a runner, the machine named by runs-on; a GitHub-hosted runner is a fresh virtual machine per job, so nothing survives between jobs except what you cache or upload. A job's steps run in order in one workspace: uses runs a published action, run a shell command, and a non-zero exit code fails the job.
.github/workflows/e2e.yml: one workflow, two jobs
# .github/workflows/e2e.yml name: e2e Part 1:on: pull_request: push: branches: [main] schedule: - cron: '0 1 * * *' # every night, 01:00 UTC jobs: smoke: Part 2:if: github.event_name != 'schedule' Part 3:runs-on: ubuntu-latest timeout-minutes: 15 Part 4:steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 24 Part 5:cache: npm - run: npm ci - run: npx playwright install --with-deps chromium - run: npx playwright test --project=chromium Part 6:--grep @smoke - uses: actions/upload-artifact@v7 Part 7:if: always() with: name: smoke-results path: results/ retention-days: 14 nightly: if: github.event_name == 'schedule' runs-on: ubuntu-latest timeout-minutes: 45 Part 8:strategy: fail-fast: false matrix: browser: [chromium, firefox, webkit] steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 24 cache: npm - run: npm ci - run: npx playwright install --with-deps ${{ matrix.browser }} - run: npx playwright test --project=${{ matrix.browser }} - uses: actions/upload-artifact@v7 if: always() with: Part 9:name: nightly-${{ matrix.browser }} path: results/ retention-days: 14
- 1Triggers:
onPull requests, pushes tomainand a nightly cron schedule in UTC start the workflow. - 2Job conditionThe
smokejob runs for pull requests and pushes, never for the schedule. - 3RunnerA fresh virtual machine for each job; only caches and artifacts outlive it.
- 4StepsRun top to bottom in one workspace; a non-zero exit code fails the job.
- 5Dependency cacheRestores npm downloads while
package-lock.jsonis unchanged. - 6Pull request selectionOnly the
@smoketests, in Chromium: fast enough to gate a merge. - 7Keep the evidenceThe upload runs even after a failed test step.
- 8Browser matrixOne parallel job per browser;
fail-fast: falselets all three finish. - 9Unique artifact nameEach matrix job uploads under its own name; a repeated name in one run fails.
GitHub Actions syntax. GitLab CI and Jenkins have the same parts under other names.
Text version
# .github/workflows/e2e.yml
name: e2e
on:
pull_request:
push:
branches: [main]
schedule:
- cron: '0 1 * * *' # every night, 01:00 UTC
jobs:
smoke:
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test --project=chromium --grep @smoke
- uses: actions/upload-artifact@v7
if: always()
with:
name: smoke-results
path: results/
retention-days: 14
nightly:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npx playwright install --with-deps ${{ matrix.browser }}
- run: npx playwright test --project=${{ matrix.browser }}
- uses: actions/upload-artifact@v7
if: always()
with:
name: nightly-${{ matrix.browser }}
path: results/
retention-days: 14Try it: YAML Formatter Try it: YAML to JSON Try it: Cron Expression Explainer
Dependency caching and a browser matrix
Because each job starts clean, npm ci would download every package on every run. A dependency cache stores those downloads under a key computed from the lock file and restores them next time; when package-lock.json changes, so does the key. cache: npm on actions/setup-node caches npm's download cache, not node_modules, so npm ci still installs exactly what the lock file says, only faster, and must give the same result on a cache miss. Playwright's CI documentation advises against caching browser binaries: restoring them takes about as long as downloading them.
A matrix turns one job definition into one job per combination of values. browser: [chromium, firefox, webkit] creates three parallel jobs, each passing its value to --project, which must match a project name in the Playwright configuration. Adding shard: [1, 2] makes six jobs, so add a dimension only for a real risk, such as rendering differences between engines. fail-fast: false lets every browser finish, showing whether a failure belongs to one engine or all.
Worked example: pull request or nightly
A shop team has 1,240 unit, 310 API and 180 browser tests, 25 tagged @smoke for sign-in, search, cart and payment. The browser suite takes about 35 minutes per browser; pull requests have a 15-minute budget. The team sorts each suite by speed, stability, the risk it covers and who acts on a failure. The workflow above implements the browser rows.
- Pull requests get fast, stable checks on the key journeys; the author waits for them, so they gate the merge.
- Pushes to
mainrepeat that set: two pull requests that passed alone can break together. - The nightly run carries the slow, broad suites; a named owner reads it each morning and checks the commits since the last green night.
- Quarantined flaky tests leave the pull request set and run only at night, reporting without blocking anyone. In the nightly job, the main test step adds
--grep-invert @quarantine, which skips tests with that tag; a second step runs--grep @quarantinewithcontinue-on-error: true, so its failure does not fail the job, andif: always(), so it still runs after the main step has failed.
Suite Tests Time* Pull request Push to main Nightly 01:00 UTC
Lint + unit 1,240 3 min gate run run
API tests 310 6 min gate run run
E2E @smoke, Chromium 25 4 min gate run -
E2E full, 3 browsers 3 x 180 35 min - - run, 3 parallel jobs
gate: must pass before the merge
* wall-clock time per job; illustrative figuresTest reports, artifacts and parallel jobs
Configure the test runner to write results for machines and people, then make the job keep them. The excerpt below writes JUnit XML for the CI server, an HTML report for people, and traces and screenshots of failed tests. GitLab reads the XML through artifacts:reports:junit, Jenkins through the JUnit plugin's junit step; elsewhere, check the platform's documentation.
Artifacts are files a job uploads so they outlive its runner. Upload them with if: always(): by default a step after a failed step is skipped, and failed runs are when you need them. GitHub Actions needs unique artifact names within a run, hence nightly-${{ matrix.browser }}; retention-days sets how long they stay. Keep personal data and secrets out of them.
Jobs without needs run in parallel, as do matrix jobs, as far as runners allow; max-parallel caps a matrix. Inside a job, Playwright's CI guidance prefers one worker for stable runs, scaling out with shards instead. Merge the per-job reports, for example with Playwright's blob reporter and npx playwright merge-reports, so the gate sees one result.
// playwright.config.ts (excerpt)
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
workers: process.env.CI ? 1 : undefined,
outputDir: 'results/artifacts', // traces, screenshots
reporter: [
['junit', { outputFile: 'results/junit.xml' }], // for the CI server
['html', { outputFolder: 'results/html', open: 'never' }], // for people
],
use: { trace: 'retain-on-failure', screenshot: 'only-on-failure' },
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});Common mistakes
- Indenting with tabs, which YAML rejects, or with the wrong number of spaces, which may still be valid YAML but mean something else. Format the file and read it as JSON before pushing.
- Uploading reports without
if: always(), so artifacts exist only for green runs. - Running every test in every browser on every pull request until people merge without waiting.
- A nightly run with no owner: a red night read a week later is no safety net.
- Trusting a green job without checking the test count: a test tagged
@smoekby mistake silently drops out of the--grep @smokeset. - Caching anything that decides the result, such as test output, or a cache key that ignores the lock file.
The QA perspective
Review the workflow file in pull requests like code. For each change, ask which events trigger it, which tests run on a pull request and which only at night, whether every test job keeps its reports on failure, and whether the check still fits the budget.
Record the split in the test strategy or README with the nightly owner, and move tests between columns as the suite grows. When a nightly run fails, report the run, the browser and the commit range to check.
Practice exercise
Review a test workflow and split its suites
Your browser suite has 140 tests: 20 tagged @smoke, 110 regular and 10 flaky ones tagged @quarantine. One browser job takes 2 minutes for the smoke set and 28 for everything; pull request checks must finish within 10 minutes. Two past defects appeared only in WebKit. The team works in Riyadh (UTC+3).
Decide what runs on pull requests, on pushes to main and at night, then review the workflow below.
name: e2e
on:
pull_request:
schedule:
- cron: '0 13 * * *'
jobs:
e2e:
runs-on: ubuntu-latest
strategy:
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm ci
- run: npx playwright install --with-deps ${{ matrix.browser }}
- run: npx playwright test --project=${{ matrix.browser }}
- uses: actions/upload-artifact@v7
with:
name: e2e-report
path: results/- Table the suites against the three triggers: gating, report-only or not run.
- Check the triggers, the cron time and the job conditions.
- Check caching, the matrix and the artifacts.
Show the model answer
As written, every pull request waits about 28 minutes and gates on quarantined tests. Instead, pull requests and pushes to main run the @smoke tests in Chromium and WebKit, two parallel jobs of about 2 minutes, gating the merge. The nightly run covers all 130 non-quarantined tests in three browsers (--grep-invert @quarantine), plus the quarantined ones (--grep @quarantine) in a step with continue-on-error: true and if: always(), so they report without failing the job and still run after a red main step. A named owner reads the run each morning.
Add push with branches: [main]. '0 13 * * *' is 16:00 in Riyadh, mid-afternoon rather than at night; '0 22 * * *' runs at 01:00 local time, as does '0 1 * * *' with timezone: Asia/Riyadh.
Split into a smoke job (if: github.event_name != 'schedule', matrix [chromium, webkit], --grep @smoke) and a nightly job (if: github.event_name == 'schedule', all three browsers); add timeout-minutes, fail-fast: false (otherwise the first failure cancels the other browsers) and cache: npm.
Add if: always() to the upload and name it per job and browser, such as nightly-${{ matrix.browser }}: three jobs uploading e2e-report collide.
Frequently asked questions
Do I need to know YAML well to work with pipelines?
You need the basics in this lesson: mappings, lists, indentation and quoting. Most workflow changes a tester makes are adding a step, a matrix value or a trigger. Validate the file with a YAML formatter before pushing, and read the platform's syntax reference for any key you have not used before.
Should end-to-end tests run on every pull request?
A small, stable set covering the critical journeys should, usually in one browser, because it catches the most damaging regressions while the author is still working on the change. The full suite and the other browsers usually belong to a merge or nightly run, unless the suite is fast enough to fit the pull request budget.
Why did my scheduled workflow not run when I expected?
In GitHub Actions, schedules run on the default branch and use UTC unless the entry sets a timezone, so a schedule added on a feature branch does nothing until it is merged, and the time can be hours away from your local clock. Scheduled runs can also start late, or be dropped, under high load; the start of every hour is a busy time, so a minute such as 17 instead of 0 makes a delay less likely. In a public repository, schedules are disabled after 60 days without repository activity.
What is the difference between a cache and an artifact?
A cache is an optimisation shared across runs, such as downloaded dependencies keyed on the lock file; it may be missing, and the job must still work. An artifact is an output of one run, such as reports, traces and screenshots, kept for people to download and for later jobs to use.
References
- GitHub Actions documentationHow workflows, jobs, runners and artifacts are defined in GitHub Actions — where automated tests run on each push or pull request.
- Playwright DocumentationOfficial documentation for Playwright, an end-to-end test framework that drives Chromium, WebKit and Firefox.
- YAML Ain't Markup Language (YAML™) version 1.2.2The YAML 1.2 data model and syntax, and its relationship to JSON (every JSON document is valid YAML 1.2).
- Unit test reports — GitLab CI/CDPublishing JUnit XML results as pipeline artifacts so failed tests appear in the merge request and can be compared between runs.
Practise with Mutqan tools
Format and validate YAML with consistent indentation and quoting.
Convert YAML documents to JSON with error reporting.
Translate any cron expression into plain English and list upcoming run times.
Build cron expressions visually and preview the next run times.
Pretty-print XML with configurable indentation, and minify it back.
Go deeper
Your progress on this topic
Not started
Mark the topic complete when you have studied it and done the exercise.
Sign in to track your progress
Mark topics complete, continue where you left off and unlock specialisation exams.