Skip to content

Integration 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.

SeamWhat a mock cannot catchReal dependency in the test
DatabaseUnique, foreign-key and check constraints; transaction rollback; locking and isolation; column types after a migrationPostgres (or your engine) in a container, same major version as production
Message brokerRouting keys, acknowledgement timing, redelivery, dead-letter routing, orderingRabbitMQ, Kafka or your broker in a container
CacheSerialization, TTL expiry, key collisions between tenantsRedis in a container
Third-party HTTP APIRequest shape, error codes, retries and timeouts in your clientA 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 strategyFast?Breaks when
One transaction per test, rolled back in afterEachFastestThe 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 testsFastTests inside one worker share state if truncation misses a table
Fresh container per test fileSlowRarely 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 window
# terminal, in the repository root
claude -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 Write

If 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.

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 window
# terminal: use the same Postgres major version as production
docker 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_URL

Re-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.

Terminal window
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.

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.

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.

OptionProvesMissesUse 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 manageAnything below the HTTP client: TLS, DNS, proxy settingsMost client-side error handling
Stub server on a random portThe same, plus real sockets, timeouts and connection resetsVendor behavior you did not modelRetries, timeouts, services called from a separate process
Vendor-published mock, for example stripe-mockThat requests use real URLs and valid parameters, because it is generated from Stripe’s OpenAPI specBehavior: its README says it “does not attempt to reproduce the behavior of the real Stripe API at all”, and its responses are hardcodedA request-shape check next to your own stub
The vendor’s test modeReal behaviorSpeed and determinism; needs a secret in CIA 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.

  1. 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.

  2. 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.

  3. 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 run npx stryker run --testRunner vitest --mutate "src/db/order-repository.ts" and compare the score against your threshold. Without the Vitest runner, Stryker falls back to npm test, which usually runs the unit suite, so the score says nothing about these tests. Point the runner at the integration tests with vitest.configFile in stryker.config.json, naming a Vitest config that includes only tests/integration/**. Without the local install, npx stryker fetches the old, deprecated stryker package instead. How to set the bar is on oracle strength.

  4. 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:

  1. One container per run, not per file. Start the database and broker in global setup and share them.
  2. Rollback by default, a committed schema only where needed. Truncating every table between tests costs more than a rollback.
  3. Seed reference data once. Countries, plans and feature flags go in a seed step; each test creates only the rows it changes.
  4. 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.
  5. Pin and cache images. Pin image tags, and cache the pulled images in CI so each run skips the download.

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.