Skip to main content

Automating Visual Regression Testing in GitHub Actions

A monitor displaying lines of code in a dark editor
Apr 4, 20263 min readGitHub Actions, Playwright, Visual Regression, CI/CD, E2E Testing

The problem

Every UI change on this portfolio, whether it's a new section label, a font-size tweak, or a layout restructure, risks unintended visual side effects on other pages. Manual visual QA doesn't scale, especially when a single PR might touch shared components that show up across dozens of routes.

I needed a pipeline that:

  1. Catches visual regressions automatically on every PR
  2. Updates baselines with one click when changes are intentional
  3. Re-runs CI automatically after baseline updates (no manual re-trigger)
  4. Works with Nx affected so only changed projects get tested

The architecture

The pipeline is two jobs in a single workflow file, toggled by a workflow_dispatch input:

on:
  pull_request:
    branches-ignore: [main]
  workflow_dispatch:
    inputs:
      update-snapshots:
        description: 'Regenerate VR baselines'
        type: boolean
        default: false
      snapshot-branch:
        description: 'Branch to update snapshots on'
        type: string

Normal CI (update-snapshots: false): Runs lint, typecheck, unit tests, build, and E2E tests including visual regression. If screenshots don't match baselines, Playwright fails the job.

Snapshot update (update-snapshots: true): Skips normal CI entirely. Builds the app, regenerates all Playwright screenshots with --update-snapshots, commits them to the branch, and pushes.

The PAT token trick

Here's the subtle part. GitHub Actions commits made with GITHUB_TOKEN don't trigger any subsequent workflow runs. That's a security feature to keep you out of infinite loops, but it also means that once the snapshot job commits updated baselines, CI won't re-run to verify them.

The fix is to check out the repo with a Personal Access Token (PAT) instead:

update-snapshots:
  permissions:
    contents: write
  steps:
    - uses: actions/checkout@v5
      with:
        ref: ${{ inputs.snapshot-branch || github.ref }}
        token: ${{ secrets.PAT_TOKEN }}

When the snapshot job pushes with the PAT, GitHub treats it as a "real" push rather than a bot push and fires the CI workflow on the updated branch. That closes the loop:

  1. PR opened → CI runs → E2E fails (screenshot mismatch)
  2. Developer triggers update-snapshots workflow dispatch
  3. Snapshots regenerated → committed → pushed with PAT
  4. Push triggers CI → E2E passes → PR is green

Concurrency control

Without concurrency groups, the snapshot push in step 3 can kick off a new CI run while the old, failed one is still queued for retry, which burns runner minutes and leaves confusing status checks behind.

concurrency:
  group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

The concurrency key keeps exactly one CI run per PR active, so when the snapshot push triggers a new run, any in-progress run for the same PR gets cancelled automatically.

Playwright configuration

Visual regression tests run only on Chromium in CI (all browsers plus mobile locally), and the screenshots live alongside the test file:

apps/root-e2e/src/
├── visual-regression.spec.ts
└── visual-regression.spec.ts-snapshots/
    ├── about-chromium-linux.png
    ├── experience-detail-chromium-linux.png
    ├── project-detail-chromium-linux.png
    └── projects-listing-chromium-linux.png

The snapshots are committed to the repo rather than stashed as artifacts, so anyone can review the visual diffs right in the PR using GitHub's image comparison tools.

Nx integration

The CI pipeline leans on nx affected to test only the projects that changed:

- name: Run affected tasks
  run: yarn nx affected -t lint test build typecheck --nxBail
 
- name: Run e2e tests
  run: yarn nx affected -t e2e --nxBail

If a PR only touches the audit service, the E2E tests for the root app get skipped entirely. But if shared UI components change, both the root app and its E2E suite are marked affected, so the visual regression tests run exactly when they matter.

The commit-and-push step

The snapshot update job is careful about empty commits:

git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add apps/root-e2e/src/visual-regression.spec.ts-snapshots/
 
if git diff --cached --quiet; then
  echo "No snapshot changes to commit"
else
  git commit -m "chore(e2e): update visual regression snapshots from CI"
  git push
fi

If the snapshots haven't actually changed, maybe the test was flaky or someone already updated them locally, no commit gets created, which keeps the git history free of noise.

Results

  • One-click baseline updates: no local Playwright install needed
  • Automatic CI re-verification: PAT-based push triggers a fresh run
  • Zero wasted runner time: concurrency groups cancel stale jobs
  • Git-tracked baselines: visual diffs reviewable in PR
  • Nx-aware: E2E only runs when the app is actually affected

The pipeline has already caught a handful of regressions: a max-w-3xl that should have been max-w-5xl, an overflow-hidden quietly clipping content, and a SectionLabel addition that shifted card layouts. Each one got caught before merge, fixed in the same PR, and verified automatically.