How to Test Docker Containers and Dockerized Applications

How to Test Docker Containers and Dockerized Applications

Shipping a Docker image without testing it is like deploying code without tests — the problems show up in production. Docker containers need their own testing layer: build tests, container smoke tests, integration tests, and security scans. Here's how to build that layer.

Key Takeaways

Test at four levels: Dockerfile, image, running container, and application. Each level catches different classes of bugs.

hadolint catches Dockerfile mistakes before you build. Bad base images, missing HEALTHCHECK directives, and security anti-patterns show up instantly.

Container Structure Tests verify image contents declarically. Check that binaries exist, files are in the right place, and the image runs as a non-root user.

Smoke tests confirm the container starts and responds correctly. A 5-line shell script that curls the health endpoint catches most production startup failures.

The Four Layers of Docker Testing

Testing a Dockerized application means testing at four levels:

  1. Dockerfile validation — catch syntax errors and anti-patterns before building
  2. Image tests — verify the built image contains what it should
  3. Container smoke tests — confirm the running container responds correctly
  4. Integration tests — verify the application inside the container works with real dependencies

Most teams only do #4 and wonder why production deployments fail. All four levels are fast and automatable.

Layer 1: Dockerfile Linting with hadolint

hadolint is a Dockerfile linter that catches common mistakes:

# Install
brew install hadolint  <span class="hljs-comment"># macOS
<span class="hljs-comment"># or: docker run --rm -i hadolint/hadolint < Dockerfile

<span class="hljs-comment"># Run
hadolint Dockerfile

Common issues it catches:

# ❌ hadolint will flag these
FROM ubuntu                    # no tag = unpredictable builds
RUN apt-get update             # should be combined with apt-get install
RUN apt-get install curl wget  # missing -y flag causes interactive failures
ADD ./app /app                 # use COPY for local files, ADD for URLs/archives

