A real test pipeline is more than a single npm test command. It runs different types of tests in stages, fails fast when something breaks, tests across multiple environments, and preserves artifacts for debugging. This lesson builds a complete pipeline step by step.
Pipeline Architecture
The optimal pipeline runs tests in order of speed and cost:
Lint + Type Check (fast) → Unit Tests (fast) → Integration Tests (medium) → Build (medium) → E2E Tests (slow)Fast checks run first because there is no point waiting 10 minutes for E2E tests to discover a typo that linting would catch in 3 seconds.
The Complete Workflow
# .github/workflows/test-pipeline.yml
name: Test Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint-and-typecheck:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npx tsc --noEmit
unit-tests:
name: Unit Tests
needs: lint-and-typecheck
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npx jest --ci --coverage --testPathPattern='unit'
- name: Upload coverage
if: matrix.node-version == 20
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
integration-tests:
name: Integration Tests
needs: lint-and-typecheck
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Run integration tests
run: npx jest --ci --testPathPattern='integration'
env:
DATABASE_URL: postgres://test:test@localhost:5432/testdb
build:
name: Build
needs: [unit-tests, integration-tests]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: .next/
retention-days: 1
e2e-tests:
name: E2E Tests
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps
- uses: actions/download-artifact@v4
with:
name: build-output
path: .next/
- name: Run E2E tests
run: npx playwright test
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/Key Patterns Explained
Concurrency Control
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: trueIf you push a new commit while a previous pipeline is still running, the old run is cancelled. This prevents wasted CI minutes on outdated code.
Fail-Fast Strategy
By default, matrix builds use fail-fast: true — if one configuration fails, all other running configurations are cancelled. This saves time when a failure is not specific to one Node version:
strategy:
fail-fast: true # default
matrix:
node-version: [18, 20]Set fail-fast: false when you want all configurations to complete regardless:
strategy:
fail-fast: false
matrix:
node-version: [18, 20]Service Containers
The integration test job spins up a PostgreSQL container. GitHub Actions supports Docker service containers for databases, caches, and other dependencies:
services:
postgres:
image: postgres:16
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10sThe health-cmd ensures the database is ready before tests start. Without it, tests might run before PostgreSQL finishes initializing.
Artifact Passing Between Jobs
The build job uploads the compiled output as an artifact. The e2e-tests job downloads it instead of rebuilding. This avoids compiling twice and ensures E2E tests run against the exact same build:
# In build job
- uses: actions/upload-artifact@v4
with:
name: build-output
path: .next/
# In e2e job
- uses: actions/download-artifact@v4
with:
name: build-output
path: .next/Always Upload Reports
The if: always() condition ensures reports are uploaded even when tests fail:
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/Without this, you lose the report when you need it most — when tests fail and you need to debug.
Key Takeaways
- Structure pipeline stages by speed: lint first, E2E last.
- Use
concurrencywithcancel-in-progressto stop stale runs. - Matrix builds test across Node versions;
fail-fastcontrols cancellation behavior. - Pass build artifacts between jobs to avoid redundant compilation.
- Always upload test reports as artifacts, even on failure.