Skip to content
shellhacks.dev
CI/CD

How to Run Integration Tests With Service Containers in CI

Run integration tests against real databases in CI with service containers: GitHub and GitLab syntax, health checks, ports and host names.

6 min read
Summarize in AI
Illustration for "How to Run Integration Tests With Service Containers in CI": a terminal session, test results, stacked containers.

Tests that mock the database pass until the day the query has a syntax error the mock never saw. A real Postgres or Redis in CI costs little and catches those. CI systems start such dependencies for you as service containers, and the only tricky parts are readiness and addressing. This post covers both for the two common platforms, as the CI side of throwaway databases. Examples use PostgreSQL 17 and Redis 7.

GitHub Actions

.github/workflows/integration.ymlYAML
jobs:
  integration:
    runs-on: ubuntu-24.04
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: app_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 5s
          --health-timeout 3s
          --health-retries 10
      redis:
        image: redis:7
        ports:
          - 6379:6379
        options: --health-cmd "redis-cli ping" --health-interval 5s --health-retries 10
    env:
      DATABASE_URL: postgres://postgres:postgres@localhost:5432/app_test
      REDIS_URL: redis://localhost:6379
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/migrate.sh
      - run: ./scripts/integration-tests.sh

The runner starts each service and, because of the health options, waits until the check passes before the first step. Without a health command, the job begins as soon as the container starts, and the first test fails with "connection refused" while the database is still initializing. Because the job runs directly on the runner, services are reached on localhost at the mapped port. Writing 5432:5432 fixes the host port; writing just 5432 picks a random one, readable from ${{ job.services.postgres.ports['5432'] }}.

A job that runs in a container

YAML
    container: node:22
    env:
      DATABASE_URL: postgres://postgres:postgres@postgres:5432/app_test

When the job itself runs in a container, it joins the same Docker network as the services, and the host name is the service label (postgres), not localhost, and no port mapping is needed. Mixing the two modes is the most frequent source of "works locally, not in CI". Service containers are supported on Linux runners only, not macOS or Windows.

GitLab CI

.gitlab-ci.ymlYAML
integration:
  image: node:22
  services:
    - name: postgres:17
      alias: db
      variables:
        POSTGRES_PASSWORD: postgres
  variables:
    DATABASE_URL: postgres://postgres:postgres@db:5432/postgres
  script:
    - ./scripts/wait-for-db.sh db 5432
    - ./scripts/integration-tests.sh

GitLab services share a network with the job and are reached by the alias. They have no built-in health wait, so the job's script must wait: poll with pg_isready or a TCP check, as in waiting for a service, before it runs the tests.

Make the tests reliable

  • Create a fresh schema per run, or reset between tests with transactions, so state does not leak.
  • Pin the image version to production's major version; latest breaks tests when upstream releases.
  • Use tmpfs for the data directory if speed matters; it is throwaway anyway.
  • Print the service logs on failure, for example docker logs in an if: failure() step.

Where this does not apply

Services that need cloud features (a managed queue, object storage with IAM) are better replaced by local emulators or tested in a staging environment. Very heavy services slow every run; split integration tests into their own job, so the quick unit tests report first. For the shell side of testing against disposable services, see testing scripts with network and files, and the related fail-fast CI scripts.

How the job reaches the service

The hostname you use depends on where the test process runs. When the job runs directly on the runner machine and the service runs as a container beside it, the service's port is published to the runner, and the tests connect to localhost and the mapped port. When the job itself runs inside a container, both containers join a shared network, and the tests connect by the service's label as the hostname, with the container's own port. Mixing the two is a classic cause of connection refused errors, such as using localhost from inside a job container, where it points at the job container and finds nothing. GitHub Actions makes the first case explicit with a ports mapping, and the second case needs no mapping. GitLab CI always uses the second style, and derives the hostname from the image name unless you set an alias, replacing slashes and colons in a way that is easy to guess wrong, so setting an alias removes the doubt.

Print the connection settings at the start of the test job, as environment variables the tests read, so the same tests run against a local container, a CI service and a remote database by changing the variables.

Readiness and the race at startup

A service container is started before the job's steps run, and it is not necessarily ready to accept connections when the first step begins. A database can take several seconds to initialize, and the first test then fails with a connection error that passes on a rerun. Give the service a health check with the options key in GitHub Actions, using flags such as --health-cmd, --health-interval and --health-retries, and the runner waits for it to pass before it starts the job. Where no health check is available, add a short wait step that polls the port or runs a query with a time limit and fails clearly when the limit passes. Avoid a fixed sleep, which is too short on a slow runner and wasteful on a fast one. The same applies to migrations and seed data: run them as an explicit step after the service is ready, so a failure is visible and not mixed with the tests.

Pin the image version to the one production uses, including the major version of the database, because a test that passes against the latest release can hide a bug that appears on the version you run.

Keeping the tests fast and independent

Each run gets a fresh service, which is the main benefit: no leftover rows, no shared state with other jobs, and nothing to clean up. Make use of that by not building tests that depend on order. Create the data each test needs, or wrap tests in transactions that roll back, and avoid sleeping to wait for asynchronous results. Tests that share one database and run in parallel need separate schemas or databases per worker, which a small setup step can create. Mount a tmpfs for the data directory of a database in CI to make it faster, accepting that the data is lost when it stops, which is fine for a test. Set modest resource limits, because a runner has little memory and a database that wants a large cache will either fail or slow the whole job. Collect the service logs on failure, since the cause of a failed test is often in the database's own output, and a step that prints them with if: failure() saves a rerun.

Debugging a flaky service in CI

When the tests pass locally and fail in CI on a connection, check the basics in order. Print the service's status and logs in a step that always runs. Confirm the hostname and port the tests used by echoing them. Check whether the service had finished starting by looking at the first timestamps in its log. Look for resource limits: a database killed by the out-of-memory killer on a small runner produces a sudden connection reset in the middle of a test run. Pin versions and compare them with local. Reproduce the failure locally with the same compose file the CI uses, which is a good reason to define the services once in a file both can read. Retrying a failed test hides the problem, so use retries sparingly and always report that a retry happened.

See GitHub's service containers guide and GitLab services.

Also inTesting
Taggeddocker
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.