At 2:17 AM, your phone buzzes three times. The product group chat explodes with 9 messages: "Login page is blank, nobody can get in!" You stumble to your computer barefoot, glance at the last release commit — a frontend developer changed a shared component, and nobody ran the tests.
You grab a cigarette and think through the smoke: This should never have happened. If there had been a bot that automatically ran all tests before merging, blocking merges that failed, you'd be asleep right now instead of fixing an "impossible" JS error.
Everyone knows this needs to be done, but we all think, "I'll set up automation next week." That next week usually turns into another 2:17 AM. Today we'll build this automated test pipeline with zero cost and pure GitHub Actions — and I'll clear out the pitfalls I fell into.
Problem breakdown: Why "manually running tests" equals "not running tests at all"
Frontend projects move faster and faster, but the testing stage always lags behind. The root cause is blunt:
- The project doesn't enforce running tests before PR merges, relying on word of mouth: "Did you test it?".
- Developer environments vary wildly; tests that pass locally often explode in CI.
- When setting up CI, traditional Jenkins requires server setup and maintenance that small teams can't afford. GitLab CI is nice, but if you're on GitHub, you also need to register runners — a non-trivial mental overhead.
Why not use GitHub Actions directly? It's deeply integrated with your repo, offers extremely generous free minutes for public repos (2,000 minutes per account per month), so running frontend tests costs essentially nothing for small teams. Combine it with branch protection rules that enforce checks before merge, and PRs that fail tests simply can't enter the main branch — a free quality wall.
But real-world implementation isn't that smooth. Here's my tech stack reasoning and the two pitfalls that kept me up until dawn.
Design: Why I chose this tech stack
Our frontend project: React + TypeScript, pnpm package manager, testing split into three layers:
- ESLint + Prettier static checks: catch formatting errors and unsafe code.
- Vitest unit/component tests: fast, ideal as a quick pre-commit gate.
- Playwright end-to-end tests: simulate a real browser, covering critical user paths (login, checkout, etc.).
The GitHub Actions workflow is designed as a single file with three jobs, running serial-then-partially-parallel:
PR opened/pushed → lint → unit-test → e2e-test → all jobs pass → PR mergable
Why not other solutions?
- CircleCI / Travis CI: used to be great, but free tiers have been heavily cut. Frontend e2e tests take time and burn through limits quickly.
- Jenkins: requires you to maintain servers, has massive plugin complexity; our frontend team has no capacity to babysit it.
-
Running tests in Git hooks: can block locally, but is easily skipped (
--no-verify) and can't be the only gate.
GitHub Actions' biggest advantage is declarative + zero ops. You only need a YAML file, and GitHub shoulders all the compute. For a team like ours with "no budget for CI machines," it's the optimal solution.
Core implementation: From YAML to locking down PRs
1. Basic pipeline: Automatically triggered on PRs
The following Workflow solves the "nobody runs tests" problem — as soon as someone opens a PR against main or pushes a commit, the full suite kicks in automatically.
# .github/workflows/pr-checks.yml
name: PR Checks
on:
pull_request:
branches: [main] # 所有想合入 main 的 PR 都跑
jobs:
lint-and-unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
# 使用 pnpm 必须官方 action,顺带解决缓存问题
- uses: pnpm/action-setup@v3
with:
version: 9
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm run lint
- name: Unit tests
run: pnpm run test:unit
Key decision: Use pnpm/action-setup instead of manually installing pnpm with npm i -g pnpm. The official action automatically handles pnpm version and store path and works seamlessly with caching. --frozen-lockfile ensures CI won't silently mutate the lock file, blocking "clever" workarounds.
2. End-to-end tests: Playwright with pre-cached browsers
Unit tests are fast, but they can't catch the "component was split but the page broke" bug. Playwright e2e simulates real user clicks, but installing browsers every run is slow. The following code caches browser binaries to keep e2e times reasonable.
e2e-test:
needs: lint-and-unit # 单元过了再跑贵的 e2e,省分钟
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
wi
(The original article was cut off at this point, missing the rest of the Playwright setup and the && pitfall.)
Top comments (0)