Configure GitHub Actions to run integration tests with PostgreSQL service containers, run migrations automatically, publish test results, and gate pull request merges on passing integration tests.
Running integration tests locally is straightforward. Running them reliably in CI requires orchestrating a database service, running schema migrations, managing environment variables, and ensuring the test runner exits cleanly. GitHub Actions provides all the primitives needed — service containers, health checks, artifact upload, and job dependencies.
This lesson builds a complete CI workflow for a Node.js application with unit tests, integration tests, and test coverage reporting. The workflow runs unit tests first for fast feedback, then integration tests against a real PostgreSQL container, and finally uploads results for failed run diagnosis.
A well-structured CI workflow separates concerns into distinct jobs that communicate through artifacts and job dependencies.
1# .github/workflows/ci.yml
2name: CI
3
4on:
5 push:
6 branches: [main, develop]
7 pull_request:
8 branches: [main]
9
10concurrency:
11 group: ${{ github.workflow }}-${{ github.ref }}
12 cancel-in-progress: true # cancel outdated runs on force-push
13
14jobs:
15 # Job 1: fast feedback — runs in ~30 seconds
16 unit-tests:
17 name: Unit Tests
18 runs-on: ubuntu-latest
19 steps:
20 - uses: actions/checkout@v4
21 - uses: actions/setup-node@v4
22 with:
23 node-version: '20'
24 cache: 'npm'
25 - run: npm ci
26 - run: npm run test:unit -- --coverage --coverageReporters=lcov
27 env:
28 NODE_ENV: test
29 - uses: actions/upload-artifact@v4
30 with:
31 name: unit-coverage
32 path: coverage/lcov.info
33
34 # Job 2: integration tests — runs after unit tests
35 integration-tests:
36 name: Integration Tests
37 runs-on: ubuntu-latest
38 needs: unit-tests # only run if unit tests pass
39 # ... (see next concept)GitHub Actions service containers run as Docker containers alongside the job. The health check ensures Postgres is ready before tests start.
1 integration-tests:
2 name: Integration Tests
3 runs-on: ubuntu-latest
4 needs: unit-tests
5 timeout-minutes: 20
6
7 services:
8 postgres:
9 image: postgres:16-alpine
10 env:
11 POSTGRES_USER: testuser
12 POSTGRES_PASSWORD: testpassword
13 POSTGRES_DB: testdb
14 ports:
15 - 5433:5432 # map host 5433 to container 5432
16 options: >
17 --health-cmd pg_isready
18 --health-interval 5s
19 --health-timeout 5s
20 --health-retries 10
21 --health-start-period 10s
22
23 env:
24 TEST_DATABASE_URL: postgresql://testuser:testpassword@localhost:5433/testdb
25 JWT_SECRET: ${{ secrets.CI_JWT_SECRET }}
26 NODE_ENV: test
27
28 steps:
29 - uses: actions/checkout@v4
30
31 - uses: actions/setup-node@v4
32 with:
33 node-version: '20'
34 cache: 'npm'
35
36 - name: Install dependencies
37 run: npm ci
38
39 - name: Wait for PostgreSQL
40 run: |
41 until pg_isready -h localhost -p 5433 -U testuser; do
42 echo 'Waiting for PostgreSQL...'
43 sleep 2
44 done
45 echo 'PostgreSQL is ready'
46
47 - name: Run database migrations
48 run: npx prisma migrate deploy
49 env:
50 DATABASE_URL: ${{ env.TEST_DATABASE_URL }}
51
52 - name: Run integration tests
53 run: npm run test:integration -- --forceExit --testResultsProcessor=jest-junit
54 env:
55 TEST_DATABASE_URL: ${{ env.TEST_DATABASE_URL }}
56 JEST_JUNIT_OUTPUT_DIR: test-results
57 JEST_JUNIT_OUTPUT_NAME: integration-results.xmlUpload test results and coverage so failed runs can be diagnosed without re-running locally.
1 - name: Upload test results
2 uses: actions/upload-artifact@v4
3 if: always() # upload even when tests fail
4 with:
5 name: integration-test-results
6 path: test-results/integration-results.xml
7 retention-days: 7
8
9 - name: Upload integration coverage
10 uses: actions/upload-artifact@v4
11 if: success()
12 with:
13 name: integration-coverage
14 path: coverage/
15 retention-days: 3
16
17 - name: Publish test results to PR
18 uses: EnricoMi/publish-unit-test-result-action@v2
19 if: always()
20 with:
21 files: test-results/*.xml
22 comment_mode: always
23 check_name: Integration Test Results
24
25# Install jest-junit for XML output:
26# npm install --save-dev jest-junit
27
28# jest.integration.config.ts addition:
29// reporters: [
30// 'default',
31// ['jest-junit', { outputDirectory: 'test-results', outputName: 'integration-results.xml' }]
32// ]Enforce coverage thresholds in CI and publish coverage trends to Codecov for historical tracking.
1 coverage-check:
2 name: Coverage Gate
3 runs-on: ubuntu-latest
4 needs: [unit-tests, integration-tests]
5
6 steps:
7 - uses: actions/checkout@v4
8
9 - uses: actions/download-artifact@v4
10 with:
11 name: unit-coverage
12 path: coverage/unit
13
14 - name: Upload to Codecov
15 uses: codecov/codecov-action@v4
16 with:
17 token: ${{ secrets.CODECOV_TOKEN }}
18 files: coverage/unit/lcov.info
19 flags: unit
20 fail_ci_if_error: true
21
22 - name: Enforce coverage threshold
23 run: |
24 COVERAGE=$(cat coverage/unit/lcov.info | grep -oP 'LF:\K[0-9]+' | paste -sd+ | bc)
25 COVERED=$(cat coverage/unit/lcov.info | grep -oP 'LH:\K[0-9]+' | paste -sd+ | bc)
26 PCT=$(echo "scale=1; $COVERED * 100 / $COVERAGE" | bc)
27 echo "Coverage: ${PCT}%"
28 if (( $(echo "$PCT < 80" | bc -l) )); then
29 echo "Coverage ${PCT}% is below the 80% threshold"
30 exit 1
31 fi
32
33# Alternative: use Jest's built-in threshold (simpler):
34# In jest.config.ts:
35# coverageThreshold: { global: { branches: 80, lines: 80 } }
36# This causes jest to exit with code 1 if thresholds are not met,
37# which automatically fails the CI step.Configure branch protection rules to require all CI checks before merging.
1# .github/workflows/ci.yml — add a final summary job
2 ci-summary:
3 name: CI Summary
4 runs-on: ubuntu-latest
5 needs: [unit-tests, integration-tests, coverage-check]
6 if: always()
7 steps:
8 - name: Check all jobs passed
9 run: |
10 if [[ "${{ needs.unit-tests.result }}" != "success" ]] || \
11 [[ "${{ needs.integration-tests.result }}" != "success" ]] || \
12 [[ "${{ needs.coverage-check.result }}" != "success" ]]; then
13 echo "One or more CI jobs failed"
14 exit 1
15 fi
16 echo "All CI checks passed"1GitHub branch protection settings (Settings → Branches → main):
2☑ Require a pull request before merging
3☑ Require status checks to pass before merging
4 Required checks:
5 - Unit Tests
6 - Integration Tests
7 - Coverage Gate
8 - CI Summary
9☑ Require branches to be up to date before merging
10☑ Do not allow bypassing the above settings1// Local pre-push hook to catch failures early
2// .husky/pre-push
3#!/bin/sh
4npm run test:unit -- --passWithNoTests
5if [ $? -ne 0 ]; then
6 echo 'Unit tests failed — push aborted'
7 exit 1
8fi
9
10// package.json
11{
12 "scripts": {
13 "test:unit": "jest --config jest.config.ts",
14 "test:integration": "jest --config jest.integration.config.ts --runInBand --forceExit",
15 "test:ci": "npm run test:unit && npm run test:integration"
16 }
17}A production-ready GitHub Actions CI workflow for a monorepo with separate unit and integration test jobs, PostgreSQL service, coverage reporting, and PR status checks.
1# .github/workflows/ci.yml
2name: CI
3
4on:
5 push:
6 branches: [main, develop]
7 pull_request:
8 branches: [main]
9
10concurrency:
11 group: ${{ github.workflow }}-${{ github.ref }}
12 cancel-in-progress: true
13
14jobs:
15 lint-and-typecheck:
16 name: Lint & Type Check
17 runs-on: ubuntu-latest
18 steps:
19 - uses: actions/checkout@v4
20 - uses: actions/setup-node@v4
21 with: { node-version: '20', cache: 'npm' }
22 - run: npm ci
23 - run: npm run lint
24 - run: npm run typecheck
25
26 unit-tests:
27 name: Unit Tests
28 runs-on: ubuntu-latest
29 steps:
30 - uses: actions/checkout@v4
31 - uses: actions/setup-node@v4
32 with: { node-version: '20', cache: 'npm' }
33 - run: npm ci
34 - name: Run unit tests with coverage
35 run: npm run test:unit -- --coverage --coverageReporters=text --coverageReporters=lcov
36 env:
37 NODE_ENV: test
38 - uses: actions/upload-artifact@v4
39 if: always()
40 with:
41 name: unit-coverage
42 path: coverage/lcov.info
43
44 integration-tests:
45 name: Integration Tests
46 runs-on: ubuntu-latest
47 needs: [lint-and-typecheck, unit-tests]
48 timeout-minutes: 15
49
50 services:
51 postgres:
52 image: postgres:16-alpine
53 env:
54 POSTGRES_USER: testuser
55 POSTGRES_PASSWORD: testpassword
56 POSTGRES_DB: developerbootstrap_test
57 ports:
58 - 5433:5432
59 options: >
60 --health-cmd pg_isready
61 --health-interval 5s
62 --health-timeout 5s
63 --health-retries 10
64
65 redis:
66 image: valkey/valkey:8-alpine
67 ports:
68 - 6380:6379
69 options: --health-cmd "redis-cli ping" --health-interval 5s --health-retries 5
70
71 env:
72 NODE_ENV: test
73 TEST_DATABASE_URL: postgresql://testuser:testpassword@localhost:5433/developerbootstrap_test
74 REDIS_URL: redis://localhost:6380
75 JWT_SECRET: ci-test-jwt-secret-minimum-32-chars
76 JWT_REFRESH_SECRET: ci-test-refresh-secret-minimum-32c
77 STRIPE_SECRET_KEY: sk_test_placeholder
78 SENDGRID_API_KEY: SG.placeholder
79
80 steps:
81 - uses: actions/checkout@v4
82
83 - uses: actions/setup-node@v4
84 with: { node-version: '20', cache: 'npm' }
85
86 - run: npm ci
87
88 - name: Run Prisma migrations
89 run: npx prisma migrate deploy
90 env:
91 DATABASE_URL: ${{ env.TEST_DATABASE_URL }}
92
93 - name: Run integration tests
94 run: npm run test:integration
95 env:
96 TEST_DATABASE_URL: ${{ env.TEST_DATABASE_URL }}
97
98 - uses: actions/upload-artifact@v4
99 if: failure()
100 with:
101 name: integration-test-failure-logs
102 path: |
103 test-results/
104 logs/
105 retention-days: 3
106
107 all-checks-pass:
108 name: All Checks Pass
109 runs-on: ubuntu-latest
110 needs: [lint-and-typecheck, unit-tests, integration-tests]
111 if: always()
112 steps:
113 - name: Verify all jobs succeeded
114 run: |
115 RESULTS='${{ toJSON(needs.*.result) }}'
116 if echo "$RESULTS" | grep -q '"failure"\|"cancelled"'; then
117 echo 'One or more required checks failed'
118 exit 1
119 fi
120 echo 'All checks passed — safe to merge'This workflow has four jobs: lint-and-typecheck (fast gate, ~1 min), unit-tests (~2 min with coverage), integration-tests (needs both to pass first, ~5-8 min with real Postgres + Valkey), and all-checks-pass (a summary job that branch protection requires). The concurrency group with cancel-in-progress prevents queuing of multiple runs on force-push. Artifacts are uploaded only on failure to save storage. The all-checks-pass job is what you add to branch protection — it fails if any of its dependencies fail or are cancelled.
With integration testing complete, the next module covers end-to-end testing with Playwright: automating a real browser, writing page object models, testing authentication flows, and running E2E tests in CI.
Your progress is saved in this browser, on this device only.