Testing Inside a DevContainer in CI
If CI runs tests on the raw runner instead of inside the devcontainer, it isn't testing the environment developers use. This page runs the suite inside the container with devcontainer exec, so the test toolchain, services, and dependencies exactly match local development.
This matters most when a test suite depends on the exact runtime version, native modules compiled against specific system libraries, or services reached over a Compose network. The GitHub-hosted runner ships its own preinstalled Node, Python, and system packages, and those rarely match what the devcontainer pins. A suite that runs green against the runner's incidental toolchain has told you nothing about whether the code works in the environment a developer actually opens each morning. By routing every test through devcontainer exec, you collapse two environments into one and remove the entire class of "works in CI, breaks locally" defects that come from drift between them.
The mental model is straightforward: treat the built container as the only place tests are allowed to run. CI's job is not to reproduce a test environment from scratch — it is to boot the same container image the team builds locally, hand it the same test command, and report the exit code. Reach for this whenever the suite touches a database, a message broker, a headless browser, or any dependency declared in the devcontainer config rather than installed ad hoc on the runner. If the tests are pure and depend on nothing but the language runtime, running inside is still worth it for version parity, but the payoff grows sharply the moment real services enter the picture.
Prerequisites
You need a devcontainer that builds in CI and a test command.
- A committed devcontainer config that builds in CI.
- A test command that runs inside the container.
- Any services the tests need declared in the config/compose.
The prerequisite people most often get wrong is the "builds in CI" part. A devcontainer that opens fine in VS Code on a developer laptop can still fail on a runner because the laptop had a warm image cache, credentials for a private base image, or a feature that quietly reused something already on disk. Before you wire up devcontainer exec, prove that devcontainer up --workspace-folder . completes cleanly on a fresh runner with no prior state. The test command has the same requirement: it must be runnable non-interactively from the container's default working directory, with no assumption that a developer first ran an install step by hand. If your local flow relies on a postCreateCommand to install dependencies, confirm that command actually ran during the up step, because exec does nothing to trigger it. Getting these two facts nailed down first turns the rest of the pipeline into a couple of one-line commands.
Step-by-Step Implementation
- Build the environment.
devcontainer up --workspace-folder .
This command builds the image, creates the container, and runs the lifecycle hooks (onCreateCommand, postCreateCommand, and friends) exactly as they run on a laptop. The --workspace-folder . flag points the CLI at the current checkout so it reads .devcontainer/devcontainer.json from the repository you just cloned. Running up as a discrete first step, rather than folding it into the test command, means the build and the test are separately visible in the job log: if up fails you know the environment never came up, and if the later exec fails you know the environment was fine but the tests were not. That separation is what makes a red build diagnosable at a glance instead of forcing you to untangle a build error from a test failure.
- Run the suite inside the container.
devcontainer exec --workspace-folder . -- npm test
The exec subcommand attaches to the container that up already created and runs the command after the -- inside it, using the same user, working directory, and environment that a developer's terminal would have. Everything after the -- is passed through verbatim, so npm test here can be any test invocation your project uses — pytest -q, go test ./..., cargo test, or a make target that wraps several of them. Because the command executes in the running container rather than on the runner, it sees the pinned Node from the image, the dependencies installed during postCreateCommand, and any service reachable on the Compose network. The -- separator is not optional cosmetics: it stops the CLI from trying to interpret your test flags as its own options, which is the failure mode behind cryptic "unknown argument" errors when a test command carries flags of its own.
- Bring up dependent services if the tests need them (Compose devcontainer).
devcontainer up --workspace-folder . # runServices starts db, cache, etc.
For a Compose-backed devcontainer, up does double duty: it starts the workspace service and every other service the config lists under runServices (or all services in the file if you leave that key unset). That is why bringing up dependencies is not a separate docker compose up you have to remember — the same up that gave you the container also stood up the database and cache on the shared network. The subtlety worth planning for is readiness: up returns once the containers have started, not once Postgres is accepting connections. If your tests connect immediately they can race a database that is still initializing, so gate the first connection behind a healthcheck in the Compose file or a short wait-for loop in your test bootstrap rather than assuming the service is ready the instant up exits.
- Fail the job on a non-zero exit —
execpropagates it automatically.
There is nothing extra to write for this step, and that is the point. When npm test returns a non-zero code, devcontainer exec exits with that same code, the shell step fails, and the CI job turns red without any explicit if check or exit $? boilerplate. The one thing that quietly defeats this is wrapping the test in a pipe or a script that swallows the status — devcontainer exec ... | tee log.txt reports the exit code of tee, not the tests, so a failing suite sails through green. Keep the test command as the last thing in the step, unpiped, or set set -o pipefail so the real status survives. That single discipline is the difference between a gate that actually blocks broken code and a badge that is always green for the wrong reason.
Common Pitfalls
Test parity fails when tests run on the host or services aren't started.
A pitfall that surfaces the moment tests write files is ownership. If the container runs as a non-root user but a cached workspace volume or a mounted directory was created as root by an earlier step, the test process cannot write coverage reports, temp fixtures, or SQLite files, and you get permission-denied errors that never appear locally where the developer owns their checkout. The fix is to make ownership deliberate rather than accidental: align the container user's UID with whatever created the mount, or chown the cache path during a lifecycle hook before tests run. This is the same ownership discipline that bites shared build caches, and it is worth checking first whenever a suite fails only in CI with an I/O error rather than an assertion error.
The subtler topic-specific pitfall is a suite that passes only because a service happened to be reachable on the host. If a developer once ran a local Postgres on port 5432 and the tests defaulted to localhost:5432, they will pass on any machine that has that server running and fail inside the container, where the database lives at its Compose service name instead. Running inside the devcontainer exposes this hidden host dependency immediately, which is uncomfortable but correct — it forces the test configuration to name services the way the container network resolves them, so the same connection string works locally and in CI. Treat the first in-container run as an audit of every place your tests assume localhost.
| Symptom | Root Cause | Remediation |
|---|---|---|
| Tests pass in CI, fail locally | CI tested the host, not the container | Run tests via devcontainer exec |
| Tests can't reach the database | Services not started | Start them via the Compose devcontainer |
| Job stays green on failing tests | Exit code not propagated | Let exec return the test exit code |
| Different results each run | Unpinned deps/runtime | Pin runtime + commit the lockfile |
Conclusion
Run the suite where the code runs: inside the devcontainer, via devcontainer exec, against the same services and pinned toolchain developers use. Then a passing CI run is a real statement about the developer environment, not the runner's incidental setup.
The strategic payoff is that CI stops being a second, parallel environment you maintain by hand and becomes a faithful replay of the one already defined in .devcontainer. Every version you pin in the image, every service you declare in Compose, and every setup step you encode in a lifecycle hook now serves double duty — it configures the developer's machine and the CI job from a single source. When a test fails in CI, the reproduction is not a guessing game about which package version the runner happened to have; it is devcontainer up followed by devcontainer exec on any laptop, producing the identical failure. That is the reproducibility dividend that pinning and caching promise, collected at the exact point where it matters most.
This also compounds with the rest of the pipeline. Caching the devcontainer build keeps the up step fast, so running tests inside costs little wall-clock time over running them on the bare runner. Pinning the runtime and committing the lockfile removes the "different results each run" flakiness at its root, and running inside the container is what guarantees those pins are the ones actually exercised. Testing in the devcontainer is therefore not an isolated trick but the step that gives the pin-and-cache discipline its teeth: without it, the pinned environment exists in config but is never the thing CI validates. With it, a green check genuinely certifies the environment developers ship from.
FAQ
Why not just run tests on the CI runner?
Because the runner's toolchain, services, and dependencies differ from the devcontainer developers use, so a green run says nothing about their environment. Running inside the container via devcontainer exec makes CI test the exact environment, so failures reproduce locally on the first try. The runner's preinstalled Node or Python is chosen by the CI provider and changes without your consent, so a suite validated against it is validated against a moving target. Pinning the runtime in the devcontainer and executing there pins what CI actually tests, which is the only version guarantee that survives a runner image update.
How do the tests reach a database in CI?
Use a Compose-backed devcontainer so the database and other services start alongside the workspace, and run tests via exec inside the workspace container. The tests reach services by name on the shared network, exactly as they do locally. Because devcontainer up starts the services listed under runServices, there is no separate docker compose up to remember or keep in sync. Just make sure the tests wait for readiness rather than assuming the database accepts connections the moment up returns, since up reports started containers, not ready ones.
Does a failing test fail the job?
Yes, as long as you run via devcontainer exec, which propagates the command's exit code. A non-zero test exit becomes a non-zero job, so CI correctly fails. Avoid swallowing the exit code in a wrapper script. The most common way to lose the code is a pipe: exec ... | tee reports the status of tee, not the tests, so keep the test command unpiped as the last thing in the step or enable set -o pipefail. If you must post-process output, capture it to a file and inspect it after the command has already set the exit status.
Do I need to rebuild the container for every test run?
No — that is what caching the devcontainer build is for. The up step reuses a cached image layer when nothing in the Dockerfile or features changed, so most runs pay only for container creation and the lifecycle hooks, not a full rebuild. You get the parity of running inside the container without paying a from-scratch build on every push. Rebuild cost only returns when you actually change the base image, a feature, or a pinned dependency, which is exactly when you want a fresh build anyway.
Can I run only a subset of tests inside the container?
Yes. Everything after the -- in devcontainer exec --workspace-folder . -- ... is passed straight through, so you can hand it npm test -- --grep smoke, pytest tests/unit, or any filter your runner supports. This is useful for a fast smoke job that runs a subset on every push and a fuller exec invocation on protected branches, all against the same container. The environment stays identical between the quick and thorough runs; only the test selector changes.
What if my test command is interactive or needs a TTY?
Make it non-interactive. devcontainer exec runs the command for CI, so anything that prompts for input or insists on a TTY will hang or misbehave in a headless job. Pass the flags that put your test runner into CI or non-watch mode — for example, ensure watch mode is off and coverage or reporter output goes to a file — so the command runs to completion and returns a clean exit code. If a tool truly requires a pseudo-terminal, allocate one explicitly in the step rather than relying on default behavior.
Related
- Up to CI/CD Integration with DevContainers — the overview of CI integration.
- Running the devcontainer CLI in GitHub Actions — the workflow around the tests.
- Caching DevContainer Builds in CI — keeping the test pipeline fast.