Unit Tests, Integration Tests, and Test Reports

A workflow that only runs npm test in one step tells you pass or fail — nothing about which layer broke, how long each layer took, or where to look first. Production pipelines split tests by kind, run the fast ones first, give slower tests the infrastructure they need, and turn raw pass/fail output into a report reviewers can read on the pull request itself.

This lesson assumes you already have a working GitHub Actions workflow that checks out code and runs a build. Here we go deeper: separating unit from integration tests, provisioning service containers like databases, and publishing structured test reports instead of a wall of console text.

Overview / How it works

Unit tests exercise a single function or class in isolation, with no network, filesystem, or database. They should run in seconds and need nothing beyond your code and a runtime. Integration tests exercise your code against a real dependency — a database, a queue, another service — and are slower, more prone to flakiness, and more expensive to run. Treating them identically in CI wastes time: a broken unit test should fail your workflow in under a minute, not after you’ve spent three minutes spinning up a database container.

Aspect Unit tests Integration tests
Dependencies None — pure code Database, cache, external service
Speed Seconds Tens of seconds to minutes
Where they run Any job, no setup Job with services: containers
Failure signal Logic bug Logic bug, wiring bug, or environment bug

Most test runners (pytest, Jest, JUnit/Maven, Go’s gotestsum) can emit results as JUnit-style XML — an ecosystem-agnostic format that predates most of these tools but became the de facto standard for CI reporting. GitHub Actions doesn’t render that XML natively in the UI; you either upload it as a build artifact for download, or use a reporting action that reads the XML and creates Checks API annotations that show up inline on the pull request diff, next to the failing assertion.

Syntax or workflow structure

A test-focused workflow usually has three kinds of jobs: one or more fast test jobs, one or more slower test jobs with service containers, and a reporting job that runs after the others and aggregates results. Set permissions: contents: read at the workflow level as the default, and grant anything stronger — like checks: write — only on the job that actually needs it.

permissions:
  contents: read

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    # fast, no services needed

  integration-tests:
    needs: unit-tests
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16

  publish-report:
    needs: [unit-tests, integration-tests]
    if: always()
    permissions:
      checks: write

Examples

Example 1: A dedicated unit test job

Unit tests need nothing but the checked-out repository and the language runtime. Emit JUnit XML so later steps can parse it, and upload it as an artifact even on failure so you can inspect exactly what broke.

name: Test

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Run unit tests
        run: npm test -- --reporter=junit --outputFile=reports/unit-results.xml

      - name: Upload unit test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: unit-test-results
          path: reports/unit-results.xml

Expected behavior: on every push to main and every pull request, the job installs dependencies, runs the unit suite, and writes reports/unit-results.xml. The if: always() on the upload step means the report is saved whether tests pass or fail, so a failed run still leaves you something to download and inspect.

Example 2: Adding an integration test job with a service container

Integration tests need a real database. GitHub Actions runs services: as Docker containers alongside your job, reachable over localhost on Linux runners. Add a health check so the workflow waits for the database to accept connections before your tests start — without one, the first few queries fail with connection-refused errors on a cold start.

  integration-tests:
    needs: unit-tests
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: app
          POSTGRES_PASSWORD: test_password_only
          POSTGRES_DB: app_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd="pg_isready -U app"
          --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
        env:
          DATABASE_URL: postgresql://app:test_password_only@localhost:5432/app_test
        run: npm run test:integration -- --reporter=junit --outputFile=reports/integration-results.xml

      - name: Upload integration test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: integration-test-results
          path: reports/integration-results.xml

Expected behavior: the job waits for the postgres container to report healthy, then runs migrations and tests against it. Because this container only exists for the lifetime of the job and is not reachable outside the runner’s network, test_password_only is fine as a plain value — it isn’t a real credential, so it doesn’t belong in GitHub Secrets. Reserve secrets for values that grant access to something outside the CI run.

Example 3: Publishing a combined test report

Add a job that runs after both test jobs, downloads their artifacts, and publishes annotations on the pull request using a reporting action. This job needs checks: write, which only that job should have.

  publish-report:
    needs: [unit-tests, integration-tests]
    if: always()
    runs-on: ubuntu-latest
    permissions:
      checks: write
      pull-requests: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          pattern: '*-test-results'
          merge-multiple: true
          path: reports

      - name: Publish test report
        uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
        with:
          name: Test Results
          path: 'reports/*.xml'
          reporter: java-junit
          fail-on-error: true