# ✅ Correct
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends \
    curl \
    && rm -rf /var/lib/apt/lists/*
COPY ./app /app

Add hadolint to your CI pipeline as the first step — it runs in milliseconds.

Layer 2: Container Structure Tests

Google's Container Structure Tests verify image contents after build:

# structure-test.yaml
schemaVersion: "2.0.0"

commandTests:
  - name: "node version"
    command: "node"
    args: ["--version"]
    expectedOutput: ["v20.*"]

  - name: "application starts"
    command: "node"
    args: ["server.js", "--check"]
    expectedOutput: ["Configuration valid"]

fileExistenceTests:
  - name: "server.js exists"
    path: "/app/server.js"
    shouldExist: true

  - name: "secrets not baked in"
    path: "/.env"
    shouldExist: false

metadataTest:
  envVars:
    - key: NODE_ENV
      value: production
  exposedPorts: ["3000"]
  user: "node"   # Verify non-root user

Run against a built image:

container-structure-test test \
  --image myapp:latest \
  --config structure-test.yaml

The user test (last line) is particularly valuable — verifying your container runs as a non-root user is a security baseline that's easy to check and commonly forgotten.

Layer 3: Container Smoke Tests

A smoke test starts the container and verifies it responds correctly:

#!/bin/bash
<span class="hljs-comment"># smoke-test.sh

<span class="hljs-built_in">set -e

IMAGE=<span class="hljs-variable">${1:-myapp:latest}
CONTAINER_NAME=<span class="hljs-string">"smoke-test-$$"

<span class="hljs-built_in">echo <span class="hljs-string">"Starting container: $IMAGE"
docker run -d \
  --name <span class="hljs-string">"$CONTAINER_NAME" \
  -p 0:3000 \
  -e DATABASE_URL=<span class="hljs-string">"postgresql://localhost/test" \
  <span class="hljs-string">"$IMAGE"

<span class="hljs-comment"># Get the randomly assigned port
PORT=$(docker port <span class="hljs-string">"$CONTAINER_NAME" 3000 <span class="hljs-pipe">| <span class="hljs-built_in">cut -d: -f2)

<span class="hljs-comment"># Wait for the app to be ready
<span class="hljs-built_in">echo <span class="hljs-string">"Waiting for app on port $PORT..."
<span class="hljs-built_in">timeout 30 bash -c <span class="hljs-string">"until curl -sf http://localhost:$PORT/health; do sleep 1; done"

<span class="hljs-comment"># Run assertions
<span class="hljs-built_in">echo <span class="hljs-string">"Testing health endpoint..."
RESPONSE=$(curl -sf http://localhost:<span class="hljs-variable">$PORT/health)
<span class="hljs-built_in">echo <span class="hljs-string">"$RESPONSE" <span class="hljs-pipe">| grep -q <span class="hljs-string">'"status":"ok"' <span class="hljs-pipe">|| { <span class="hljs-built_in">echo <span class="hljs-string">"Health check failed"; <span class="hljs-built_in">exit 1; }

<span class="hljs-built_in">echo <span class="hljs-string">"Testing readiness..."
curl -sf http://localhost:<span class="hljs-variable">$PORT/ready <span class="hljs-pipe">|| { <span class="hljs-built_in">echo <span class="hljs-string">"Readiness check failed"; <span class="hljs-built_in">exit 1; }

<span class="hljs-built_in">echo <span class="hljs-string">"All smoke tests passed."

<span class="hljs-comment"># Cleanup
docker stop <span class="hljs-string">"$CONTAINER_NAME"
docker <span class="hljs-built_in">rm <span class="hljs-string">"$CONTAINER_NAME"

Run this after every image build:

docker build -t myapp:latest .
./smoke-test.sh myapp:latest

Layer 4: Integration Tests with Docker

For integration tests, run the application container alongside its dependencies. Here's a pattern using docker run directly (without Compose):

#!/bin/bash
<span class="hljs-comment"># integration-test.sh

<span class="hljs-comment"># Start dependencies
POSTGRES_ID=$(docker run -d \
  -e POSTGRES_PASSWORD=<span class="hljs-built_in">test \
  -e POSTGRES_DB=testdb \
  postgres:15-alpine)

POSTGRES_IP=$(docker inspect -f <span class="hljs-string">'{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' <span class="hljs-string">"$POSTGRES_ID")

<span class="hljs-comment"># Wait for Postgres
<span class="hljs-keyword">until docker <span class="hljs-built_in">exec <span class="hljs-string">"$POSTGRES_ID" pg_isready -U postgres 2>/dev/null; <span class="hljs-keyword">do
  <span class="hljs-built_in">sleep 1
<span class="hljs-keyword">done

<span class="hljs-comment"># Run app container with test config
APP_ID=$(docker run -d \
  -e DATABASE_URL=<span class="hljs-string">"postgresql://postgres:test@${POSTGRES_IP}/testdb" \
  -e NODE_ENV=<span class="hljs-built_in">test \
  myapp:latest)

<span class="hljs-comment"># Wait for app
APP_IP=$(docker inspect -f <span class="hljs-string">'{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' <span class="hljs-string">"$APP_ID")
<span class="hljs-built_in">timeout 30 bash -c <span class="hljs-string">"until curl -sf http://${APP_IP}:3000/health; do sleep 1; done"

<span class="hljs-comment"># Run tests against the running container
docker run --<span class="hljs-built_in">rm \
  -e API_BASE_URL=<span class="hljs-string">"http://${APP_IP}:3000" \
  myapp-tests:latest \
  npm <span class="hljs-built_in">test

EXIT_CODE=$?

<span class="hljs-comment"># Cleanup
docker stop <span class="hljs-string">"$APP_ID" <span class="hljs-string">"$POSTGRES_ID"
docker <span class="hljs-built_in">rm <span class="hljs-string">"$APP_ID" <span class="hljs-string">"$POSTGRES_ID"

<span class="hljs-built_in">exit <span class="hljs-variable">$EXIT_CODE

Security Scanning

Include a security scan as part of your Docker testing pipeline:

# Trivy: scan for vulnerabilities in the image
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:latest

<span class="hljs-comment"># Or with Docker Scout (built into Docker Desktop)
docker scout cves myapp:latest

In GitHub Actions:

- name: Scan image for vulnerabilities
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: 'myapp:latest'
    format: 'table'
    exit-code: '1'
    ignore-unfixed: true
    severity: 'CRITICAL,HIGH'

Make this a blocking step — don't deploy images with critical CVEs.

Complete GitHub Actions Pipeline

name: Docker Build and Test
on: [push, pull_request]

jobs:
  docker-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Lint Dockerfile
        run: docker run --rm -i hadolint/hadolint < Dockerfile

      - name: Build image
        run: docker build -t myapp:${{ github.sha }} .

      - name: Run structure tests
        run: |
          curl -LO https://storage.googleapis.com/container-structure-test/latest/container-structure-test-linux-amd64
          chmod +x container-structure-test-linux-amd64
          ./container-structure-test-linux-amd64 test \
            --image myapp:${{ github.sha }} \
            --config structure-test.yaml

      - name: Smoke test
        run: ./smoke-test.sh myapp:${{ github.sha }}

      - name: Integration tests
        run: ./integration-test.sh

      - name: Security scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: 'myapp:${{ github.sha }}'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'

HEALTHCHECK in Your Dockerfile

Always add a HEALTHCHECK directive — it enables Docker (and orchestrators like Kubernetes) to know when your container is actually ready:

FROM node:20-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

COPY . .
USER node

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
  CMD curl -f http://localhost:3000/health || exit 1

CMD ["node", "server.js"]

Without HEALTHCHECK, Docker considers a container healthy as soon as the process starts — before your application is ready to serve traffic.

Beyond Container Tests

Container tests verify your image works. They don't verify your users' experience after deployment. For that, you need end-to-end tests against your running application.

HelpMeTest runs automated browser tests against your deployed application — verifying login flows, checkout processes, and critical user journeys. Pair it with your Docker test suite for full-stack test coverage: container tests catch infrastructure problems, HelpMeTest catches application problems.

Read more