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:
- Dockerfile validation — catch syntax errors and anti-patterns before building
- Image tests — verify the built image contains what it should
- Container smoke tests — confirm the running container responds correctly
- 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 DockerfileCommon 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 /appAdd 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 userRun against a built image:
container-structure-test test \
--image myapp:latest \
--config structure-test.yamlThe 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:latestLayer 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_CODESecurity 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:latestIn 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.