Containers: Docker and Compose
How you get the same environment on your laptop and in CI, and how you stand up a dependency the tests need. You do not need to write production images; you need to read a Compose file and know why the container cannot reach the database.
A test suite that passes on your laptop and fails in CI is one of the most common frustrations in software testing, and the cause is usually not the test at all. It is a difference between two environments: a service that exists locally but not in the container, a port that is mapped differently, a network the test container cannot reach. Docker and Compose are how most teams keep environments consistent, and you do not need to write production images to work with them. You need to read a Compose file, understand what a container actually is, and know why one cannot reach the database it depends on.
What a container actually is
A container is a running process with its own filesystem, network namespace, and set of environment variables. It is isolated from the host machine but shares its kernel. That last part matters: a container is not a virtual machine. It starts in milliseconds because it is not booting an operating system, just launching a process inside a walled-off view of one.
An image is the read-only template a container is built from: a base OS layer, dependencies, and application code, baked in a specific order. Every docker build produces a new image, and every docker run produces a new container from that image. Two containers from the same image start identical and diverge only in what happens after they start. That is exactly the property that makes them useful for reproducible test environments.
Reading a Compose file
docker-compose.yml describes a set of services, the containers a system needs to run together: an app, a database, maybe a cache or a message queue. Each service gets its own container. Compose wires them onto a shared network so they can reach each other by service name instead of an IP address.
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://user:pass@db:5432/app
depends_on:
- db
db:
image: postgres:16
environment:
- POSTGRES_PASSWORD=pass
ports:
- "5432:5432"Reading this file answers most "why can't the test connect" questions before you open a debugger. The app's DATABASE_URL points at db, the service name, not localhost. That is the single most common mistake in a test that works locally but fails in a container. A config file gets hardcoded to localhost when the actual host, inside the Docker network, is the service name Compose assigns.
Why the container cannot reach the database
Three causes cover almost every "connection refused" a tester hits with Compose:
- Wrong hostname: the app config points at
localhostinstead of the service name Compose assigns, such asdb. - Service not ready yet:
depends_oncontrols start order, not readiness. Postgres's container can be running before Postgres itself is accepting connections, so the app connects before the database is listening. - Port only exposed internally: a port defined under a service but not mapped to the host with
ports:is reachable from other containers on the network but not from your own machine's browser or curl.
A QA engineer on a five-person team once spent an afternoon debugging integration tests that passed locally but failed every time in CI. This started right after a colleague added a Redis cache to the Compose file. The tests connected before Redis had finished starting, since depends_on only waited for the container process to launch, not the port to open. For example, the fix was adding a healthcheck to the Redis service and a condition: service_healthy to the app's depends_on entry. That change made Compose actually wait for Redis to be ready before starting the app container.
Reproducing a CI failure locally
Most CI systems run the exact Compose setup checked into the repo, which means a failure in CI should be reproducible with docker compose up on your own machine. This is the fastest way to debug it. Pull the same image versions, run the same seed scripts, and hit the same failure without waiting on a CI queue.
Once the Compose environment is stable, the same debugging instincts extend into environment configuration and secrets, covered in cloud basics, and into the test data seeded inside the database service itself, covered in test data management.
FAQ
Questions people ask
Do I need to know how to write a Dockerfile to test with Docker?
No. Reading an existing Compose file and understanding service networking covers most of what a tester needs. Writing production images is a separate, deeper skill.
Why does my test pass locally but fail in CI with the exact same Compose file?
Check image versions first. A latest tag can pull a different version in CI than what is cached locally, and a service that was already running locally masks a startup-order problem.
What is the difference between `depends_on` and a healthcheck?
depends_on waits for a container to start; a healthcheck combined with condition: service_healthy waits for the service inside it to actually be ready to accept traffic.
How do I see what is happening inside a running container?
docker compose logs <service> shows its output, and docker compose exec <service> sh gives you a shell inside it to poke around directly.