An AI coding agent can produce a convincing patch while still making an incorrect assumption about a database, queue, or other service. Unit tests often cannot expose differences between a fake dependency and the service used in production. The safer pattern is to give each run a disposable, real dependency, wait for it to become usable, execute the integration tests, and destroy everything even when the run fails.
Last reviewed: 2026-08-25
Direct answer
Use a per-run service-container contract with five explicit phases: provision, wait, wire, test, and clean up. Locally, Testcontainers Getting Started describes this model: the library starts real services in Docker, gives test code a programmatic endpoint, and removes the resources after execution. In CI, GitHub’s service-container guide provides the equivalent job-scoped model. GitHub creates a fresh service container for each configured job and destroys it when the job completes.
Start by writing the dependency contract before asking an agent to change application code. Record the service image, the interface your test needs, the readiness signal, the seed data, and the cleanup expectation. Keep the contract narrow: a PostgreSQL database and one schema is easier to diagnose than an entire shared staging environment. The image and configuration should be versioned with the test definition so a reviewer can see what the agent actually exercised.
Provision the dependency inside the same isolated run as the patch. Testcontainers supports common languages and requires a Docker-API-compatible runtime. Its Getting Started guide lists Docker Desktop, Docker Engine on Linux, and Testcontainers Cloud as officially supported runtime environments; Docker’s Testcontainers documentation separately confirms the Docker-API-compatible prerequisite and notes active testing on recent Docker for Linux and Docker Desktop for Mac and Windows. If the runtime is unavailable, stop before changing the patch and report an environment failure rather than silently substituting a mock.
Wait for readiness, not merely process creation. Testcontainers documents wait strategies for ensuring a container and the application inside it are initialized. In GitHub Actions, a PostgreSQL service can use a health command and retry settings. A useful operator sequence is:
1. Create a run identifier and a disposable dependency set.
2. Start each service and wait for its health or readiness signal.
3. Resolve the host, port, and database name from the run, never from a shared default.
4. Apply migrations or seed only the data needed by the test.
5. Run the agent-generated integration tests.
6. Collect sanitized results, then remove containers, volumes, and networks.
On the happy path, the readiness check passes, the tests return their expected status, the sanitized record is uploaded, and cleanup is confirmed. On the error path, preserve the first failing test or readiness error, classify the failure, run the same unconditional cleanup, and report cleanup separately. The agent may propose a code change only after the operator has established whether the failure belongs to the patch, the dependency, or the runner.
The network address depends on the CI topology. When the job itself runs in a container, GitHub places the job and its services on a user-defined bridge network. The service label becomes the hostname and service ports do not need to be published to the host. When the job runs directly on the runner, the test reaches the service through localhost or 127.0.0.1, and the workflow must map the service port to the host. Mixing these two contracts is a common reason for an agent’s test to pass locally and fail in CI.
Here is a deliberately sanitized GitHub Actions shape for a container job. It follows the PostgreSQL pattern in GitHub’s PostgreSQL service-container tutorial ; the bracketed value is supplied by the test environment and is not a credential to copy into a repository.
name: agent-integration
on: [pull_request]
jobs:
integration:
runs-on: ubuntu-latest
container:
image: node:20-bookworm-slim
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: "[REDACTED]"
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Check out repository code
uses: actions/checkout@v6
- name: Install dependencies
run: npm ci
- name: Run integration tests
run: npm run test:integration
env:
POSTGRES_HOST: postgres
POSTGRES_PORT: "5432"
Treat that file as a topology example, not a complete application configuration. The test harness still needs to read its database settings, create only test data, and fail if readiness or cleanup cannot be confirmed. If you choose a runner-host job instead, publish the required container port and use the assigned host port; do not retain a fixed port just because it worked on one developer laptop.
For a local Testcontainers implementation, use the same lifecycle in your language’s idioms. A language-neutral sketch is enough to make the handoff explicit:
dependency = provision_real_service(image, run_id)
try:
wait_until_ready(dependency)
endpoint = resolve_mapped_endpoint(dependency)
run_migrations_and_integration_tests(endpoint)
finally:
destroy_run_resources(dependency)
Testcontainers describes both a GenericContainer abstraction and technology-specific modules. Prefer a module when it supplies the service’s connection details and wait strategy; use the generic abstraction when the dependency is not covered. Either way, the agent’s evidence should show which image, endpoint mode, readiness result, test command, and cleanup result were used.
Who this is for
This guide is for engineers who let coding agents modify application code, database access, message handling, or integration tests and then need a repeatable check before review. It is useful to repository maintainers, CI owners, and reviewers who want an agent run to be reproducible on a laptop and on a Linux CI runner. It is especially relevant when several agent branches run in parallel, because shared services can create data pollution and configuration drift.
It is not a replacement for unit tests, production smoke tests, or a deployment approval process. The goal is a focused integration boundary: real dependencies, disposable state, an observable lifecycle, and a result a human can inspect. If an agent only changes pure functions, this workflow may be unnecessary; if it changes a service adapter, schema, migration, or serialization contract, it is a strong candidate.
Key takeaways
- Give every run its own service set. Testcontainers identifies isolated, on-demand infrastructure as a way to avoid test-data pollution when pipelines run in parallel.
- Make readiness a gate. A running container is not automatically a ready database or broker; use a documented wait strategy or a service health check.
- Keep the network contract explicit. Container-job services use their labels as hostnames; host-runner jobs need published ports and
localhost-style access. - Use real dependencies for the boundary under test. The Testcontainers guide warns that in-memory services and fake replicas may not implement all production behavior.
- Capture enough metadata to reproduce the run without capturing secrets or full payloads.
- Make cleanup unconditional and observable. Report cleanup as a separate outcome from the test result so a failed test cannot hide leaked resources.
For a reviewable handoff, pair the integration result with a reviewable agent diff . The diff explains what changed; the container record explains what environment actually exercised it.
Sources checked
- Testcontainers Getting Started explains real services in Docker, isolated provisioning, wait strategies, mapped ports, network aliases, automatic cleanup, supported languages, and the typical before/during/after workflow.
- Communicating with Docker service containers documents fresh per-job service containers, Linux-runner requirements, container-job networking, host-runner port mapping, and service labels.
- Creating PostgreSQL service containers provides container-job and runner-job PostgreSQL workflow patterns, health-check options, and a test that creates and reads a table.
- Docker’s Testcontainers page confirms the open-source library model, Docker-API-compatible runtime prerequisite, actively tested Docker environments, and language-specific guides.
These are public documentation sources and were refetched for this article. Recommendations in the remaining sections are operating choices derived from the documented lifecycle; they are not claims that a particular repository has already adopted them.
Contract details to verify
Before an agent run is accepted, verify each item in this small contract:
| Contract item | What the operator records | Passing evidence |
|---|---|---|
| Dependency identity | Image and the interface needed by the test | The run record names the service and image without exposing sensitive configuration |
| Runtime | Docker-API-compatible runtime and runner type | The preflight says whether Docker Engine, Docker Desktop, or Testcontainers Cloud is available |
| Topology | Container-job hostname or runner-host mapped port | The application endpoint matches the selected topology |
| Readiness | Health command, wait strategy, timeout, and result | Tests start only after the service reports ready |
| Data boundary | Schema, seed set, and run identifier | No shared staging rows or developer data are used |
| Test command | Exact command and exit status | A reviewer can rerun the same test target |
| Evidence | Sanitized fields and artifact locations | Logs identify the run without payloads or credentials |
| Cleanup | Containers, volumes, networks, and cleanup status | Cleanup is recorded on both success and error paths |
Keep logs structured and intentionally small. For example:
{
"run_id": "run-42",
"service": "postgres",
"image": "postgres:15",
"topology": "container-job",
"host": "postgres",
"mapped_port": 5432,
"readiness": "ready",
"test_status": "passed",
"test_count": 18,
"duration_ms": 8420,
"cleanup_status": "complete",
"failure_class": null
}
Do not log connection strings, query parameters, environment dumps, request bodies, or secret-bearing headers. If a failure message contains one, replace the value with [REDACTED] before attaching it to a pull request. A run identifier should be short-lived correlation data, not an access credential.
Failure modes
The container exists but the test gets connection refused. Process creation is not readiness. Add the service’s health command or a Testcontainers wait strategy, record the wait result, and stop the test if the readiness deadline expires. Do not let the agent “fix” the application by adding arbitrary sleeps.
The hostname works locally but not in CI. A container job can resolve the service label on the shared bridge network; a job running on the host needs a published port and localhost or 127.0.0.1. Inspect the job topology first, then change one endpoint setting. Avoid changing application behavior to compensate for a networking mismatch.
Parallel runs see each other’s rows. This indicates a shared database, a reused volume, or a non-unique schema. Move the dependency into the run, namespace test data by the run identifier, and verify destruction. Testcontainers specifically presents isolated infrastructure as protection against data pollution and configuration drift.
The runner cannot start containers. GitHub’s service-container documentation requires a Linux runner for service containers, job containers, and Docker container actions; a self-hosted runner also needs Docker installed. Testcontainers likewise requires a Docker-API-compatible runtime. Fail the preflight with an environment classification instead of falling back to an unverified fake service.
A fixed host port collides. Raw Docker or Compose setups can encounter port conflicts, while Testcontainers maps ports to available host ports. Prefer a container-job network or resolve the dynamically mapped port and pass it to the test process.
A test fails and the workspace remains dirty. Cleanup must sit on an unconditional path. Testcontainers documents automatic removal of labeled resources through its Ryuk sidecar, including abnormal process termination; still record whether cleanup was observed and investigate runtime-specific exceptions.
The agent retries until the original defect disappears from the log. Set a bounded retry policy for the workflow, preserve the first failure, and classify subsequent attempts as retries. A retry is diagnostic evidence, not proof that the patch is correct.
Logs reveal sensitive data. Reduce the log schema to correlation and outcome fields, redact before upload, and review artifacts as part of the pull request. Never copy a real connection string into a fixture or an example.
FAQ
Why use a real service instead of a mock? A mock or in-memory implementation can be fast, but the Testcontainers guide notes that fake replicas may not support every production feature or behave the same way. Use a mock for a unit boundary and a disposable real service for the integration boundary.
Should I use Testcontainers or GitHub service containers? Use Testcontainers when the test code should provision and address dependencies in both local and CI runs. Use GitHub service containers when the workflow owns a small, stable set of dependencies and the job topology is clear. They can express the same lifecycle; choose one source of truth for each test target.
Do service containers work on every runner? The supplied GitHub documentation says workflows using service containers or job containers require Linux; GitHub-hosted jobs should use Ubuntu, and self-hosted jobs need Linux with Docker. Check the runner before the agent starts.
How do I connect from a job container? Use the configured service label as the hostname and the container port. GitHub’s bridge-network model means the service need not publish its port to the host in this topology.
How do I connect from a host-runner job? Map the service port to the runner and use localhost or 127.0.0.1. If the host port is assigned dynamically, pass the resolved value into the test process rather than assuming a default.
What should happen after a failed test? Preserve the test failure, collect sanitized evidence, and run cleanup. A failed assertion and a cleanup failure are separate findings; report both so an operator can decide whether to retry or repair the harness.
Reader next step
Choose one agent-generated change that crosses a service boundary and implement the smallest disposable test around it. First, write down the image, readiness signal, endpoint topology, seed data, test command, and cleanup check. Then run it once on a local Docker-API-compatible runtime and once in a Linux CI job. Exercise both a passing case and an intentional, clearly labeled failing assertion. Save only the structured fields shown above, attach the test result beside the CI failure triage workflow , and ask a reviewer to confirm that the environment—not a shared service or an accidental mock—produced the result.
When that loop is stable, make it the required integration gate for the class of patches your agent edits. The next agent run should be judged on four visible facts: the dependency was real, readiness was observed, the test outcome is reproducible, and cleanup completed.