Expected behavior: even if a test job fails, if: always() lets this job still run and publish results. The action reads every JUnit XML file, creates a Check Run with pass/fail counts, and attaches inline annotations at the exact file and line of each failing assertion. Pinning the action to a commit SHA (with the version in a comment) means a compromised upstream tag can’t silently change what code executes in your pipeline; the trade-off is that you must manually bump the pin to receive fixes and features.

Step by step

  1. Confirm your test runner can emit JUnit-style XML — most runners have a flag or plugin for this (pytest: --junitxml, Jest: jest-junit reporter, Maven/Gradle: built in).
  2. Create a job for unit tests only. No services:, no external calls — just checkout, install, run, upload the XML as an artifact with if: always().
  3. Create a second job for integration tests. Add each dependency under services: with an image, required env vars, exposed ports, and a health check command.
  4. Point your integration test job’s needs: at the unit test job so slow integration tests don’t start until the fast, cheap signal has already passed.
  5. Add a publish-report job that depends on both test jobs, runs with if: always(), and declares only the permissions it needs (checks: write, and pull-requests: write if the action posts PR comments).
  6. Pin any third-party reporting action to a commit SHA and keep the human-readable version as a trailing comment.

Common Mistakes

Mistake 1 — no health check on the service container. A job starts the database and immediately runs tests against it. The first run works because GitHub’s runner was already warm; a week later a cold pull of the postgres:16 image adds a few seconds of startup time and every test fails with a connection error. The fix is the options: health check shown in Example 2 — Actions won’t mark the service ready, and won’t let your steps proceed, until pg_isready succeeds.

Mistake 2 — reaching for pull_request_target to get write permissions on fork PRs. A pull_request-triggered workflow from a fork only gets a read-only GITHUB_TOKEN, so a naive fix is switching the trigger to pull_request_target, which runs with the base repository’s permissions and secrets. That’s dangerous: if the job also checks out the fork’s head commit (a common next step, to actually run the fork’s tests), it executes untrusted code with access to your repository’s write token and secrets — a fork PR could exfiltrate secrets or push to protected branches.

# Unsafe: runs fork PR code with base-repo write permissions and secrets
on:
  pull_request_target:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - run: npm ci && npm test  # untrusted code, privileged token

Keep the trigger as pull_request so forked runs stay sandboxed with a read-only token; accept that check-publishing steps requiring write access won’t work for fork PRs under that trigger. If you need write-permission reporting for fork PRs, use a separate workflow triggered by workflow_run after the untrusted pull_request workflow finishes — it runs in the base repository’s context without ever checking out fork code, so it can safely have the elevated permissions.

Best Practices

  • Keep unit and integration tests in separate jobs so a broken unit test fails fast, before any service container spins up.
  • Use each ecosystem’s native JUnit XML output rather than inventing a custom parser — every major reporting action expects it.
  • Always upload test result artifacts with if: always(); a run that only uploads on success hides the exact data you need when something breaks.
  • Give service containers real health checks instead of a fixed sleep — sleeps are either too short (flaky) or too long (slow) and never adapt to actual startup time.
  • Scope permissions: per job. The reporting job needs checks: write; the test jobs don’t need anything beyond contents: read.
  • Pin third-party actions to a commit SHA, with the version as a comment, especially for anything that receives write permissions.
  • Treat fork pull requests as untrusted input. Never combine pull_request_target with a checkout of the fork’s own commit.
  • Fail the workflow on any test failure — don’t let a non-zero exit code get swallowed by a shell pipeline or an || true.

Practice Exercises

  1. Split an existing single-job test workflow into a unit-tests job and an integration-tests job, with the integration job depending on the unit job via needs:.
  2. Add a services: container for a dependency your project uses (Postgres, MySQL, or Redis), including a health check, and connect to it from your integration tests using an environment variable.
  3. Add a publish-report job that downloads both jobs’ artifacts and publishes a combined JUnit report, granting it only the permissions it needs.
  4. Find or write a workflow that uses pull_request_target together with a checkout of github.event.pull_request.head.sha. Explain in your own words what an attacker-controlled fork PR could do with it, then rewrite it using the safer pull_request plus workflow_run pattern described above.

Summary

Separating unit and integration tests into distinct jobs gives you fast feedback without sacrificing the confidence that real dependency wiring provides. Service containers with health checks make integration tests reliable rather than flaky. Emitting JUnit XML and publishing it through a scoped, SHA-pinned reporting job turns raw console output into inline PR annotations reviewers can act on — without handing write permissions or secrets to code from an untrusted fork.