Skip to content

Docker in CI: Testing Strategies

The "Ship" phase of the Docker lifecycle (File 04) relies heavily on automated testing. One of the greatest benefits of containerization is the ability to run tests against the exact same artifact that will be deployed to production.

This guide details the strategies for integrating Docker into your Continuous Integration (CI) pipeline to ensure code quality and reliability.

1. Linting the Dockerfile

Before you even build the image, you should validate the Dockerfile itself. This catches syntax errors and enforces best practices (like those in File 07).

Tool: Hadolint Hadolint is a smart Dockerfile linter that checks for common mistakes and violations of best practices.

How to use it in CI: Run Hadolint as a container against your local Dockerfile.

docker run --rm -i hadolint/hadolint < Dockerfile

Common Errors it catches:

  • Using latest tags (non-deterministic builds).

  • Running apt-get install without apt-get update in the same layer.

  • Forgetting to clean up apt caches (bloated images).

  • Installing packages without version pinning.

2. The Test Container Pattern

The most robust way to test is to run your test suite inside the container. This ensures that the environment where tests pass is identical to the production environment.

Strategy A: The Multi-Stage "Test" Target

Use a multistage Dockerfile to create a specific stage for testing.

# ... (Builder stage) ...

# --- Test Stage ---
FROM builder AS tester
# Install test dependencies (e.g., pytest, coverage)
RUN pip install pytest coverage
# Copy source code
COPY . .
# Run the tests
RUN pytest tests/

# ... (Production stage) ...

In CI: You build up to the tester target. If the RUN pytest command fails, the build fails, and the pipeline stops.

docker build --target tester .

Strategy B: The "Runner" Container

Build your production image, then use docker run to execute the test command inside it (overriding the default CMD or ENTRYPOINT).

In CI:

# 1. Build the image
docker build -t myapp:ci .

# 2. Run tests inside it
# We override the entrypoint to run our test runner
docker run --rm --entrypoint pytest myapp:ci tests/

3. Integration Testing with Docker Compose

Unit tests are simple, but real applications rely on databases, caches, and other services. Docker Compose is the perfect tool for spinning up these ephemeral integration environments in CI.

The Goal:

  1. Spin up a fresh database container.

  2. Spin up your application container.

  3. Run tests that make real HTTP requests or DB queries.

  4. Tear everything down.

The docker-compose.test.yml File: Create a separate Compose file specifically for testing.

version: '3.8'
services:
  # The application under test
  sut:  # System Under Test
    build: .
    command: pytest tests/  # Override command to run tests
    depends_on:
      - db
    environment:
      - DATABASE_URL=postgres://user:pass@db:5432/testdb

  # Ephemeral database for testing
  db:
    image: postgres:14-alpine
    environment:
      - POSTGRES_DB=testdb
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass

The CI Workflow:

# 1. Build the images
docker-compose -f docker-compose.test.yml build

# 2. Run the test suite
# --abort-on-container-exit stops all containers when the 'sut' exits
# --exit-code-from sut makes the command return the exit code of the test runner
docker-compose -f docker-compose.test.yml up \
    --abort-on-container-exit \
    --exit-code-from sut
  • Success: If tests pass, sut exits with code 0. Compose tears down, and the CI job passes.

  • Failure: If tests fail, sut exits with code 1. Compose tears down, and the CI job fails.

4. Database Initialization in CI

A common "gotcha" in CI is that the application starts before the database is ready to accept connections.

The Problem: depends_on only waits for the container to start, not for the database process to be ready.

The Solution: Healthchecks or Wait Scripts

Option A: docker-compose Healthchecks (Compose v2.1+) Configure the db service with a healthcheck, and make sut depend on the condition service_healthy.

  db:
    image: postgres:14-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 5s
      timeout: 5s
      retries: 5

  sut:
    depends_on:
      db:
        condition: service_healthy

Option B: Wait-For-It Script (Universal) Wrap your test command with a script like wait-for-it.sh or dockerize.

  sut:
    command: ["./wait-for-it.sh", "db:5432", "--", "pytest", "tests/"]

5. Scanning for Vulnerabilities

Security testing is part of CI. Before pushing an image to the registry, scan it.

Tool: Trivy Trivy is a comprehensive, easy-to-use vulnerability scanner.

In CI:

# Run Trivy container against your image
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
    aquasec/trivy image --exit-code 1 --severity CRITICAL myapp:ci
  • --exit-code 1: Fails the pipeline if vulnerabilities are found.

  • --severity CRITICAL: Only fails on critical issues (prevents noise).