Integration Test Patterns
PLIntegration testing with AI agents means generating tests that run code against a real database, a containerized message broker, and network-level stubs for third-party APIs, then proving each test fails when its seam breaks. The qualifier that matters: the agent must see every test go red once, or the suite only proves that the code still runs.
This page is for developers who ask an agent to “add integration tests”, and for tech leads who decide whether those tests may gate a merge. The typical failure: the agent mocks the repository, writes 40 green tests, and a migration that changes email from varchar(255) to text and recreates the table without its unique index ships anyway. Duplicate accounts appear in production a week later, and no test ever touched Postgres.
What you’ll walk away with from integration testing
Section titled “What you’ll walk away with from integration testing”- A prompt that builds the test harness: a Postgres container started once per run, migrations applied, and one rollback per test.
- A prompt for repository tests that only a real database can fail: constraints, transactions, concurrency, JSON and time zones.
- A prompt for queue consumers that polls for outcomes instead of sleeping, and covers redelivery and the dead-letter queue.
- A decision table for stubbing third-party APIs, and a stub prompt modeled on Stripe’s Payment Intents API.
- A seam-by-seam fault-injection check that tells you whether the suite catches bugs, without reading it line by line.
Which failures belong in an integration test?
Section titled “Which failures belong in an integration test?”An integration test earns its cost only where a mock cannot fail. If the behavior is plain logic, it belongs in a unit test. If it is a user journey through the UI, it belongs in an end-to-end test. Integration tests sit between them, at the seams where your code meets something it does not control.
| Seam | What a mock cannot catch | Real dependency in the test |
|---|---|---|
| Database | Unique, foreign-key and check constraints; transaction rollback; locking and isolation; column types after a migration | Postgres (or your engine) in a container, same major version as production |
| Message broker | Routing keys, acknowledgement timing, redelivery, dead-letter routing, ordering | RabbitMQ, Kafka or your broker in a container |
| Cache | Serialization, TTL expiry, key collisions between tenants | Redis in a container |
| Third-party HTTP API | Request shape, error codes, retries and timeouts in your client | A stub at the network boundary; the vendor’s test mode for a scheduled check |
Two neighbours own adjacent concerns. Endpoint behavior and consumer-driven contracts with Pact are on API testing. Factories, seed data and anonymized fixtures are on test data management. This page covers the harness and the seams.
Set up a real test database the agent can run
Section titled “Set up a real test database the agent can run”Before the agent writes a single test, it needs a harness it can start and tear down on its own. Testcontainers is the common choice for Node, Java, Python and Go: the test process starts a throwaway container, reads its mapped port, and removes it at the end. Versions checked on npm on 2026-09-26: testcontainers and @testcontainers/postgresql 12.1.0.
The isolation strategy is the decision that shapes every test after it. Choose it before you prompt, and put it in the prompt.
| Isolation strategy | Fast? | Breaks when |
|---|---|---|
One transaction per test, rolled back in afterEach | Fastest | The code under test commits or opens its own transaction, or the test needs two connections (concurrency, locking) |
| One schema or database per test worker, truncated between tests | Fast | Tests inside one worker share state if truncation misses a table |
| Fresh container per test file | Slow | Rarely wrong, but start-up time dominates the suite |
Most suites use rollback by default and a per-worker schema for the tests that need real commits. Say so in the prompt, or the agent picks one and applies it everywhere.
Run the harness prompt in Claude Code, Codex, or Cursor
Section titled “Run the harness prompt in Claude Code, Codex, or Cursor”The prompt is the same in each tool. What differs is how the agent gets permission to run Docker and the test command without asking at every step.
Run it interactively with claude, or headless for a repeatable job. Save the prompt as prompts/integration-harness.md first. --allowedTools pre-approves only the commands the task needs, including the one install the prompt asks for and no other package (flag syntax checked with claude --help on 2.1.287):
# terminal, in the repository rootclaude -p "$(cat prompts/integration-harness.md)" \ --permission-mode dontAsk \ --allowedTools "Bash(docker info)" "Bash(npx vitest run *)" "Bash(npm install -D testcontainers @testcontainers/postgresql)" Edit WriteIf the agent should run your migration command outside the Vitest global setup, add it to the list too, for example "Bash(npm run db:migrate)"; with --permission-mode dontAsk, headless -p denies any command that would prompt on every provider (-p starts in Manual permission mode; on third-party providers or with telemetry off it starts in auto mode from v2.1.285).
To keep the same approvals for every session, put the Bash(...) rules under permissions.allow in .claude/settings.json.
Run it in the interactive CLI with codex, or headless with codex exec. To let the agent edit the repository and run commands in it, use a permission profile, -c default_permissions=":workspace" (beta). The legacy --sandbox workspace-write flag is still accepted in 0.157.1 (checked with codex exec --help). Do not combine the two; OpenAI says the profile and legacy sandbox systems do not compose.
# terminal, in the repository root# permission profile (beta)codex exec -c default_permissions=":workspace" "$(cat prompts/integration-harness.md)"# or the legacy sandbox flag, still accepted in 0.157.1:codex exec --sandbox workspace-write "$(cat prompts/integration-harness.md)"Whether the sandbox can reach the Docker daemon depends on your platform and settings, which is why the prompt starts with docker info. If that step fails, run the containers outside the agent session and pass the connection URL in, as described in ephemeral environments.
Paste the prompt into the Agent panel and attach package.json and the migration folder with @. Approve docker info and the test command when the agent asks. In Cursor’s run-mode settings, keep docker info, the test command and package installs behind an approval rather than letting them run unattended.
Give the agent the live schema through MCP
Section titled “Give the agent the live schema through MCP”Without database access, the agent infers column types and constraints from migration files, and it gets them wrong after a migration that altered a table in place. A database MCP server lets it query information_schema, list indexes, and run the failing query against the container itself.
DBHub (npm @bytebase/dbhub, 1.3.1 on 2026-09-26) connects to PostgreSQL, MySQL, MariaDB, SQL Server, Oracle and SQLite through one server. Point it at a throwaway database, never a shared one.
The MCP DSN is not the per-run Testcontainers URL. The harness above maps Postgres to a random host port that changes on every run, and exports the URL only inside the test process. So that URL is unset in the shell where you add the server, and stale after the next run if you copy it. Instead, start a long-lived schema-inspection container on a fixed port, migrate it, and export its URL before you add the server:
# terminal: use the same Postgres major version as productiondocker run -d --name it-schema -p 55432:5432 -e POSTGRES_PASSWORD=test postgres:<production major>export TEST_DATABASE_URL=postgres://postgres:test@localhost:55432/postgres# apply every migration to it with your real migration command, pointed at $TEST_DATABASE_URLRe-run the migrations on it-schema whenever the schema changes, so the agent sees what the tests see. The shell expands $TEST_DATABASE_URL when you add the server, and the value is stored in your local, uncommitted MCP config.
claude mcp add dbhub -- npx -y @bytebase/dbhub@latest --transport stdio --dsn "$TEST_DATABASE_URL"The default scope is local, so the entry stays out of .mcp.json and out of git.
codex mcp add dbhub -- npx -y @bytebase/dbhub@latest --transport stdio --dsn "$TEST_DATABASE_URL"The entry goes into ~/.codex/config.toml, outside the repository.
Add a server under mcpServers in .cursor/mcp.json with "command": "npx" and "args": ["-y", "@bytebase/dbhub@latest", "--transport", "stdio", "--dsn", "<test container URL>"]. Keep that file out of git if it holds the URL.
Never give the agent a production or staging DSN for this work. Read-only access for production diagnostics is a different job with different servers; the options and their read-only switches are on database MCP servers. Do not use @modelcontextprotocol/server-postgres: it is deprecated and archived (npm marks 0.6.2 “no longer supported”; checked 2026-09-26).
Write database tests that only a real database can fail
Section titled “Write database tests that only a real database can fail”Ask for behaviors, not methods. “Test OrderRepository” produces one test per method that checks the return value. A list of database behaviors produces tests that fail when the database disagrees with your code.
Migrations deserve their own test. The unique index that disappeared in the opening example was lost when a migration rebuilt the table. A test that applies every migration to an empty database and then asserts the constraints you rely on (not the whole schema) catches that class of bug in seconds. Ask the agent to generate that list of constraints from the current schema, review it once, and keep the test next to the migrations.
Test message queues and async consumers
Section titled “Test message queues and async consumers”Queue tests fail in two ways that database tests do not. They are timing-dependent, so an agent reaches for setTimeout(2000) and the suite becomes slow and flaky at once. And the interesting behavior is what happens on the second delivery, which a happy-path test never triggers.
Stub third-party APIs at the network boundary
Section titled “Stub third-party APIs at the network boundary”For APIs you do not control, the test needs something that answers HTTP the way the vendor does. The choice depends on what you need the stub to prove.
| Option | Proves | Misses | Use it for |
|---|---|---|---|
In-process interception (for example MSW, msw 2.15.0 on npm on 2026-09-26) | Your client’s handling of each response, with no port to manage | Anything below the HTTP client: TLS, DNS, proxy settings | Most client-side error handling |
| Stub server on a random port | The same, plus real sockets, timeouts and connection resets | Vendor behavior you did not model | Retries, timeouts, services called from a separate process |
| Vendor-published mock, for example stripe-mock | That requests use real URLs and valid parameters, because it is generated from Stripe’s OpenAPI spec | Behavior: its README says it “does not attempt to reproduce the behavior of the real Stripe API at all”, and its responses are hardcoded | A request-shape check next to your own stub |
| The vendor’s test mode | Real behavior | Speed and determinism; needs a secret in CI | A scheduled job that catches stub drift, not every pull request |
The stub is only as true as its model of the vendor. Build it from the endpoints your production client really calls, and run the same client code against the vendor’s test mode on a schedule so drift shows up as a failed nightly job instead of a production incident.
How do you know the integration suite catches bugs without reading it?
Section titled “How do you know the integration suite catches bugs without reading it?”A green integration suite shows the code runs against real dependencies. It does not show that the assertions would notice a broken seam. Four checks give you that evidence, and all of them run without a person reading the tests.
-
Seen failing, per seam. Every prompt above ends with the agent breaking the guarded behavior and pasting the red output. Keep that report in the pull request. A test with no red run on record is not trusted to gate a merge.
-
Fault injection on the harness. Once per suite, not per test: drop a constraint, remove a transaction boundary, ack before commit, make the stub return 500. At least one test must fail for each fault. The broader practice is on error injection testing.
-
Mutation score on the repository and client layers. Run mutation testing on the files the change touched. With StrykerJS, install it once with
npm install -D @stryker-mutator/core @stryker-mutator/vitest-runner(both 10.0.0 on npm on 2026-09-26), then runnpx stryker run --testRunner vitest --mutate "src/db/order-repository.ts"and compare the score against your threshold. Without the Vitest runner, Stryker falls back tonpm test, which usually runs the unit suite, so the score says nothing about these tests. Point the runner at the integration tests withvitest.configFileinstryker.config.json, naming a Vitest config that includes onlytests/integration/**. Without the local install,npx strykerfetches the old, deprecatedstrykerpackage instead. How to set the bar is on oracle strength. -
Flake rate with retries off. Run the integration project three times in random order in CI. With Vitest, that is
npx vitest run --project integration --sequence.shuffle --retry 0, repeated three times (flags checked against Vitest 5.0.2 help). Any test that fails once and passes once is quarantined and fixed, never retried into green.
Then lock the oracle: the implementing agent must not edit the tests that judge it, and CI runs the suite from outside its diff. The per-tool setup is on protecting the oracle.
Sign-off splits the same way as for API tests. A person approves the list of behaviors per seam and the isolation strategy, because those encode product and data decisions. CI owns the rest: the suite, the fault-injection run, the mutation threshold and the flake check. The pull request carries the seen-failing report and those results as its evidence bundle.
Keep integration tests fast enough for every pull request
Section titled “Keep integration tests fast enough for every pull request”A suite that takes 20 minutes gets moved to nightly, and then it stops gating anything. Keep it on every pull request with these habits, in this order:
- One container per run, not per file. Start the database and broker in global setup and share them.
- Rollback by default, a committed schema only where needed. Truncating every table between tests costs more than a rollback.
- Seed reference data once. Countries, plans and feature flags go in a seed step; each test creates only the rows it changes.
- Run the affected files on each commit. With Vitest, pass a path filter:
npx vitest run tests/integration/orders. Run the whole project before merge. - Pin and cache images. Pin image tags, and cache the pulled images in CI so each run skips the download.
When integration tests break down
Section titled “When integration tests break down”The suite fails in the agent’s session with a Docker connection error. The agent’s sandbox cannot reach the Docker daemon, or Docker is not running. Recovery: run docker info yourself; if it works outside the session, start the containers outside the agent and pass the connection URL in through the environment. The pattern for parallel agents is on ephemeral environments.
Tests pass alone and fail together, or pass locally and fail in CI. Shared database state, a hardcoded port, or a different Postgres version. Recovery: rollback isolation, port 0 for every stub, and the pinned production major version in both places. Ask the agent to run the suite three times in random order (--sequence.shuffle in Vitest) before it reports done.
The agent made a failing test pass by weakening it. Look for expect.anything(), a widened error match, a removed assertion, or a test moved to skip. Recovery: block test edits in implementing sessions and flag diffs that reduce assertion count, as described on protecting the oracle.
The agent mocked the database inside an integration test. It hit a hard setup problem and replaced the repository with a fake to get green. Recovery: add a CI step before the integration run that fails when any file under tests/integration/ mocks your own code, for example ! grep -rnE "vi\.mock\(['\"](\.|@/|src/)" tests/integration/ (adjust the prefixes to your import aliases), and restate in the prompt that the database is real.
The concurrency test passes every time, even with the constraint removed. Both inserts ran in the same transaction or on the same connection, so there was no race. Recovery: use two connections with committed transactions, and keep the seen-failing report as proof.
Assertions depend on row order. Postgres returns rows in no guaranteed order without ORDER BY, and the test passes until a vacuum or a plan change. Recovery: sort in the query or compare as sets.
The stub drifted from the vendor. The vendor changed an error code, the stub still returns the old one, and production handles the new one wrongly. Recovery: the scheduled test-mode run fails first; regenerate the stub from the current client calls and the vendor’s API reference, and add the new code to the stub.