A CI pipeline that runs tests on every commit is the safety net that makes continuous deployment safe. Without it, “it works on my machine” becomes the only assurance you have before deploying. The blog application’s CI pipeline runs all three test layers: backend pytest (fast, in parallel with frontend), frontend Vitest (fast), and Playwright E2E (slow, run only on pushes to main or on pull requests). Coverage reports are uploaded as CI artifacts so the team can review coverage trends over time.
GitHub Actions CI Pipeline
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
# ── Backend tests ─────────────────────────────────────────────────────────
backend:
name: Backend Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_pass
POSTGRES_DB: blog_test
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-retries 5
env:
DATABASE_URL: postgresql://test_user:test_pass@localhost/blog_test
SECRET_KEY: ci-secret-key-not-for-production
ENVIRONMENT: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements*.txt') }}
- run: pip install -r requirements.txt
- name: Run backend tests with coverage
run: |
pytest \
--cov=app \
--cov-report=xml:coverage-backend.xml \
--cov-report=term-missing \
--cov-fail-under=80 \
-v
- name: Upload backend coverage
uses: actions/upload-artifact@v4
with:
name: backend-coverage
path: coverage-backend.xml
# ── Frontend unit + component tests ──────────────────────────────────────
frontend:
name: Frontend Tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- name: Cache node_modules
uses: actions/cache@v4
with:
path: frontend/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('frontend/package-lock.json') }}
- run: npm ci
- name: Run Vitest with coverage
run: |
npx vitest run \
--coverage \
--coverage.reporter=lcov \
--coverage.reporter=text
- name: Upload frontend coverage
uses: actions/upload-artifact@v4
with:
name: frontend-coverage
path: frontend/coverage/lcov.info
# ── E2E tests (runs after backend + frontend pass) ────────────────────────
e2e:
name: E2E Tests
runs-on: ubuntu-latest
needs: [backend, frontend] # only run if unit tests pass
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_pass
POSTGRES_DB: blog_e2e
ports: ["5432:5432"]
env:
DATABASE_URL: postgresql://test_user:test_pass@localhost/blog_e2e
SECRET_KEY: ci-e2e-secret-key
TEST_USER_EMAIL: e2e@example.com
TEST_USER_PASSWORD: Password1
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -r requirements.txt
- run: npm ci
working-directory: frontend
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
working-directory: frontend
- name: Run Playwright E2E tests
run: npx playwright test
working-directory: frontend
- name: Upload Playwright report
if: failure() # only upload on failure
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: frontend/playwright-report/
needs: [backend, frontend] to run only after both the backend and frontend unit test jobs pass. If pytest or Vitest fails, there is no point running the slower Playwright suite — the E2E tests would almost certainly fail too. This job ordering makes the CI pipeline fail fast: unit test failures are surfaced in ~2 minutes, before the 5-10 minute E2E run starts. The if condition further restricts E2E to pull requests and pushes to main, saving CI time on feature branch commits.--cov-fail-under=80 for pytest and --coverage.thresholds.lines=80 for Vitest.SECRET_KEY: ci-secret-key-not-for-production in the workflow above is intentionally obvious — real production secrets are in GitHub repository secrets and referenced as ${{ secrets.PRODUCTION_SECRET_KEY }} only in deployment workflows, never in test workflows.Package.json Test Scripts
// frontend/package.json scripts
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug"
}
}
Common Mistakes
Mistake 1 — Running E2E on every feature branch commit (too slow)
❌ Wrong — 10-minute E2E suite on every git push.
✅ Correct — restrict E2E to pull requests and main branch with the if condition.
Mistake 2 — Hardcoding production secrets in CI workflows
❌ Wrong — SECRET_KEY: myProductionSecret123 in a public GitHub Actions file.
✅ Correct — test-only throwaway values in workflow files; real secrets in GitHub Secrets.