# Building Test and Accessibility Gates with GitHub Actions

> A pipeline where unit tests, E2E, and accessibility checks run on every PR. Set up a merge gate with GitHub Actions in one workflow file — CI beginners welcome, terms explained in three minutes.

**Published:** 2026-08-03 | **Updated:** 2026-08-03

---


> This is **Part 17** of the "Frontend Testing, Done Right" series. [Browse the full series](/en/series/frontend-testing-done-right/) · [Glossary](/en/posts/frontend-testing-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](https://github.com/IsaacEryn/frontend-testing-lab/tree/step-17) — this part's snapshot is pinned to the `step-17` tag. 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)

{{< img src="images/contents/ci-pipeline-en.png" alt="A pipeline diagram where opening a PR triggers unit and e2e jobs on separate machines simultaneously, both must pass for the merge gate to open, failures keep reports as artifacts for remote autopsy via traces, and only code reaching main deploys to the live demo" >}}

---

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

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

The point: the e2e job includes the **[axe scan](/en/posts/frontend-testing-axe-e2e/)**, so this pipeline catches accessibility violations too.

Push, and the Actions tab ends like this — this repo's actual run record.

```bash
✓ main test · 29518543463

JOBS
✓ unit in 23s
✓ e2e in 42s
```

Because 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.

{{< img src="images/contents/merge-gate-en.png" alt="How the merge gate works - a PR triggers the unit job and the e2e job in parallel; when both are green the merge button opens, the code lands on main and auto-deploys, but a single red locks the merge button. Only code clearing the test gate stands before users" >}}

This repo also carries a deploy workflow (`deploy-pages.yml`): when code reaches main, the [live demo](https://isaaceryn.github.io/frontend-testing-lab/) 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](/en/posts/frontend-testing-playwright-debug/) — "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:

```bash
! 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`)

```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-artifact` keeps 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](/en/posts/frontend-testing-glossary/) has them all, one line each.

