Integration Test Patterns
Your unit tests mock the database and pass with flying colors. Then you deploy and discover that a Drizzle ORM migration changed a column type from varchar(255) to text, which caused your unique constraint to silently stop working. The mock never knew. Integration tests exist to catch the things that happen when real components talk to each other — the exact place where bugs hide.
What You’ll Walk Away With
Section titled “What You’ll Walk Away With”- Patterns for database integration tests with proper setup, teardown, and isolation
- API contract testing workflows that catch breaking changes across services
- Strategies for testing message queues, caches, and external service integrations
- AI prompts that generate complete test infrastructure, not just test cases
- Techniques for keeping integration tests fast enough to run in CI
Database Integration Testing
Section titled “Database Integration Testing”The Test Database Strategy
Section titled “The Test Database Strategy”Integration tests need a real database, but not your production one. Set up an isolated test database that resets between tests.
@src/lib/db/schema.ts @tests/setup.tsCreate a database integration test setup for our Drizzle ORM + PostgreSQL project:
1. A setup file that: - Creates a test database (or uses a container via testcontainers) - Runs all migrations before the test suite - Provides a clean transaction wrapper per test (rollback after each) - Exports a `getTestDb()` helper that returns an isolated database connection
2. An example test for UserRepository.create that: - Uses the real database (not mocks) - Verifies the record exists after creation - Checks unique constraint enforcement (duplicate email) - Verifies cascade deletes work correctly - Tests NULL handling for optional fields
Follow our existing test patterns in @tests/setup.tsclaude "Create a complete database integration test infrastructure:
1. Install and configure testcontainers for PostgreSQL: - Auto-start a PostgreSQL container before tests - Run migrations automatically - Provide per-test transaction isolation
2. Create /tests/integration/db-setup.ts with: - beforeAll: start container, run migrations - beforeEach: begin transaction - afterEach: rollback transaction - afterAll: stop container
3. Create /tests/integration/user-repository.integration.test.ts with tests: - Insert and retrieve a user (verify all fields round-trip correctly) - Unique email constraint (expect proper error, not crash) - Pagination (insert 50 users, verify page 2 returns correct 10) - Full-text search (if supported by our schema) - Concurrent inserts (same email, only one should succeed)
Run the tests after creating them. Fix any issues."Set up database integration testing for this project:1. Configure testcontainers for PostgreSQL2. Create test infrastructure with per-test isolation3. Generate integration tests for all repository classes4. Verify tests pass with a real database5. Add to CI pipeline configuration
Focus on testing database behaviors that unit tests with mocks cannot catch:- Constraint enforcement (unique, foreign key, check)- Transaction isolation and rollback- Query performance with realistic data volumes- Migration compatibilityAPI Contract Testing
Section titled “API Contract Testing”Consumer-Driven Contract Tests
Section titled “Consumer-Driven Contract Tests”When your service consumes another service’s API, contract tests verify the integration without running both services.
Message Queue Testing
Section titled “Message Queue Testing”Testing event-driven integrations requires verifying message publication, consumption, and ordering.
External Service Testing
Section titled “External Service Testing”The Stub Server Pattern
Section titled “The Stub Server Pattern”For external APIs you do not control (Stripe, Twilio, SendGrid), use stub servers that simulate the API behavior.
Create a stub server for the Stripe API that we can use in integration tests.
The stub should handle:1. POST /v1/charges - Return success with a charge ID2. POST /v1/charges - Return 402 when amount > 999999 (simulates declined)3. POST /v1/refunds - Return success with refund ID4. GET /v1/charges/{id} - Return charge details for known IDs, 404 for unknown
Implementation:- Use Express with a random port (for parallel test execution)- Store created charges in memory (so GET returns what POST created)- Include a reset() method to clear state between tests- Export a factory: createStripeStub() returns { url, server, reset }
Save to /tests/stubs/stripe.stub.tsclaude "Create a comprehensive Stripe API stub for integration testing.
Save to /tests/stubs/stripe.stub.ts with:- Express server on random port- In-memory charge and refund storage- Configurable error responses for testing failure paths- Request logging for test assertions- Automatic cleanup in afterAll hooks
Then create /tests/integration/payment.integration.test.ts that:- Uses the Stripe stub instead of the real API- Tests: successful payment, declined card, refund, partial refund- Verifies our service handles each Stripe response correctly- Verifies retry logic for transient failures (500 responses)
Run tests after creation."Create stub servers for all external API integrations in this project.Check /src/clients/ for the external services we call.For each external service:1. Create a stub server in /tests/stubs/2. Support success and failure responses3. Create integration tests that use the stubs4. Verify our error handling for each failure mode
Include a test helper that starts all stubs and injects their URLsinto our service configuration.Keeping Integration Tests Fast
Section titled “Keeping Integration Tests Fast”-
Parallelize with isolation
Run independent test suites in parallel. Each suite gets its own database schema or container.
-
Shared test containers
Start the database container once for the entire suite, not per test file. Use transaction rollback for per-test isolation.
-
Selective running
Tag tests and run only affected integration tests:
npm test -- --testPathPattern=integration/orderwhen working on order code. -
Database seeding vs. per-test setup
For read-only tests, seed reference data once. Only create per-test data for tests that modify it.
-
Container caching in CI
Cache Docker images in your CI pipeline to avoid pulling them on every run.
When This Breaks
Section titled “When This Breaks”“Integration tests are flaky and fail randomly in CI.” The top causes: shared database state between tests, hardcoded ports conflicting in parallel runs, and timing-dependent assertions. Use transaction rollback for isolation, random ports for services, and polling loops instead of setTimeout for async operations.
“Tests pass locally but fail in CI.” Check for differences in database versions, missing environment variables, and Docker networking. Use testcontainers to ensure the same database version runs everywhere. Pin your container images to specific versions.
“Integration tests are too slow to run on every commit.” Run only affected integration tests on commits (based on changed files). Run the full suite on PR merge and nightly. This gives fast feedback during development and comprehensive verification before merge.
“The AI generated tests that depend on insertion order.” Database query results are unordered unless you specify ORDER BY. Ask the AI to “sort results before asserting, or assert on set membership rather than array equality.”