This is Part 17 of the “Frontend Testing, Done Right” series. Browse the full series · Glossary
Tests have no power while they’re “something you run locally, sometimes.” They protect a team only once they become a checkpoint that runs on every PR. This article builds that checkpoint with GitHub Actions — a merge gate where unit tests, E2E, and accessibility checks must pass before code can merge — from a single workflow file. Never touched CI? Fine. Three minutes of vocabulary first.
If you’ve been following the series, chapter 4 starts here — everything you’ve learned about ‘what and how to verify’ becomes ‘automatic, always’, and past this chapter you’ll be the one leading testing conversations on your team. From a search? The workflow copies straight out.
Practice code: frontend-testing-lab — this part’s snapshot is pinned to the
step-17tag. The workflow lives in a single file:.github/workflows/test.yml.
What we’ll learn#
- Splitting unit+build+E2E into jobs
- Gating merges on accessibility (axe)
- Keeping failure artifacts (reports)

New to CI? Three minutes of vocabulary#
- CI (Continuous Integration): every time code lands in the repository, a server automatically builds and tests it. The device that kills “works on my machine.”
- PR (Pull Request): “please merge this change into main.” The unit at which teams review code.
- Merge gate: a rule that blocks merging until the PR’s checks pass. What we build today.
GitHub Actions is GitHub’s CI runner. One file below is all it takes. (Also: npm ci is the CI-flavored npm install — exact lockfile versions, clean install every time.)
The workflow#
The repo’s actual workflow. (.github/workflows/test.yml) Structure first — on is “when to run” (a push to main, or a PR opening), jobs is “what to run,” and each job’s steps are “the tasks in order.” Jobs run on separate machines, simultaneously — splitting unit and E2E halves the total time.
name: test
on:
push:
branches: [main]
pull_request:
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test # Vitest (unit & component)
- run: npm run typecheck # type-checks test and e2e files too
- run: npm run build # production bundle
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium # browser on the CI machine
- run: npm run e2e # E2E + axe accessibility scan
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }} # keep the report even on failure (for the autopsy)
with:
name: playwright-report
path: playwright-report/
retention-days: 7The point: the e2e job includes the axe scan, so this pipeline catches accessibility violations too.
Push, and the Actions tab ends like this — this repo’s actual run record.
✓ main test · 29518543463
JOBS
✓ unit in 23s
✓ e2e in 42sBecause unit and e2e ran on separate machines at the same time, the total isn’t their sum (65s) but the longer one (42s). Splitting the jobs paid off.
Making it a gate#
Repository settings → Branch protection: mark these checks required, and no green means no merge. Accessibility violations and broken tests both stop before deploy.

This repo also carries a deploy workflow (deploy-pages.yml): when code reaches main, the live demo refreshes automatically. A small pipeline where only gate-cleared code stands before users.
Failure as evidence#
With upload-artifact keeping the Playwright report, a CI failure means downloading the report from the Actions page and inspecting the trace — “it only breaks on CI” stops being a mystery.
There’s a trap here, and I stepped on it myself. The playwright-report/ folder only exists if the html reporter is configured. Leave that out and CI ends empty-handed with this warning:
! No files were found with the provided path: playwright-report/.
No artifacts will be uploaded.This repo got exactly that warning at first. Hence one line in the config. (repo playwright.config.ts)
reporter: process.env.CI
? [['list'], ['html', { open: 'never' }]] // CI: generate the HTML report for the artifact
: 'list',Terminal output only for local runs, an uploadable HTML report on CI — built only where it’s needed.
The moment accessibility rises from ‘occasional check’ to ‘merge requirement’, team culture shifts. The pipeline nags so nobody has to.
One-page summary#
- CI = the server checks every push · PR = a merge request · merge gate = no pass, no merge
- Workflow structure:
on(when) →jobs(what, in parallel) →steps(in order) — drop it in.github/workflows/and done - Separate jobs for unit and E2E run in parallel — and the E2E job carries the axe scan, gating accessibility too
- Branch protection must mark the checks required for the gate to be real
- Failure as evidence:
upload-artifactkeeps the Playwright report, traces included, for remote autopsies - Mind the trap:
playwright-report/only exists with the html reporter configured — otherwise upload-artifact ends empty-handed with “No files were found”
The machine keeps watch now#
Automation stands. Next: reading its results — coverage and quality metrics.
The first time CI blocks your own PR with a red light, it stings — but that’s production not blowing up. CI is the guardian angel everyone resents.
Level up: you can raise a merge gate that guards your team’s quality automatically.
Next up: reading coverage and quality metrics
Bumped into an unfamiliar term? The glossary has them all, one line each.
