Skip to main content

Building a Test Pipeline

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: true

If 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 10s

The 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 concurrency with cancel-in-progress to stop stale runs.
  • Matrix builds test across Node versions; fail-fast controls cancellation behavior.
  • Pass build artifacts between jobs to avoid redundant compilation.
  • Always upload test reports as artifacts, even on failure.